diff --git a/app.py b/app.py index 56b2a0e0c203f3cdd84f94a6181856d95ed37ecf..6c1427cb45cfa04a211f96eb8337b1fc1961f522 100644 --- a/app.py +++ b/app.py @@ -1,168 +1,992 @@ -import streamlit as st +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +نظام واهبي للذكاء الاصطناعي لتحليل العقود والمناقصات +تطبيق Streamlit الرئيسي الذي يجمع جميع الوحدات والمكونات +""" + +import os import sys -from pathlib import Path +import streamlit as st +import pandas as pd +import numpy as np + +# تهيئة حالة الجلسة لكل وحدات النظام +if 'page' not in st.session_state: + st.session_state.page = 'home' +if 'analysis_type' not in st.session_state: + st.session_state.analysis_type = None +if 'show_document_upload' not in st.session_state: + st.session_state.show_document_upload = False +if 'report_type' not in st.session_state: + st.session_state.report_type = None +if 'show_report_form' not in st.session_state: + st.session_state.show_report_form = False +if 'analysis_result' not in st.session_state: + st.session_state.analysis_result = None +if 'current_document' not in st.session_state: + st.session_state.current_document = None +if 'current_document_text' not in st.session_state: + st.session_state.current_document_text = None +if 'loaded_files' not in st.session_state: + st.session_state.loaded_files = [] +if 'notifications' not in st.session_state: + st.session_state.notifications = [] + +# وظيفة لتهيئة حزم NLTK المطلوبة عند بدء التطبيق +def initialize_nltk_resources(): + """تنزيل وتهيئة موارد NLTK المطلوبة""" + try: + # محاولة تنزيل حزم NLTK الأساسية + import nltk + + # تحديد المسار المخصص لتنزيل NLTK data + nltk_data_path = os.path.join(os.path.expanduser("~"), "nltk_data") + os.makedirs(nltk_data_path, exist_ok=True) + nltk.data.path.append(nltk_data_path) + + # قائمة بالحزم المطلوبة + required_packages = ['punkt', 'stopwords', 'wordnet', 'omw-1.4'] + for package in required_packages: + try: + if package == 'punkt': + nltk.data.find('tokenizers/punkt') + elif package == 'stopwords': + nltk.data.find('corpora/stopwords') + elif package == 'wordnet': + nltk.data.find('corpora/wordnet') + else: + nltk.data.find(f'corpora/{package}') + except LookupError: + print(f"تنزيل حزمة NLTK: {package}") + nltk.download(package, download_dir=nltk_data_path, quiet=False) + + print("تم تهيئة موارد NLTK بنجاح.") + except Exception as e: + print(f"خطأ في تهيئة NLTK: {e}") + st.error(f"حدث خطأ أثناء تهيئة موارد NLTK: {e}") + +# تهيئة موارد NLTK عند بدء التطبيق +initialize_nltk_resources() + +# مسار نسبي للملفات الثابتة (للتأكد من العمل في بيئات مختلفة) +def get_static_path(file_path): + """مسار ملف ثابت يعمل سواء كان التشغيل من المجلد الرئيسي أو من المجلد الفرعي""" + # قائمة المسارات المحتملة + possible_paths = [ + # المسار المباشر (في حالة تشغيل التطبيق من نفس المجلد) + file_path, + # المسار النسبي من مجلد التطبيق (tender-analysis-system) + os.path.join(os.path.dirname(__file__), file_path), + # المسار النسبي من المجلد الأعلى + os.path.join(os.path.dirname(os.path.dirname(__file__)), "tender-analysis-system", file_path), + ] + + # اختبار كل مسار محتمل + for path in possible_paths: + if os.path.exists(path): + return path + + # إذا لم يتم العثور على الملف، إعادة المسار الأصلي + return file_path + +# إعداد إعدادات الصفحة +try: + st.set_page_config( + page_title="نظام WAHBi للذكاء الاصطناعي | التعاقدات والمناقصات", + page_icon="📊", + layout="wide", + initial_sidebar_state="expanded" + ) +except Exception as e: + print(f"خطأ في إعداد الصفحة: {e}") + # يحدث هذا غالبًا عند استخدام st.set_page_config أكثر من مرة + +# استيراد ملفات CSS الجديدة +try: + # تحديد مسارات الملفات + main_css_path = get_static_path("utils/css/main.css") + rtl_css_path = get_static_path("utils/css/rtl.css") + enhanced_css_path = get_static_path("utils/css/enhanced.css") + + # تحميل ملف CSS الرئيسي + if os.path.exists(main_css_path): + with open(main_css_path, "r", encoding='utf-8') as f: + main_css = f.read() + st.markdown(f"", unsafe_allow_html=True) + print(f"تم تحميل ملف CSS الرئيسي بنجاح من: {main_css_path}") + else: + print(f"تعذر العثور على ملف CSS الرئيسي: {main_css_path}") + + # تحميل ملف دعم الاتجاه من اليمين إلى اليسار + if os.path.exists(rtl_css_path): + with open(rtl_css_path, "r", encoding='utf-8') as f: + rtl_css = f.read() + st.markdown(f"", unsafe_allow_html=True) + print(f"تم تحميل ملف CSS للتوجيه RTL بنجاح من: {rtl_css_path}") + else: + print(f"تعذر العثور على ملف CSS للتوجيه RTL: {rtl_css_path}") + + # تحميل ملف التحسينات المتقدمة + if os.path.exists(enhanced_css_path): + with open(enhanced_css_path, "r", encoding='utf-8') as f: + enhanced_css = f.read() + st.markdown(f"", unsafe_allow_html=True) + print(f"تم تحميل ملف CSS المحسن بنجاح من: {enhanced_css_path}") + else: + print(f"تعذر العثور على ملف CSS المحسن: {enhanced_css_path}") + +except Exception as e: + st.warning(f"حدث خطأ أثناء تحميل ملفات CSS: {str(e)}") + print(f"خطأ في تحميل ملفات CSS: {str(e)}") + +# إضافة Font Awesome وأي أصول خارجية أخرى +st.markdown(""" + +""", unsafe_allow_html=True) + +# إضافة CSS المخصص +st.markdown(""" + +""", unsafe_allow_html=True) -# إضافة مسار المشروع للنظام -sys.path.append(str(Path(__file__).parent.parent)) +# استيراد المكونات والوحدات +from utils.components.sidebar import render_sidebar +from utils.helpers import create_directory_if_not_exists, get_data_folder -# استيراد الوحدات -from modules.document_analysis.document_app import DocumentAnalysisApp +# استيراد وحدات التطبيق from modules.pricing.pricing_app import PricingApp +from modules.projects.projects_app import ProjectsApp from modules.resources.resources_app import ResourcesApp -from modules.risk_analysis.risk_analyzer import RiskAnalysisApp -from modules.project_management.project_management_app import ProjectsApp +from modules.risk_assessment.risk_assessment_app import RiskAssessmentApp +from modules.project_tracker.tracker_app import TrackerApp from modules.maps.maps_app import MapsApp from modules.notifications.notifications_app import NotificationsApp -from modules.document_comparison.document_comparison_app import DocumentComparisonApp -from modules.translation.translation_app import TranslationApp -from modules.ai_assistant.ai_app import AIAssistantApp -from modules.data_analysis.data_analysis_app import DataAnalysisApp -from styling.enhanced_ui import UIEnhancer - -# تكوين الصفحة -st.set_page_config( - page_title="نظام تحليل المناقصات", - page_icon="📊", - layout="wide", - initial_sidebar_state="expanded", - menu_items={ - 'Get Help': 'https://www.example.com/help', - 'Report a bug': "https://www.example.com/bug", - 'About': "### نظام تحليل المناقصات\nالإصدار 2.0.0" - } -) - -# تطبيق التنسيق العام -ui_enhancer = UIEnhancer(page_title="نظام تحليل المناقصات", page_icon="📊") -ui_enhancer.apply_global_styles() - -# إنشاء قائمة العناصر -menu_items = [ - {"name": "لوحة المعلومات", "icon": "house"}, - {"name": "المناقصات والعقود", "icon": "file-text"}, - {"name": "تحليل المستندات", "icon": "file-earmark-text"}, - {"name": "نظام التسعير", "icon": "calculator"}, - {"name": "حاسبة تكاليف البناء", "icon": "building"}, - {"name": "الموارد والتكاليف", "icon": "people"}, - {"name": "تحليل المخاطر", "icon": "exclamation-triangle"}, - {"name": "إدارة المشاريع", "icon": "kanban"}, - {"name": "الخرائط والمواقع", "icon": "geo-alt"}, - {"name": "الجدول الزمني", "icon": "calendar3"}, - {"name": "الإشعارات", "icon": "bell"}, - {"name": "مقارنة المستندات", "icon": "files"}, - {"name": "الترجمة", "icon": "translate"}, - {"name": "المساعد الذكي", "icon": "robot"}, - {"name": "تحليل البيانات", "icon": "bar-chart"}, - {"name": "الإعدادات", "icon": "gear"} -] - -# إنشاء الشريط الجانبي -selected = ui_enhancer.create_sidebar(menu_items) - -# تحديد الوحدة المطلوبة بناءً على اختيار المستخدم -if selected == "لوحة المعلومات": - ui_enhancer.create_header("لوحة المعلومات", "نظرة عامة على المناقصات والمشاريع") - - # عرض لوحة المعلومات +from modules.voice_narration.voice_narration_app import VoiceNarrationApp +from modules.achievements.achievements_app import AchievementsApp +from modules.ai_finetuning.finetuning_app import FinetuningApp +from modules.document_comparison.comparison_app import DocumentComparisonApp + +# إنشاء مجلدات البيانات الضرورية +create_directory_if_not_exists(get_data_folder()) +create_directory_if_not_exists(os.path.join(get_data_folder(), "projects")) +create_directory_if_not_exists(os.path.join(get_data_folder(), "documents")) +create_directory_if_not_exists(os.path.join(get_data_folder(), "analysis")) + +def main(): + """الدالة الرئيسية للتطبيق""" + + # تقديم الشريط الجانبي وتلقي الوحدة المختارة + selected_module = render_sidebar() + + # إذا كان المستخدم غير مصرح له، قم بإظهار شاشة تسجيل الدخول + if "is_authenticated" in st.session_state and not st.session_state.is_authenticated: + render_login_screen() + return + + # إظهار الوحدة المختارة + if selected_module == "الرئيسية": + render_homepage() + + elif selected_module == "إدارة المشاريع": + projects_app = ProjectsApp() + projects_app.render() + + elif selected_module == "التسعير المتكاملة": + pricing_app = PricingApp() + pricing_app.render() + + elif selected_module == "الموارد والتكاليف": + resources_app = ResourcesApp() + resources_app.render() + + elif selected_module == "تحليل المستندات": + # تقديم واجهة تحليل المستندات + render_document_analysis() + + elif selected_module == "مقارنة المستندات": + # تقديم واجهة مقارنة المستندات + comparison_app = DocumentComparisonApp() + comparison_app.render() + + elif selected_module == "تقييم مخاطر العقود": + risk_app = RiskAssessmentApp() + risk_app.render() + + elif selected_module == "التقارير والتحليلات": + # تقديم واجهة التقارير والتحليلات + render_reports_and_analytics() + + elif selected_module == "متتبع حالة المشروع": + tracker_app = TrackerApp() + tracker_app.render() + + elif selected_module == "خريطة المشاريع": + maps_app = MapsApp() + maps_app.render() + + elif selected_module == "نظام الإشعارات": + notifications_app = NotificationsApp() + notifications_app.render() + + elif selected_module == "الترجمة الصوتية": + voice_app = VoiceNarrationApp() + voice_app.render() + + elif selected_module == "نظام الإنجازات": + achievements_app = AchievementsApp() + achievements_app.render() + + elif selected_module == "المساعد الذكي": + # تقديم واجهة المساعد الذكي + render_ai_assistant() + + elif selected_module == "ضبط نماذج الذكاء الاصطناعي": + finetuning_app = FinetuningApp() + finetuning_app.render() + + else: + st.error("الوحدة المطلوبة غير موجودة") + + +def render_login_screen(): + """عرض شاشة تسجيل الدخول""" + st.markdown("

نظام WAHBi للذكاء الاصطناعي

", unsafe_allow_html=True) + + st.markdown(""" +
+

تسجيل الدخول

+

يرجى إدخال بيانات الاعتماد الخاصة بك للوصول إلى النظام.

+
+ """, unsafe_allow_html=True) + + col1, col2, col3 = st.columns([1, 2, 1]) + + with col2: + username = st.text_input("اسم المستخدم") + password = st.text_input("كلمة المرور", type="password") + + if st.button("تسجيل الدخول"): + # تنفيذ منطق المصادقة + if username == "admin" and password == "admin": # بيانات اعتماد مؤقتة للتطوير + st.session_state.is_authenticated = True + st.session_state.user_info = { + "id": 1, + "username": "admin", + "full_name": "مدير النظام", + "email": "admin@example.com", + "role": "مدير", + "department": "الإدارة", + "last_login": "2023-01-01 09:00:00" + } + st.rerun() + else: + st.error("اسم المستخدم أو كلمة المرور غير صحيحة") + + st.markdown(""" +
+

نظام WAHBi للذكاء الاصطناعي © 2025 شركة شبه الجزيرة للمقاولات

+

جميع الحقوق محفوظة

+
+ """, unsafe_allow_html=True) + + +def render_homepage(): + """عرض الصفحة الرئيسية للتطبيق""" + st.markdown("

نظام WAHBi للذكاء الاصطناعي

", unsafe_allow_html=True) + st.markdown("
نظام متكامل لتحليل العقود والمناقصات باستخدام تقنيات الذكاء الاصطناعي المتقدمة
", unsafe_allow_html=True) + + # عرض مؤشرات الأداء الرئيسية + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.markdown(""" +
+
24
+
المناقصات النشطة
+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+
8
+
مشاريع قيد التنفيذ
+
+ """, unsafe_allow_html=True) + + with col3: + st.markdown(""" +
+
12
+
مستندات قيد التحليل
+
+ """, unsafe_allow_html=True) + + with col4: + st.markdown(""" +
+
5
+
تنبيهات تتطلب الاهتمام
+
+ """, unsafe_allow_html=True) + + # عرض المشاريع الأخيرة والوصول السريع + col1, col2 = st.columns([2, 1]) + + with col1: + st.markdown("### المناقصات الأخيرة") + + st.markdown(""" +
+
إنشاء طريق سريع بمنطقة الرياض
+
رقم المناقصة: TR-2025-134
+
الجهة المالكة: وزارة النقل
+
تاريخ الإغلاق: 15 أبريل 2025
+
+
+
85%
+
نسبة الإنجاز
+
+
+
متوسطة
+
المخاطر
+
+
+
مرتفعة
+
الأولوية
+
+
+
+ +
+
تطوير شبكة الصرف الصحي بالمنطقة الشرقية
+
رقم المناقصة: WS-2025-089
+
الجهة المالكة: وزارة المياه
+
تاريخ الإغلاق: 22 أبريل 2025
+
+
+
62%
+
نسبة الإنجاز
+
+
+
مرتفعة
+
المخاطر
+
+
+
مرتفعة
+
الأولوية
+
+
+
+ +
+
بناء 3 مدارس بمنطقة مكة المكرمة
+
رقم المناقصة: ED-2025-112
+
الجهة المالكة: وزارة التعليم
+
تاريخ الإغلاق: 5 مايو 2025
+
+
+
38%
+
نسبة الإنجاز
+
+
+
منخفضة
+
المخاطر
+
+
+
متوسطة
+
الأولوية
+
+
+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown("### الوصول السريع") + + st.markdown(""" +
+ + + + + + + + + +
+ """, unsafe_allow_html=True) + + st.markdown("### آخر التنبيهات") + + st.markdown(""" +
+
انتهاء موعد تقديم المناقصة
+
مشروع إنشاء الطريق السريع - متبقي 3 أيام
+
+ +
+
تغيير في شروط المناقصة
+
تم تحديث مستندات مشروع شبكة الصرف الصحي
+
+ +
+
إكمال تحليل المستند
+
اكتمل تحليل عقد بناء المدارس بنجاح
+
+ """, unsafe_allow_html=True) + + # معلومات حول النظام + st.markdown("---") + + st.markdown(""" +
+

حول النظام

+

نظام WAHBi للذكاء الاصطناعي هو نظام متكامل لتحليل العقود والمناقصات وإدارة المشاريع، مصمم خصيصاً لشركات المقاولات والبناء. يستخدم النظام تقنيات الذكاء الاصطناعي المتقدمة لتحليل المستندات واستخراج المعلومات المهمة وتقييم المخاطر ودعم اتخاذ القرار.

+
+ """, unsafe_allow_html=True) + + # معلومات الشركة + st.markdown(""" +
+

هذا النظام يعمل لشركة شبه الجزيرة للمقاولات

+

جميع الحقوق محفوظة 2025

+
+ """, unsafe_allow_html=True) + + +def render_document_analysis(): + """عرض واجهة تحليل المستندات""" + st.markdown("

تحليل المستندات

", unsafe_allow_html=True) + + st.markdown(""" +
+

استخدم هذه الوحدة لتحليل مستندات العقود والمناقصات باستخدام تقنيات الذكاء الاصطناعي المتقدمة. + يمكنك تحميل المستندات بتنسيقات PDF أو Word وسيقوم النظام بتحليلها واستخراج المعلومات المهمة مثل الشروط والتكاليف والمخاطر والتزاماتك كمقاول.

+
+ """, unsafe_allow_html=True) + + # أدوات التحليل + st.markdown("### أدوات التحليل:", unsafe_allow_html=True) + col1, col2, col3 = st.columns(3) with col1: - ui_enhancer.create_metric_card("المناقصات النشطة", "12", "+3", ui_enhancer.COLORS['primary']) + st.markdown(""" +
+
تحليل العقد الشامل
+

تحليل شامل للعقد باستخدام Claude AI لاستخراج جميع البنود والشروط والالتزامات والمواعيد النهائية.

+
+ """, unsafe_allow_html=True) + + if st.button("تحليل جديد", key="btn_complete_analysis"): + # هنا سيتم استدعاء وحدة تحليل العقد الشامل + st.session_state.analysis_type = "complete" + st.session_state.show_document_upload = True + st.rerun() with col2: - ui_enhancer.create_metric_card("المشاريع قيد التنفيذ", "8", "+1", ui_enhancer.COLORS['success']) + st.markdown(""" +
+
تحليل جداول الكميات
+

تحليل متخصص لجداول الكميات (BOQ) لاستخراج قوائم المواد والكميات والأسعار والتكاليف الإجمالية.

+
+ """, unsafe_allow_html=True) + + if st.button("تحليل جديد", key="btn_boq_analysis"): + # هنا سيتم استدعاء وحدة تحليل جداول الكميات + st.session_state.analysis_type = "boq" + st.session_state.show_document_upload = True + st.rerun() with col3: - ui_enhancer.create_metric_card("المناقصات المقدمة", "24", "+5", ui_enhancer.COLORS['info']) + st.markdown(""" +
+
تحليل الشروط والأحكام
+

تحليل متخصص للشروط والأحكام في العقد لتحديد الشروط الغير عادية أو المقيدة والمخاطر القانونية.

+
+ """, unsafe_allow_html=True) + + if st.button("تحليل جديد", key="btn_terms_analysis"): + # هنا سيتم استدعاء وحدة تحليل الشروط والأحكام + st.session_state.analysis_type = "terms" + st.session_state.show_document_upload = True + st.rerun() - # عرض الإشعارات الأخيرة - st.markdown("### الإشعارات الأخيرة") + # التحليلات الأخيرة + st.markdown("### التحليلات الأخيرة") - notifications = [ - {"title": "موعد تقديم مناقصة", "project": "إنشاء مبنى مستشفى الولادة والأطفال", "date": "2025-04-05", "priority": "عالية"}, - {"title": "تحديث مستندات", "project": "صيانة وتطوير طريق الملك عبدالله", "date": "2025-03-28", "priority": "متوسطة"}, - {"title": "اجتماع مراجعة التسعير", "project": "إنشاء محطة معالجة مياه الصرف الصحي", "date": "2025-03-25", "priority": "عالية"} - ] + st.markdown(""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
اسم المستندنوع التحليلتاريخ التحليلالحالةالإجراءات
عقد إنشاء طريق سريع.pdfتحليل شامل2023-03-25مكتمل + + +
جداول كميات مشروع صرف صحي.xlsxتحليل جداول الكميات2023-03-23مكتمل + + +
شروط وأحكام عقد بناء مدارس.pdfتحليل الشروط والأحكام2023-03-20مكتمل + + +
ملحق عقد مشروع كباري.pdfتحليل شامل2023-03-18قيد المعالجة + + +
+ """, unsafe_allow_html=True) - for notification in notifications: - with st.container(): - col1, col2 = st.columns([4, 1]) - with col1: - st.markdown(f"**{notification['title']}** - {notification['project']}") - st.caption(f"التاريخ: {notification['date']} | الأولوية: {notification['priority']}") - with col2: - st.button("عرض", key=f"view_{notification['title']}") - st.divider() - -elif selected == "تحليل المستندات": - document_app = DocumentAnalysisApp() - document_app.run() - -elif selected == "نظام التسعير": - pricing_app = PricingApp() - pricing_app.run() - -elif selected == "الموارد والتكاليف": - resources_app = ResourcesApp() - resources_app.run() - -elif selected == "تحليل المخاطر": - risk_app = RiskAnalysisApp() - risk_app.run() - -elif selected == "إدارة المشاريع": - projects_app = ProjectsApp() - projects_app.run() - -elif selected == "الخرائط والمواقع": - maps_app = MapsApp() - maps_app.run() - -elif selected == "الإشعارات": - notifications_app = NotificationsApp() - notifications_app.run() - -elif selected == "مقارنة المستندات": - document_comparison_app = DocumentComparisonApp() - document_comparison_app.run() - -elif selected == "الترجمة": - translation_app = TranslationApp() - translation_app.run() - -elif selected == "المساعد الذكي": - ai_app = AIAssistantApp() - ai_app.run() - -elif selected == "تحليل البيانات": - data_analysis_app = DataAnalysisApp() - data_analysis_app.run() - -elif selected == "الإعدادات": - ui_enhancer.create_header("الإعدادات", "إعدادات النظام والحساب") - - # عرض إعدادات النظام - st.markdown("### إعدادات النظام") - - tabs = st.tabs(["إعدادات عامة", "الواجهة", "الأمان", "مفاتيح API"]) - - with tabs[0]: - st.checkbox("تفعيل الإشعارات", value=True) - st.checkbox("حفظ تلقائي للبيانات", value=True) - st.selectbox("اللغة", ["العربية", "English"]) - st.selectbox("المنطقة الزمنية", ["توقيت الرياض (GMT+3)", "توقيت جرينتش (GMT)"]) - - with tabs[1]: - st.radio("النمط", ["فاتح", "داكن", "تلقائي (حسب نظام التشغيل)"]) - st.slider("حجم الخط", 12, 20, 16) - st.color_picker("لون التمييز", "#1E88E5") - - with tabs[2]: - st.checkbox("تفعيل المصادقة الثنائية", value=False) - st.number_input("مدة الجلسة (دقائق)", min_value=5, max_value=120, value=30) - st.button("تغيير كلمة المرور") - - with tabs[3]: - st.text_input("مفتاح OpenAI API", type="password") - st.text_input("مفتاح Google Maps API", type="password") - st.button("حفظ مفاتيح API") + # إحصائيات التحليل + st.markdown("### إحصائيات التحليل") + + col1, col2 = st.columns(2) + + with col1: + st.markdown(""" +
+

توزيع أنواع المستندات

+
+
+ عقود ومناقصات + 45% +
+
+
+
+
+ +
+
+ جداول كميات + 30% +
+
+
+
+
+ +
+
+ شروط وأحكام + 15% +
+
+
+
+
+ +
+
+ مستندات أخرى + 10% +
+
+
+
+
+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+

إحصائيات التحليل الشهرية

+
+
+
42
+
مستند تم تحليله
+
+
+
38
+
تحليل ناجح
+
+
+
4
+
تحليل غير مكتمل
+
+
+ +

متوسط وقت المعالجة

+
+
تحليل شامل:
+
+
+
+
2:30
+
+ +
+
جداول الكميات:
+
+
+
+
1:45
+
+ +
+
الشروط والأحكام:
+
+
+
+
2:00
+
+
+ """, unsafe_allow_html=True) + + +def render_reports_and_analytics(): + """عرض واجهة التقارير والتحليلات""" + st.markdown("

التقارير والتحليلات

", unsafe_allow_html=True) + + st.markdown(""" +
+

استخدم هذه الوحدة لإنشاء تقارير تحليلية متقدمة عن المشاريع والمناقصات والأداء العام. + يوفر النظام رؤى وتحليلات متعمقة تساعدك على فهم أداء مشاريعك وتحسين عمليات صنع القرار.

+
+ """, unsafe_allow_html=True) + + # أنواع التقارير + st.markdown("### أنواع التقارير") + + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown(""" +
+
تقارير المشاريع
+

تقارير تفصيلية عن حالة المشاريع وتقدمها ومؤشرات الأداء الرئيسية والمشكلات المحتملة.

+
+ """, unsafe_allow_html=True) + + if st.button("إنشاء تقرير", key="btn_project_report"): + # هنا سيتم استدعاء وحدة إنشاء تقارير المشاريع + st.session_state.report_type = "project" + st.session_state.show_report_form = True + st.rerun() + + with col2: + st.markdown(""" +
+
تقارير الأداء المالي
+

تحليل مالي للمشاريع يتضمن الإيرادات والتكاليف والأرباح والتدفقات النقدية والانحرافات عن الميزانية.

+
+ """, unsafe_allow_html=True) + + if st.button("إنشاء تقرير", key="btn_financial_report"): + # هنا سيتم استدعاء وحدة إنشاء تقارير الأداء المالي + st.session_state.report_type = "financial" + st.session_state.show_report_form = True + st.rerun() + + with col3: + st.markdown(""" +
+
تقارير المناقصات
+

تحليل شامل للمناقصات النشطة والمنتهية ونسب الفوز والمنافسين ومقارنة الأسعار.

+
+ """, unsafe_allow_html=True) + + if st.button("إنشاء تقرير", key="btn_tender_report"): + # هنا سيتم استدعاء وحدة إنشاء تقارير المناقصات + st.session_state.report_type = "tender" + st.session_state.show_report_form = True + st.rerun() + + # لوحة البيانات + st.markdown("### لوحة البيانات التنفيذية") + + col1, col2 = st.columns([2, 1]) + + with col1: + st.markdown("#### أداء المشاريع حسب القطاع") + + # إنشاء بيانات تجريبية للرسم البياني + sectors = ['البنية التحتية', 'السكني', 'التعليمي', 'الصحي', 'النقل'] + performance = [85, 72, 64, 90, 78] + + # إنشاء رسم بياني شريطي + chart_data = pd.DataFrame({'القطاع': sectors, 'الأداء (%)': performance}) + st.bar_chart(chart_data.set_index('القطاع'), use_container_width=True) + + # عرض بيان توضيحي + st.caption("مقارنة أداء المشاريع عبر القطاعات المختلفة (نسبة الإنجاز)") + + with col2: + st.markdown("#### المؤشرات الرئيسية") + + # نسبة المشاريع المتأخرة + st.markdown("##### نسبة المشاريع المتأخرة") + delayed_projects = 15 + st.progress(delayed_projects / 100) + st.markdown(f"

{delayed_projects}%

", unsafe_allow_html=True) + + # متوسط هامش الربح + st.markdown("##### متوسط هامش الربح") + profit_margin = 22 + st.progress(profit_margin / 100) + st.markdown(f"

{profit_margin}%

", unsafe_allow_html=True) + + # معدل نجاح المناقصات + st.markdown("##### معدل نجاح المناقصات") + tender_success = 35 + st.progress(tender_success / 100) + st.markdown(f"

{tender_success}%

", unsafe_allow_html=True) + + # تقارير الأداء + st.markdown("### تقارير الأداء الأخيرة") + + # التقرير الأول + with st.container(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown("#### التقرير الشهري لمشاريع الربع الأول 2025") + st.markdown("تقرير شامل يوضح أداء جميع المشاريع النشطة خلال الربع الأول من عام 2025، بما في ذلك تحليل التكاليف والجدول الزمني والمخاطر.") + st.markdown("**تاريخ الإنشاء:** 15 مارس 2025") + + with col2: + st.markdown("
", unsafe_allow_html=True) # إضافة مسافة + col2_1, col2_2 = st.columns(2) + with col2_1: + if st.button("عرض", key="view_report1"): + st.session_state.view_report = "quarterly_q1_2025" + st.session_state.show_report_viewer = True + with col2_2: + if st.button("تنزيل", key="download_report1"): + st.info("جاري تحميل التقرير...") + + st.markdown("---") + + # التقرير الثاني + with st.container(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown("#### تحليل أداء المناقصات 2024-2025") + st.markdown("تحليل مقارن لنتائج المناقصات بين عامي 2024 و 2025، يوضح التحسن في معدلات النجاح وتحليل أسباب الخسارة وفرص التحسين.") + st.markdown("**تاريخ الإنشاء:** 28 فبراير 2025") + + with col2: + st.markdown("
", unsafe_allow_html=True) # إضافة مسافة + col2_1, col2_2 = st.columns(2) + with col2_1: + if st.button("عرض", key="view_report2"): + st.session_state.view_report = "tenders_analysis_2024_2025" + st.session_state.show_report_viewer = True + with col2_2: + if st.button("تنزيل", key="download_report2"): + st.info("جاري تحميل التقرير...") + + st.markdown("---") + + # التقرير الثالث + with st.container(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown("#### تقرير المخاطر المالية للمشاريع الجارية") + st.markdown("تقرير تفصيلي حول المخاطر المالية للمشاريع الجارية، بما في ذلك تحليل التدفقات النقدية والمستحقات المتأخرة والمطالبات المحتملة.") + st.markdown("**تاريخ الإنشاء:** 10 فبراير 2025") + + with col2: + st.markdown("
", unsafe_allow_html=True) # إضافة مسافة + col2_1, col2_2 = st.columns(2) + with col2_1: + if st.button("عرض", key="view_report3"): + st.session_state.view_report = "financial_risks_2025" + st.session_state.show_report_viewer = True + with col2_2: + if st.button("تنزيل", key="download_report3"): + st.info("جاري تحميل التقرير...") + + +def render_ai_assistant(): + """عرض واجهة المساعد الذكي باستخدام المكون الجديد""" + try: + from modules.ai_assistant.assistant_app import AssistantApp + + # عرض العنوان والوصف + st.markdown("

المساعد الذكي

", unsafe_allow_html=True) + + st.markdown(""" +
+

المساعد الذكي هو واجهة تفاعلية مدعومة بتقنيات الذكاء الاصطناعي لمساعدتك في جميع أنشطة إدارة المشاريع والعقود. + يمكنك طرح أسئلة بلغتك الطبيعية والحصول على إجابات فورية، أو طلب مساعدة في مهام محددة مثل تحليل بنود العقد أو تقدير التكاليف.

+
+ """, unsafe_allow_html=True) + + # استدعاء المساعد الذكي الجديد + ai_assistant = AssistantApp() + ai_assistant.render() + + except Exception as e: + st.error(f"حدث خطأ في تحميل المساعد الذكي: {str(e)}") + st.markdown(""" +
+

😞 عذراً، واجهنا مشكلة في تحميل المساعد الذكي

+

يرجى المحاولة مرة أخرى لاحقاً أو التواصل مع فريق الدعم الفني إذا استمرت المشكلة.

+
+ """, unsafe_allow_html=True) + + +# تشغيل التطبيق عند تنفيذ الملف مباشرة +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/config.py b/config.py index f8c4dc4ef601f6f3115ea9391ad69f177e47124c..cc11ab735b3f7e7f7fef5036f16f78e7013e965f 100644 --- a/config.py +++ b/config.py @@ -1,229 +1,62 @@ -""" -ملف الإعدادات لنظام إدارة المناقصات -""" - -import os -import json -from pathlib import Path - -class AppConfig: - """فئة إعدادات التطبيق""" - - def __init__(self): - """تهيئة الإعدادات""" - # المسارات الأساسية - self.app_dir = os.path.dirname(os.path.abspath(__file__)) - self.assets_dir = os.path.join(self.app_dir, "assets") - self.data_dir = os.path.join(self.app_dir, "data") - - # إنشاء المجلدات إذا لم تكن موجودة - Path(self.assets_dir).mkdir(parents=True, exist_ok=True) - Path(self.data_dir).mkdir(parents=True, exist_ok=True) - - # مسارات الأصول - self.icons_dir = os.path.join(self.assets_dir, "icons") - self.images_dir = os.path.join(self.assets_dir, "images") - self.fonts_dir = os.path.join(self.assets_dir, "fonts") - - # إنشاء مجلدات الأصول إذا لم تكن موجودة - Path(self.icons_dir).mkdir(parents=True, exist_ok=True) - Path(self.images_dir).mkdir(parents=True, exist_ok=True) - Path(self.fonts_dir).mkdir(parents=True, exist_ok=True) - - # مسارات البيانات - self.database_file = os.path.join(self.data_dir, "database.db") - self.settings_file = os.path.join(self.data_dir, "settings.json") - self.charts_dir = os.path.join(self.data_dir, "charts") - - # إنشاء مجلد الرسوم البيانية إذا لم يكن موجودًا - Path(self.charts_dir).mkdir(parents=True, exist_ok=True) - - # تحميل الإعدادات - self.settings = self._load_settings() - - def _load_settings(self): - """تحميل الإعدادات من ملف JSON""" - default_settings = { - "app": { - "name": "نظام إدارة المناقصات", - "version": "1.0.0", - "language": "ar", - "theme": "light", - "font": "Cairo", - "font_size": 12 - }, - "database": { - "type": "sqlite", - "path": self.database_file - }, - "ui": { - "window_width": 1200, - "window_height": 800, - "sidebar_width": 250 - }, - "notifications": { - "enabled": True, - "email_enabled": True, - "email_server": "smtp.example.com", - "email_port": 587, - "email_username": "", - "email_password": "" - }, - "reports": { - "default_format": "pdf", - "default_path": os.path.join(self.data_dir, "reports") - }, - "backup": { - "auto_backup": True, - "backup_frequency": "weekly", - "backup_path": os.path.join(self.data_dir, "backups"), - "max_backups": 10 - } - } - - # إذا كان ملف الإعدادات موجودًا، قم بتحميله - if os.path.exists(self.settings_file): - try: - with open(self.settings_file, "r", encoding="utf-8") as f: - settings = json.load(f) - - # دمج الإعدادات المحملة مع الإعدادات الافتراضية - self._merge_settings(default_settings, settings) - return default_settings - except Exception as e: - print(f"خطأ في تحميل الإعدادات: {str(e)}") - return default_settings - else: - # إنشاء ملف الإعدادات الافتراضية - self._save_settings(default_settings) - return default_settings - - def _merge_settings(self, default_settings, loaded_settings): - """دمج الإعدادات المحملة مع الإعدادات الافتراضية""" - for key, value in loaded_settings.items(): - if key in default_settings: - if isinstance(value, dict) and isinstance(default_settings[key], dict): - self._merge_settings(default_settings[key], value) - else: - default_settings[key] = value - - def _save_settings(self, settings=None): - """حفظ الإعدادات إلى ملف JSON""" - if settings is None: - settings = self.settings - - try: - with open(self.settings_file, "w", encoding="utf-8") as f: - json.dump(settings, f, ensure_ascii=False, indent=4) - return True - except Exception as e: - print(f"خطأ في حفظ الإعدادات: {str(e)}") - return False - - def get_setting(self, section, key, default=None): - """الحصول على قيمة إعداد معين""" - try: - return self.settings[section][key] - except KeyError: - return default - - def set_setting(self, section, key, value): - """تعيين قيمة إعداد معين""" - if section not in self.settings: - self.settings[section] = {} - - self.settings[section][key] = value - self._save_settings() - - def get_app_name(self): - """الحصول على اسم التطبيق""" - return self.get_setting("app", "name", "نظام إدارة المناقصات") - - def get_app_version(self): - """الحصول على إصدار التطبيق""" - return self.get_setting("app", "version", "1.0.0") - - def get_language(self): - """الحصول على لغة التطبيق""" - return self.get_setting("app", "language", "ar") - - def set_language(self, language): - """تعيين لغة التطبيق""" - self.set_setting("app", "language", language) - - def get_theme(self): - """الحصول على نمط التطبيق""" - return self.get_setting("app", "theme", "light") - - def set_theme(self, theme): - """تعيين نمط التطبيق""" - self.set_setting("app", "theme", theme) - - def get_font(self): - """الحصول على خط التطبيق""" - return self.get_setting("app", "font", "Cairo") - - def set_font(self, font): - """تعيين خط التطبيق""" - self.set_setting("app", "font", font) - - def get_font_size(self): - """الحصول على حجم خط التطبيق""" - return self.get_setting("app", "font_size", 12) - - def set_font_size(self, font_size): - """تعيين حجم خط التطبيق""" - self.set_setting("app", "font_size", font_size) - - def get_window_size(self): - """الحصول على حجم نافذة التطبيق""" - width = self.get_setting("ui", "window_width", 1200) - height = self.get_setting("ui", "window_height", 800) - return (width, height) - - def set_window_size(self, width, height): - """تعيين حجم نافذة التطبيق""" - self.set_setting("ui", "window_width", width) - self.set_setting("ui", "window_height", height) - - def get_sidebar_width(self): - """الحصول على عرض الشريط الجانبي""" - return self.get_setting("ui", "sidebar_width", 250) - - def set_sidebar_width(self, width): - """تعيين عرض الشريط الجانبي""" - self.set_setting("ui", "sidebar_width", width) - - def get_database_config(self): - """الحصول على إعدادات قاعدة البيانات""" - return self.settings.get("database", { - "type": "sqlite", - "path": self.database_file - }) - - def get_notifications_config(self): - """الحصول على إعدادات الإشعارات""" - return self.settings.get("notifications", { - "enabled": True, - "email_enabled": True, - "email_server": "smtp.example.com", - "email_port": 587, - "email_username": "", - "email_password": "" - }) - - def get_reports_config(self): - """الحصول على إعدادات التقارير""" - return self.settings.get("reports", { - "default_format": "pdf", - "default_path": os.path.join(self.data_dir, "reports") - }) - - def get_backup_config(self): - """الحصول على إعدادات النسخ الاحتياطي""" - return self.settings.get("backup", { - "auto_backup": True, - "backup_frequency": "weekly", - "backup_path": os.path.join(self.data_dir, "backups"), - "max_backups": 10 - }) +""" +ملف إعدادات النظام +""" + +import os +from pathlib import Path + +# مسارات النظام +ROOT_DIR = Path(__file__).parent +STATIC_DIR = os.path.join(ROOT_DIR, 'static') +MODELS_DIR = os.path.join(ROOT_DIR, 'models') +DATA_DIR = os.path.join(ROOT_DIR, 'database', 'data') + +# عنوان التطبيق +APP_TITLE = "النظام الشامل لتحليل العقود والمناقصات - شركة شبه الجزيرة للمقاولات" +APP_ICON = "📋" + +# إعدادات قاعدة البيانات +DB_TYPE = "sqlite" # يمكن استبدالها بـ 'mysql' أو 'postgresql' +DB_PATH = os.path.join(DATA_DIR, "tender_db.sqlite") + +# إعدادات أخرى +DEBUG_MODE = True +LOG_LEVEL = "INFO" +LOCALE = "ar_SA" + +# مسارات النماذج المدربة +NLP_ARABIC_MODEL = os.path.join(MODELS_DIR, "trained", "arabic_nlp_model.h5") +RISK_ANALYSIS_MODEL = os.path.join(MODELS_DIR, "trained", "risk_analysis_model.pkl") +PRICE_PREDICTION_MODEL = os.path.join(MODELS_DIR, "trained", "price_prediction_model.pkl") + +# تكوين واجهة المستخدم +UI_THEME = "light" # 'light' أو 'dark' +ENABLE_ANIMATIONS = True +DEFAULT_MODULE = "الرئيسية" + +# تكوين المحتوى المحلي +LOCAL_CONTENT_CATEGORIES = ["القوى العاملة", "المنتجات", "الخدمات"] +LOCAL_CONTENT_TARGETS = { + "القوى العاملة": 0.8, # 80% + "المنتجات": 0.7, # 70% + "الخدمات": 0.6 # 60% +} + +# تكوين التسعير +PRICING_METHODS = [ + "التسعير القياسي", + "التسعير غير المتزن", + "التسعير التنافسي", + "التسعير الموجه بالربحية" +] + +DEFAULT_OVERHEAD_PERCENTAGE = 15 # النسبة الافتراضية للمصاريف العامة والأرباح + +# إعدادات تحليل المستندات +SUPPORTED_DOCUMENT_TYPES = ["pdf", "docx", "xlsx", "dwg", "jpg", "png"] +MAX_UPLOAD_SIZE_MB = 20 + +# إعدادات API الذكاء الاصطناعي +AI_API_ENABLED = True +AI_API_ENDPOINT = "http://localhost:8000/api/v1" +AI_API_KEY = "YOUR_API_KEY_HERE" # يجب استبدالها في بيئة الإنتاج \ No newline at end of file diff --git a/data/achievements/user_1_achievements.json b/data/achievements/user_1_achievements.json new file mode 100644 index 0000000000000000000000000000000000000000..73af4aeaef7ec2c6d3b6034dc37a508b3a4f05f5 --- /dev/null +++ b/data/achievements/user_1_achievements.json @@ -0,0 +1,82 @@ +{ + "user_id": 1, + "total_points": 450, + "level": 1, + "unlocked_achievements": [ + { + "id": "first_project", + "name": "بداية الرحلة", + "description": "قم بإنشاء مشروعك الأول", + "icon": "🏆", + "points": 100, + "category": "مشاريع", + "difficulty": "سهل", + "unlocked_date": "2025-03-15 14:32:05" + }, + { + "id": "first_document_analysis", + "name": "المحلل الأول", + "description": "قم بتحليل مستند للمرة الأولى", + "icon": "📊", + "points": 150, + "category": "تحليل", + "difficulty": "سهل", + "unlocked_date": "2025-03-17 09:45:22" + }, + { + "id": "voice_narration", + "name": "مترجم صوتي", + "description": "استخدم ميزة الترجمة الصوتية لأول مرة", + "icon": "🎙️", + "points": 200, + "category": "ترجمة", + "difficulty": "سهل", + "unlocked_date": "2025-03-30 12:15:30" + } + ], + "in_progress_achievements": [ + { + "id": "five_projects", + "name": "محترف المشاريع", + "description": "قم بإنشاء خمسة مشاريع", + "icon": "🏅", + "points": 500, + "category": "مشاريع", + "difficulty": "متوسط", + "progress": 2, + "total": 5, + "percentage": 40, + "start_date": "2025-03-15 14:32:05", + "last_updated": "2025-03-28 16:10:45" + }, + { + "id": "five_document_analysis", + "name": "محلل متمرس", + "description": "قم بتحليل خمسة مستندات", + "icon": "📈", + "points": 600, + "category": "تحليل", + "difficulty": "متوسط", + "progress": 3, + "total": 5, + "percentage": 60, + "start_date": "2025-03-17 09:45:22", + "last_updated": "2025-03-29 11:20:18" + }, + { + "id": "multilingual_expert", + "name": "خبير متعدد اللغات", + "description": "استخدم الترجمة الصوتية بخمس لغات مختلفة", + "icon": "🌍", + "points": 800, + "category": "ترجمة", + "difficulty": "صعب", + "progress": 1, + "total": 5, + "percentage": 20, + "start_date": "2025-03-30 12:15:30", + "last_updated": "2025-03-30 12:15:30" + } + ], + "last_updated": "2025-03-30 12:15:30" +} \ No newline at end of file diff --git a/data/achievements/user_1_languages.json b/data/achievements/user_1_languages.json new file mode 100644 index 0000000000000000000000000000000000000000..09a414e1b49c4485912ebbf70c8edda0180ad8e6 --- /dev/null +++ b/data/achievements/user_1_languages.json @@ -0,0 +1,3 @@ +{ + "languages": ["العربية"] +} \ No newline at end of file diff --git a/data/achievements/user_1_risks.json b/data/achievements/user_1_risks.json new file mode 100644 index 0000000000000000000000000000000000000000..21e18f0287e5d2ecb3b072bb19ae9da53d6e84a7 --- /dev/null +++ b/data/achievements/user_1_risks.json @@ -0,0 +1,3 @@ +{ + "total_risks": 4 +} \ No newline at end of file diff --git a/data/project_tracker/project_1_kpis.json b/data/project_tracker/project_1_kpis.json new file mode 100644 index 0000000000000000000000000000000000000000..b3fd3552affdf20872cdc2eb8b247fce1773030a --- /dev/null +++ b/data/project_tracker/project_1_kpis.json @@ -0,0 +1,63 @@ +{ + "spi": 1.05, + "cpi": 0.98, + "quality_score": 92, + "safety_incidents": 0, + "resource_utilization": 85, + "risk_score": 15, + "customer_satisfaction": 90, + "environmental_compliance": 95, + "trends": { + "spi": [ + 0.95, + 0.98, + 1.02, + 1.05 + ], + "cpi": [ + 1.02, + 1.0, + 0.99, + 0.98 + ], + "quality_score": [ + 85, + 88, + 90, + 92 + ], + "risk_score": [ + 25, + 22, + 18, + 15 + ], + "dates": [ + "2025-03-09", + "2025-03-16", + "2025-03-23", + "2025-03-30" + ] + }, + "issues": [ + { + "id": 1, + "description": "تأخر في توريد المواد", + "severity": "متوسط", + "status": "قيد المعالجة", + "created_date": "2025-03-20", + "responsible": "قسم المشتريات", + "resolution": "التنسيق مع المورد البديل" + }, + { + "id": 2, + "description": "نقص في فريق العمل", + "severity": "منخفض", + "status": "تم الحل", + "created_date": "2025-03-15", + "responsible": "قسم الموارد البشرية", + "resolution": "تم توظيف فريق إضافي" + } + ], + "last_updated": "2025-03-30 21:18:10" +} \ No newline at end of file diff --git a/data/project_tracker/project_1_status.json b/data/project_tracker/project_1_status.json new file mode 100644 index 0000000000000000000000000000000000000000..42fdeaba47a93c43bd10bfbec3ced0be550c778a --- /dev/null +++ b/data/project_tracker/project_1_status.json @@ -0,0 +1,169 @@ +{ + "project_id": 1, + "project_name": "مشروع إنشاء مبنى إداري", + "project_code": "PC-2025-001", + "client": "وزارة الإسكان", + "location": "الرياض، المملكة العربية السعودية", + "start_date": "2025-02-28", + "end_date": "2025-10-16", + "budget": 10000000, + "duration": 230, + "elapsed_days": 30, + "overall_progress": 25, + "status": "في التقدم", + "phases": [ + { + "id": "planning", + "name": "التخطيط", + "description": "مرحلة التخطيط وإعداد الجدول الزمني", + "order": 1, + "progress": 100, + "status": "completed", + "start_date": "2025-02-28", + "end_date": "2025-03-10", + "actual_end_date": "2025-03-12", + "deliverables": [ + "خطة المشروع", + "الجدول الزمني", + "خطة الموارد" + ], + "responsible": "فريق التخطيط", + "notes": "تم الانتهاء من مرحلة التخطيط بنجاح قبل الموعد المحدد", + "critical": true + }, + { + "id": "pricing", + "name": "التسعير", + "description": "تسعير المشروع وتحليل التكاليف", + "order": 2, + "progress": 100, + "status": "completed", + "start_date": "2025-03-10", + "end_date": "2025-03-20", + "actual_end_date": "2025-03-22", + "deliverables": [ + "جدول الكميات المسعر", + "تحليل التكاليف", + "خطة التدفق النقدي" + ], + "responsible": "قسم التسعير", + "notes": "تم تحقيق وفر في تكاليف المشروع بنسبة 5%", + "critical": true + }, + { + "id": "bidding", + "name": "تقديم العطاء", + "description": "إعداد وتقديم وثائق العطاء", + "order": 3, + "progress": 100, + "status": "completed", + "start_date": "2025-03-20", + "end_date": "2025-03-25", + "actual_end_date": "2025-03-25", + "deliverables": [ + "وثائق العطاء", + "خطاب التقديم", + "الضمان البنكي الابتدائي" + ], + "responsible": "مدير المشروع", + "notes": "تم تقديم العطاء في الموعد المحدد", + "critical": true + }, + { + "id": "evaluation", + "name": "تقييم العطاء", + "description": "مرحلة تقييم العطاء من قبل العميل", + "order": 4, + "progress": 75, + "status": "in_progress", + "start_date": "2025-03-25", + "end_date": "2025-04-04", + "actual_end_date": null, + "deliverables": [ + "الرد على استفسارات العميل", + "العرض التقديمي", + "تقديم المستندات الإضافية" + ], + "responsible": "العميل / مدير المشروع", + "notes": "مرحلة التقييم جارية، تم الرد على جميع استفسارات العميل", + "critical": true + }, + { + "id": "awarding", + "name": "ترسية العطاء", + "description": "مرحلة ترسية العطاء وتوقيع العقد", + "order": 5, + "progress": 0, + "status": "not_started", + "start_date": "2025-04-04", + "end_date": "2025-04-14", + "actual_end_date": null, + "deliverables": [ + "خطاب الترسية", + "العقد الموقع", + "الضمان البنكي النهائي" + ], + "responsible": "الإدارة القانونية / مدير المشروع", + "notes": "ننتظر نتيجة الترسية", + "critical": true + }, + { + "id": "mobilization", + "name": "التجهيز", + "description": "تجهيز الموقع وتوفير الموارد", + "order": 6, + "progress": 0, + "status": "not_started", + "start_date": "2025-04-14", + "end_date": "2025-04-29", + "actual_end_date": null, + "deliverables": [ + "تقرير التجهيز", + "قائمة الموارد", + "خطة التنفيذ التفصيلية" + ], + "responsible": "قسم العمليات", + "notes": "التجهيز سيبدأ بعد توقيع العقد", + "critical": false + }, + { + "id": "execution", + "name": "التنفيذ", + "description": "تنفيذ أعمال المشروع", + "order": 7, + "progress": 0, + "status": "not_started", + "start_date": "2025-04-29", + "end_date": "2025-09-26", + "actual_end_date": null, + "deliverables": [ + "تقارير التقدم الدورية", + "محاضر الاجتماعات", + "الفواتير" + ], + "responsible": "فريق التنفيذ", + "notes": "التنفيذ سيستمر لمدة 6 أشهر", + "critical": true + }, + { + "id": "handover", + "name": "التسليم", + "description": "تسليم المشروع للعميل", + "order": 8, + "progress": 0, + "status": "not_started", + "start_date": "2025-09-26", + "end_date": "2025-10-11", + "actual_end_date": null, + "deliverables": [ + "محضر الاستلام", + "وثائق الضمان", + "دليل التشغيل والصيانة" + ], + "responsible": "مدير المشروع / العميل", + "notes": "التسليم يشمل فترة الاختبار والتدريب", + "critical": true + } + ], + "last_updated": "2025-03-30 21:18:10" +} \ No newline at end of file diff --git a/data/projects/.gitkeep b/data/projects/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..9f50f2f50aab20a0bb6cbf5cfc9f40f6b57e5842 --- /dev/null +++ b/data/projects/.gitkeep @@ -0,0 +1 @@ +# مجلد لحفظ ملفات المشاريع \ No newline at end of file diff --git a/database/db_connector.py b/database/db_connector.py index 79e3337f31e54c12937a566c0e48ef8755cd4b0f..69449496189ee03f2f533cab735a55bcae1f8f41 100644 --- a/database/db_connector.py +++ b/database/db_connector.py @@ -1,323 +1,61 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -موصل قاعدة البيانات لنظام إدارة المناقصات +وحدة الاتصال بقاعدة البيانات """ import os -import sqlite3 -import logging +import sys +import psycopg2 +from dotenv import load_dotenv -logger = logging.getLogger('tender_system.database') +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -class DatabaseConnector: - """فئة موصل قاعدة البيانات""" - - def __init__(self, config): - """تهيئة موصل قاعدة البيانات""" - self.config = config - self.db_config = config.get_database_config() - self.db_path = self.db_config.get('path') - self.connection = None - self.cursor = None - - # إنشاء قاعدة البيانات إذا لم تكن موجودة - self._initialize_database() - - def _initialize_database(self): - """تهيئة قاعدة البيانات""" - try: - # التأكد من وجود المجلد - os.makedirs(os.path.dirname(self.db_path), exist_ok=True) - - # إنشاء الاتصال - self.connection = sqlite3.connect(self.db_path) - self.cursor = self.connection.cursor() - - # إنشاء الجداول إذا لم تكن موجودة - self._create_tables() - - # إضافة بيانات افتراضية إذا كانت قاعدة البيانات فارغة - self._add_default_data() - - logger.info(f"تم تهيئة قاعدة البيانات بنجاح: {self.db_path}") - except Exception as e: - logger.error(f"خطأ في تهيئة قاعدة البيانات: {str(e)}") - raise - - def _create_tables(self): - """إنشاء جداول قاعدة البيانات""" - # جدول المستخدمين - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - full_name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - role TEXT NOT NULL, - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # جدول المشاريع - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS projects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - client TEXT NOT NULL, - description TEXT, - start_date TEXT, - end_date TEXT, - status TEXT NOT NULL, - created_by INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (created_by) REFERENCES users (id) - ) - ''') - - # جدول المستندات - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS documents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER, - name TEXT NOT NULL, - file_path TEXT NOT NULL, - document_type TEXT NOT NULL, - description TEXT, - uploaded_by INTEGER, - uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (project_id) REFERENCES projects (id), - FOREIGN KEY (uploaded_by) REFERENCES users (id) - ) - ''') - - # جدول بنود التسعير - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS pricing_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER, - item_number TEXT NOT NULL, - description TEXT NOT NULL, - unit TEXT NOT NULL, - quantity REAL NOT NULL, - unit_price REAL NOT NULL, - total_price REAL NOT NULL, - created_by INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (project_id) REFERENCES projects (id), - FOREIGN KEY (created_by) REFERENCES users (id) - ) - ''') - - # جدول الموارد البشرية - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS human_resources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - position TEXT NOT NULL, - daily_cost REAL NOT NULL, - skills TEXT, - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # جدول المعدات - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS equipment ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - daily_cost REAL NOT NULL, - status TEXT NOT NULL, - location TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # جدول المواد - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS materials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - unit TEXT NOT NULL, - quantity REAL NOT NULL, - unit_price REAL NOT NULL, - supplier TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # جدول المخاطر - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS risks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER, - name TEXT NOT NULL, - category TEXT NOT NULL, - probability TEXT NOT NULL, - impact TEXT NOT NULL, - risk_level TEXT NOT NULL, - mitigation_strategy TEXT, - created_by INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (project_id) REFERENCES projects (id), - FOREIGN KEY (created_by) REFERENCES users (id) - ) - ''') - - # جدول التقارير - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - project_id INTEGER, - report_type TEXT NOT NULL, - period TEXT, - file_path TEXT, - created_by INTEGER, - status TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (project_id) REFERENCES projects (id), - FOREIGN KEY (created_by) REFERENCES users (id) - ) - ''') - - # حفظ التغييرات - self.connection.commit() - - def _add_default_data(self): - """إضافة بيانات افتراضية""" - # التحقق من وجود مستخدمين - self.cursor.execute("SELECT COUNT(*) FROM users") - user_count = self.cursor.fetchone()[0] - - if user_count == 0: - # إضافة مستخدم افتراضي (admin/admin) - self.cursor.execute(''' - INSERT INTO users (username, password, full_name, email, role, status) - VALUES (?, ?, ?, ?, ?, ?) - ''', ('admin', 'admin', 'مدير النظام', 'admin@example.com', 'مدير', 'نشط')) - - # إضافة مستخدمين إضافيين - self.cursor.execute(''' - INSERT INTO users (username, password, full_name, email, role, status) - VALUES (?, ?, ?, ?, ?, ?) - ''', ('user1', 'password', 'أحمد محمد', 'ahmed@example.com', 'مستخدم', 'نشط')) - - self.cursor.execute(''' - INSERT INTO users (username, password, full_name, email, role, status) - VALUES (?, ?, ?, ?, ?, ?) - ''', ('user2', 'password', 'سارة أحمد', 'sara@example.com', 'مستخدم', 'نشط')) - - # حفظ التغييرات - self.connection.commit() - - logger.info("تم إضافة بيانات المستخدمين الافتراضية") - - # التحقق من وجود مشاريع - self.cursor.execute("SELECT COUNT(*) FROM projects") - project_count = self.cursor.fetchone()[0] - - if project_count == 0: - # إضافة مشاريع افتراضية - self.cursor.execute(''' - INSERT INTO projects (name, client, description, start_date, end_date, status, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('مشروع تطوير الطريق السريع', 'وزارة النقل', 'مشروع تطوير وتوسعة الطريق السريع', '2025-01-15', '2025-12-31', 'نشط', 1)) - - self.cursor.execute(''' - INSERT INTO projects (name, client, description, start_date, end_date, status, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('مشروع بناء المدرسة الثانوية', 'وزارة التعليم', 'مشروع بناء مدرسة ثانوية جديدة', '2025-02-01', '2025-08-30', 'نشط', 1)) - - self.cursor.execute(''' - INSERT INTO projects (name, client, description, start_date, end_date, status, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?) - ''', ('مشروع تجديد المستشفى', 'وزارة الصحة', 'مشروع تجديد وتطوير المستشفى', '2024-10-15', '2025-03-15', 'مكتمل', 1)) - - # حفظ التغييرات - self.connection.commit() - - logger.info("تم إضافة بيانات المشاريع الافتراضية") - - def execute_query(self, query, params=None): - """تنفيذ استعلام""" - try: - if params: - self.cursor.execute(query, params) - else: - self.cursor.execute(query) - - self.connection.commit() - return self.cursor - except Exception as e: - logger.error(f"خطأ في تنفيذ الاستعلام: {str(e)}") - self.connection.rollback() - raise - - def fetch_one(self, query, params=None): - """جلب صف واحد""" - cursor = self.execute_query(query, params) - return cursor.fetchone() - - def fetch_all(self, query, params=None): - """جلب جميع الصفوف""" - cursor = self.execute_query(query, params) - return cursor.fetchall() - - def insert(self, table, data): - """إدراج بيانات""" - columns = ', '.join(data.keys()) - placeholders = ', '.join(['?' for _ in data]) - query = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})" - - try: - self.cursor.execute(query, list(data.values())) - self.connection.commit() - return self.cursor.lastrowid - except Exception as e: - logger.error(f"خطأ في إدراج البيانات: {str(e)}") - self.connection.rollback() - raise - - def update(self, table, data, condition): - """تحديث بيانات""" - set_clause = ', '.join([f"{column} = ?" for column in data.keys()]) - query = f"UPDATE {table} SET {set_clause} WHERE {condition}" - - try: - self.cursor.execute(query, list(data.values())) - self.connection.commit() - return self.cursor.rowcount - except Exception as e: - logger.error(f"خطأ في تحديث البيانات: {str(e)}") - self.connection.rollback() - raise - - def delete(self, table, condition): - """حذف بيانات""" - query = f"DELETE FROM {table} WHERE {condition}" - - try: - self.cursor.execute(query) - self.connection.commit() - return self.cursor.rowcount - except Exception as e: - logger.error(f"خطأ في حذف البيانات: {str(e)}") - self.connection.rollback() - raise - - def close(self): - """إغلاق الاتصال""" - if self.connection: - self.connection.close() - logger.info("تم إغلاق الاتصال بقاعدة البيانات") +# تحميل متغيرات البيئة +load_dotenv() + +def get_connection(): + """ + إنشاء اتصال بقاعدة البيانات + + الإرجاع: + اتصال بقاعدة البيانات + """ + try: + # محاولة الاتصال بقاعدة البيانات + conn = psycopg2.connect( + dbname=os.getenv("PGDATABASE"), + user=os.getenv("PGUSER"), + password=os.getenv("PGPASSWORD"), + host=os.getenv("PGHOST"), + port=os.getenv("PGPORT") + ) + return conn + except Exception as e: + print(f"خطأ في الاتصال بقاعدة البيانات: {e}") + + # إذا فشل الاتصال، استخدم اتصال قاعدة بيانات SQLite محلية + import os + import sqlite3 + + # إنشاء مجلد البيانات إذا لم يكن موجوداً + data_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data') + os.makedirs(data_dir, exist_ok=True) + + # إنشاء اتصال قاعدة بيانات SQLite محلية + db_path = os.path.join(data_dir, 'local_db.sqlite') + conn = sqlite3.connect(db_path) + + # إعادة محاكاة سلوك اتصال PostgreSQL + conn.execute = conn.cursor().execute + + # إضافة وظيفة وهمية للاقتطاع (commit) والإغلاق + original_close = conn.close + def enhanced_close(): + conn.commit() + original_close() + conn.close = enhanced_close + + return conn \ No newline at end of file diff --git a/database/models.py b/database/models.py index 58f5ec86527d1a6c1beb8729e6250cb012569e55..25de14a7ddf44c9197141e7b6c8a61d1b6160a75 100644 --- a/database/models.py +++ b/database/models.py @@ -1,626 +1,279 @@ -""" -نماذج البيانات لنظام إدارة المناقصات -""" - -import sqlite3 -import logging -from datetime import datetime - -logger = logging.getLogger('tender_system.models') - -class User: - """نموذج المستخدم""" - - def __init__(self, id=None, username=None, password=None, full_name=None, email=None, role=None, status=None): - """تهيئة نموذج المستخدم""" - self.id = id - self.username = username - self.password = password - self.full_name = full_name - self.email = email - self.role = role - self.status = status - self.created_at = None - self.updated_at = None - - @staticmethod - def authenticate(username, password, db): - """مصادقة المستخدم""" - try: - query = "SELECT * FROM users WHERE username = ? AND password = ? AND status = 'نشط'" - result = db.fetch_one(query, (username, password)) - - if result: - user = User() - user.id = result[0] - user.username = result[1] - user.password = result[2] - user.full_name = result[3] - user.email = result[4] - user.role = result[5] - user.status = result[6] - user.created_at = result[7] - user.updated_at = result[8] - - return user - - return None - except Exception as e: - logger.error(f"خطأ في مصادقة المستخدم: {str(e)}") - return None - - @staticmethod - def get_by_id(user_id, db): - """الحصول على المستخدم بواسطة المعرف""" - try: - query = "SELECT * FROM users WHERE id = ?" - result = db.fetch_one(query, (user_id,)) - - if result: - user = User() - user.id = result[0] - user.username = result[1] - user.password = result[2] - user.full_name = result[3] - user.email = result[4] - user.role = result[5] - user.status = result[6] - user.created_at = result[7] - user.updated_at = result[8] - - return user - - return None - except Exception as e: - logger.error(f"خطأ في الحصول على المستخدم: {str(e)}") - return None - - @staticmethod - def get_all(db): - """الحصول على جميع المستخدمين""" - try: - query = "SELECT * FROM users" - results = db.fetch_all(query) - - users = [] - for result in results: - user = User() - user.id = result[0] - user.username = result[1] - user.password = result[2] - user.full_name = result[3] - user.email = result[4] - user.role = result[5] - user.status = result[6] - user.created_at = result[7] - user.updated_at = result[8] - - users.append(user) - - return users - except Exception as e: - logger.error(f"خطأ في الحصول على المستخدمين: {str(e)}") - return [] - - def save(self, db): - """حفظ المستخدم""" - try: - if self.id: - # تحديث مستخدم موجود - data = { - 'username': self.username, - 'password': self.password, - 'full_name': self.full_name, - 'email': self.email, - 'role': self.role, - 'status': self.status, - 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - db.update('users', data, f"id = {self.id}") - return self.id - else: - # إنشاء مستخدم جديد - data = { - 'username': self.username, - 'password': self.password, - 'full_name': self.full_name, - 'email': self.email, - 'role': self.role, - 'status': self.status - } - - self.id = db.insert('users', data) - return self.id - except Exception as e: - logger.error(f"خطأ في حفظ المستخدم: {str(e)}") - return None - - def delete(self, db): - """حذف المستخدم""" - try: - if self.id: - db.delete('users', f"id = {self.id}") - return True - - return False - except Exception as e: - logger.error(f"خطأ في حذف المستخدم: {str(e)}") - return False - - -class Project: - """نموذج المشروع""" - - def __init__(self, id=None, name=None, client=None, description=None, start_date=None, end_date=None, status=None, created_by=None): - """تهيئة نموذج المشروع""" - self.id = id - self.name = name - self.client = client - self.description = description - self.start_date = start_date - self.end_date = end_date - self.status = status - self.created_by = created_by - self.created_at = None - self.updated_at = None - - @staticmethod - def get_by_id(project_id, db): - """الحصول على المشروع بواسطة المعرف""" - try: - query = "SELECT * FROM projects WHERE id = ?" - result = db.fetch_one(query, (project_id,)) - - if result: - project = Project() - project.id = result[0] - project.name = result[1] - project.client = result[2] - project.description = result[3] - project.start_date = result[4] - project.end_date = result[5] - project.status = result[6] - project.created_by = result[7] - project.created_at = result[8] - project.updated_at = result[9] - - return project - - return None - except Exception as e: - logger.error(f"خطأ في الحصول على المشروع: {str(e)}") - return None - - @staticmethod - def get_all(db): - """الحصول على جميع المشاريع""" - try: - query = "SELECT * FROM projects" - results = db.fetch_all(query) - - projects = [] - for result in results: - project = Project() - project.id = result[0] - project.name = result[1] - project.client = result[2] - project.description = result[3] - project.start_date = result[4] - project.end_date = result[5] - project.status = result[6] - project.created_by = result[7] - project.created_at = result[8] - project.updated_at = result[9] - - projects.append(project) - - return projects - except Exception as e: - logger.error(f"خطأ في الحصول على المشاريع: {str(e)}") - return [] - - def save(self, db): - """حفظ المشروع""" - try: - if self.id: - # تحديث مشروع موجود - data = { - 'name': self.name, - 'client': self.client, - 'description': self.description, - 'start_date': self.start_date, - 'end_date': self.end_date, - 'status': self.status, - 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - db.update('projects', data, f"id = {self.id}") - return self.id - else: - # إنشاء مشروع جديد - data = { - 'name': self.name, - 'client': self.client, - 'description': self.description, - 'start_date': self.start_date, - 'end_date': self.end_date, - 'status': self.status, - 'created_by': self.created_by - } - - self.id = db.insert('projects', data) - return self.id - except Exception as e: - logger.error(f"خطأ في حفظ المشروع: {str(e)}") - return None - - def delete(self, db): - """حذف المشروع""" - try: - if self.id: - db.delete('projects', f"id = {self.id}") - return True - - return False - except Exception as e: - logger.error(f"خطأ في حذف المشروع: {str(e)}") - return False - - -class Document: - """نموذج المستند""" - - def __init__(self, id=None, project_id=None, name=None, file_path=None, document_type=None, description=None, uploaded_by=None): - """تهيئة نموذج المستند""" - self.id = id - self.project_id = project_id - self.name = name - self.file_path = file_path - self.document_type = document_type - self.description = description - self.uploaded_by = uploaded_by - self.uploaded_at = None - - @staticmethod - def get_by_id(document_id, db): - """الحصول على المستند بواسطة المعرف""" - try: - query = "SELECT * FROM documents WHERE id = ?" - result = db.fetch_one(query, (document_id,)) - - if result: - document = Document() - document.id = result[0] - document.project_id = result[1] - document.name = result[2] - document.file_path = result[3] - document.document_type = result[4] - document.description = result[5] - document.uploaded_by = result[6] - document.uploaded_at = result[7] - - return document - - return None - except Exception as e: - logger.error(f"خطأ في الحصول على المستند: {str(e)}") - return None - - @staticmethod - def get_by_project(project_id, db): - """الحصول على المستندات بواسطة معرف المشروع""" - try: - query = "SELECT * FROM documents WHERE project_id = ?" - results = db.fetch_all(query, (project_id,)) - - documents = [] - for result in results: - document = Document() - document.id = result[0] - document.project_id = result[1] - document.name = result[2] - document.file_path = result[3] - document.document_type = result[4] - document.description = result[5] - document.uploaded_by = result[6] - document.uploaded_at = result[7] - - documents.append(document) - - return documents - except Exception as e: - logger.error(f"خطأ في الحصول على المستندات: {str(e)}") - return [] - - def save(self, db): - """حفظ المستند""" - try: - if self.id: - # تحديث مستند موجود - data = { - 'project_id': self.project_id, - 'name': self.name, - 'file_path': self.file_path, - 'document_type': self.document_type, - 'description': self.description - } - - db.update('documents', data, f"id = {self.id}") - return self.id - else: - # إنشاء مستند جديد - data = { - 'project_id': self.project_id, - 'name': self.name, - 'file_path': self.file_path, - 'document_type': self.document_type, - 'description': self.description, - 'uploaded_by': self.uploaded_by - } - - self.id = db.insert('documents', data) - return self.id - except Exception as e: - logger.error(f"خطأ في حفظ المستند: {str(e)}") - return None - - def delete(self, db): - """حذف المستند""" - try: - if self.id: - db.delete('documents', f"id = {self.id}") - return True - - return False - except Exception as e: - logger.error(f"خطأ في حذف المستند: {str(e)}") - return False - - -class PricingItem: - """نموذج بند التسعير""" - - def __init__(self, id=None, project_id=None, item_number=None, description=None, unit=None, quantity=None, unit_price=None, total_price=None, created_by=None): - """تهيئة نموذج بند التسعير""" - self.id = id - self.project_id = project_id - self.item_number = item_number - self.description = description - self.unit = unit - self.quantity = quantity - self.unit_price = unit_price - self.total_price = total_price - self.created_by = created_by - self.created_at = None - self.updated_at = None - - @staticmethod - def get_by_project(project_id, db): - """الحصول على بنود التسعير بواسطة معرف المشروع""" - try: - query = "SELECT * FROM pricing_items WHERE project_id = ?" - results = db.fetch_all(query, (project_id,)) - - items = [] - for result in results: - item = PricingItem() - item.id = result[0] - item.project_id = result[1] - item.item_number = result[2] - item.description = result[3] - item.unit = result[4] - item.quantity = result[5] - item.unit_price = result[6] - item.total_price = result[7] - item.created_by = result[8] - item.created_at = result[9] - item.updated_at = result[10] - - items.append(item) - - return items - except Exception as e: - logger.error(f"خطأ في الحصول على بنود التسعير: {str(e)}") - return [] - - def save(self, db): - """حفظ بند التسعير""" - try: - if self.id: - # تحديث بند موجود - data = { - 'project_id': self.project_id, - 'item_number': self.item_number, - 'description': self.description, - 'unit': self.unit, - 'quantity': self.quantity, - 'unit_price': self.unit_price, - 'total_price': self.total_price, - 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - db.update('pricing_items', data, f"id = {self.id}") - return self.id - else: - # إنشاء بند جديد - data = { - 'project_id': self.project_id, - 'item_number': self.item_number, - 'description': self.description, - 'unit': self.unit, - 'quantity': self.quantity, - 'unit_price': self.unit_price, - 'total_price': self.total_price, - 'created_by': self.created_by - } - - self.id = db.insert('pricing_items', data) - return self.id - except Exception as e: - logger.error(f"خطأ في حفظ بند التسعير: {str(e)}") - return None - - -class Risk: - """نموذج المخاطرة""" - - def __init__(self, id=None, project_id=None, name=None, category=None, probability=None, impact=None, risk_level=None, mitigation_strategy=None, created_by=None): - """تهيئة نموذج المخاطرة""" - self.id = id - self.project_id = project_id - self.name = name - self.category = category - self.probability = probability - self.impact = impact - self.risk_level = risk_level - self.mitigation_strategy = mitigation_strategy - self.created_by = created_by - self.created_at = None - self.updated_at = None - - @staticmethod - def get_by_project(project_id, db): - """الحصول على المخاطر بواسطة معرف المشروع""" - try: - query = "SELECT * FROM risks WHERE project_id = ?" - results = db.fetch_all(query, (project_id,)) - - risks = [] - for result in results: - risk = Risk() - risk.id = result[0] - risk.project_id = result[1] - risk.name = result[2] - risk.category = result[3] - risk.probability = result[4] - risk.impact = result[5] - risk.risk_level = result[6] - risk.mitigation_strategy = result[7] - risk.created_by = result[8] - risk.created_at = result[9] - risk.updated_at = result[10] - - risks.append(risk) - - return risks - except Exception as e: - logger.error(f"خطأ في الحصول على المخاطر: {str(e)}") - return [] - - def save(self, db): - """حفظ المخاطرة""" - try: - if self.id: - # تحديث مخاطرة موجودة - data = { - 'project_id': self.project_id, - 'name': self.name, - 'category': self.category, - 'probability': self.probability, - 'impact': self.impact, - 'risk_level': self.risk_level, - 'mitigation_strategy': self.mitigation_strategy, - 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - db.update('risks', data, f"id = {self.id}") - return self.id - else: - # إنشاء مخاطرة جديدة - data = { - 'project_id': self.project_id, - 'name': self.name, - 'category': self.category, - 'probability': self.probability, - 'impact': self.impact, - 'risk_level': self.risk_level, - 'mitigation_strategy': self.mitigation_strategy, - 'created_by': self.created_by - } - - self.id = db.insert('risks', data) - return self.id - except Exception as e: - logger.error(f"خطأ في حفظ المخاطرة: {str(e)}") - return None - - -class Report: - """نموذج التقرير""" - - def __init__(self, id=None, name=None, project_id=None, report_type=None, period=None, file_path=None, created_by=None, status=None): - """تهيئة نموذج التقرير""" - self.id = id - self.name = name - self.project_id = project_id - self.report_type = report_type - self.period = period - self.file_path = file_path - self.created_by = created_by - self.status = status - self.created_at = None - self.updated_at = None - - @staticmethod - def get_by_project(project_id, db): - """الحصول على التقارير بواسطة معرف المشروع""" - try: - query = "SELECT * FROM reports WHERE project_id = ?" - results = db.fetch_all(query, (project_id,)) - - reports = [] - for result in results: - report = Report() - report.id = result[0] - report.name = result[1] - report.project_id = result[2] - report.report_type = result[3] - report.period = result[4] - report.file_path = result[5] - report.created_by = result[6] - report.status = result[7] - report.created_at = result[8] - report.updated_at = result[9] - - reports.append(report) - - return reports - except Exception as e: - logger.error(f"خطأ في الحصول على التقارير: {str(e)}") - return [] - - def save(self, db): - """حفظ التقرير""" - try: - if self.id: - # تحديث تقرير موجود - data = { - 'name': self.name, - 'project_id': self.project_id, - 'report_type': self.report_type, - 'period': self.period, - 'file_path': self.file_path, - 'status': self.status, - 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - db.update('reports', data, f"id = {self.id}") - return self.id - else: - # إنشاء تقرير جديد - data = { - 'name': self.name, - 'project_id': self.project_id, - 'report_type': self.report_type, - 'period': self.period, - 'file_path': self.file_path, - 'created_by': self.created_by, - 'status': self.status - } - - self.id = db.insert('reports', data) - return self.id - except Exception as e: - logger.error(f"خطأ في حفظ التقرير: {str(e)}") - return None +""" +نماذج بيانات النظام +""" + +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean, Text, Table, Enum +from sqlalchemy.orm import relationship +from datetime import datetime +import enum + +from database.db_connector import Base + + +# جدول العلاقة متعددة القيم بين المشاريع والملفات +project_files = Table( + 'project_files', + Base.metadata, + Column('project_id', Integer, ForeignKey('projects.id'), primary_key=True), + Column('file_id', Integer, ForeignKey('files.id'), primary_key=True) +) + +# تعريف الأنواع المدرجة +class ProjectStatus(enum.Enum): + """حالة المشروع""" + NEW = "جديد" + PRICING = "قيد التسعير" + SUBMITTED = "تم التقديم" + AWARDED = "تمت الترسية" + EXECUTION = "قيد التنفيذ" + COMPLETED = "منتهي" + CANCELLED = "ملغي" + +class TenderType(enum.Enum): + """نوع المناقصة""" + PUBLIC = "عامة" + PRIVATE = "خاصة" + DIRECT = "أمر مباشر" + +class PricingMethod(enum.Enum): + """طريقة التسعير""" + STANDARD = "قياسي" + UNBALANCED = "غير متزن" + COMPETITIVE = "تنافسي" + PROFITABILITY = "موجه بالربحية" + +# نموذج المستخدم +class User(Base): + """نموذج بيانات المستخدم""" + __tablename__ = 'users' + + id = Column(Integer, primary_key=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(128), nullable=False) + full_name = Column(String(100), nullable=False) + email = Column(String(100), unique=True, nullable=False) + phone = Column(String(20)) + role = Column(String(20), nullable=False) + department = Column(String(50)) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + last_login = Column(DateTime) + + # العلاقات + projects = relationship("Project", back_populates="created_by") + pricing_items = relationship("PricingItem", back_populates="created_by") + + def __repr__(self): + return f"" + +# نموذج المشروع +class Project(Base): + """نموذج بيانات المشروع""" + __tablename__ = 'projects' + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + tender_number = Column(String(50)) + client = Column(String(100), nullable=False) + location = Column(String(100)) + description = Column(Text) + status = Column(Enum(ProjectStatus), default=ProjectStatus.NEW) + tender_type = Column(Enum(TenderType), default=TenderType.PUBLIC) + pricing_method = Column(Enum(PricingMethod), default=PricingMethod.STANDARD) + submission_date = Column(DateTime) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + created_by_id = Column(Integer, ForeignKey('users.id')) + + # العلاقات + created_by = relationship("User", back_populates="projects") + pricing_sections = relationship("PricingSection", back_populates="project", cascade="all, delete-orphan") + pricing_items = relationship("PricingItem", back_populates="project", cascade="all, delete-orphan") + local_content_items = relationship("LocalContentItem", back_populates="project", cascade="all, delete-orphan") + risk_items = relationship("RiskItem", back_populates="project", cascade="all, delete-orphan") + files = relationship("File", secondary=project_files, back_populates="projects") + + def __repr__(self): + return f"" + +# نموذج قسم التسعير +class PricingSection(Base): + """نموذج بيانات قسم التسعير""" + __tablename__ = 'pricing_sections' + + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey('projects.id'), nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text) + section_order = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + project = relationship("Project", back_populates="pricing_sections") + pricing_items = relationship("PricingItem", back_populates="section", cascade="all, delete-orphan") + + def __repr__(self): + return f"" + +# نموذج بند التسعير +class PricingItem(Base): + """نموذج بيانات بند التسعير""" + __tablename__ = 'pricing_items' + + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey('projects.id'), nullable=False) + section_id = Column(Integer, ForeignKey('pricing_sections.id')) + item_code = Column(String(20)) + description = Column(Text, nullable=False) + unit = Column(String(20), nullable=False) + quantity = Column(Float, nullable=False) + unit_price = Column(Float, default=0) + unbalanced_price = Column(Float) + final_price = Column(Float) + pricing_strategy = Column(String(20), default="متوازن") + notes = Column(Text) + created_by_id = Column(Integer, ForeignKey('users.id')) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + project = relationship("Project", back_populates="pricing_items") + section = relationship("PricingSection", back_populates="pricing_items") + created_by = relationship("User", back_populates="pricing_items") + resource_usages = relationship("ResourceUsage", back_populates="pricing_item") + + def __repr__(self): + return f"" + +# نموذج بند المحتوى المحلي +class LocalContentItem(Base): + """نموذج بيانات بند المحتوى المحلي""" + __tablename__ = 'local_content_items' + + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey('projects.id'), nullable=False) + category = Column(String(50), nullable=False) + item_name = Column(String(100), nullable=False) + supplier_id = Column(Integer, ForeignKey('suppliers.id')) + total_cost = Column(Float, default=0) + local_percentage = Column(Float, default=0) + notes = Column(Text) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + project = relationship("Project", back_populates="local_content_items") + supplier = relationship("Supplier", back_populates="local_content_items") + + def __repr__(self): + return f"" + +# نموذج المورد +class Supplier(Base): + """نموذج بيانات المورد""" + __tablename__ = 'suppliers' + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False) + contact_person = Column(String(100)) + phone = Column(String(20)) + email = Column(String(100)) + address = Column(String(200)) + category = Column(String(50)) + is_local = Column(Boolean, default=False) + local_content_percentage = Column(Float, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + local_content_items = relationship("LocalContentItem", back_populates="supplier") + resources = relationship("Resource", back_populates="supplier") + + def __repr__(self): + return f"" + +# نموذج المخاطرة +class RiskItem(Base): + """نموذج بيانات المخاطرة""" + __tablename__ = 'risk_items' + + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey('projects.id'), nullable=False) + risk_code = Column(String(20)) + description = Column(Text, nullable=False) + category = Column(String(50), nullable=False) + impact = Column(String(20), nullable=False) + probability = Column(String(20), nullable=False) + mitigation_strategy = Column(Text) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + project = relationship("Project", back_populates="risk_items") + + def __repr__(self): + return f"" + +# نموذج المورد +class Resource(Base): + """نموذج بيانات المورد""" + __tablename__ = 'resources' + + id = Column(Integer, primary_key=True) + code = Column(String(20), unique=True) + name = Column(String(100), nullable=False) + description = Column(Text) + category = Column(String(50), nullable=False) + unit = Column(String(20), nullable=False) + unit_price = Column(Float, default=0) + supplier_id = Column(Integer, ForeignKey('suppliers.id')) + is_local = Column(Boolean, default=False) + local_content_percentage = Column(Float, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + supplier = relationship("Supplier", back_populates="resources") + resource_usages = relationship("ResourceUsage", back_populates="resource") + + def __repr__(self): + return f"" + +# نموذج استخدام المورد +class ResourceUsage(Base): + """نموذج بيانات استخدام المورد""" + __tablename__ = 'resource_usages' + + id = Column(Integer, primary_key=True) + pricing_item_id = Column(Integer, ForeignKey('pricing_items.id'), nullable=False) + resource_id = Column(Integer, ForeignKey('resources.id'), nullable=False) + quantity = Column(Float, nullable=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + # العلاقات + pricing_item = relationship("PricingItem", back_populates="resource_usages") + resource = relationship("Resource", back_populates="resource_usages") + + def __repr__(self): + return f"" + +# نموذج الملف +class File(Base): + """نموذج بيانات الملف""" + __tablename__ = 'files' + + id = Column(Integer, primary_key=True) + filename = Column(String(100), nullable=False) + original_filename = Column(String(100), nullable=False) + file_type = Column(String(20), nullable=False) + file_size = Column(Integer, nullable=False) + file_path = Column(String(255), nullable=False) + upload_date = Column(DateTime, default=datetime.now) + + # العلاقات + projects = relationship("Project", secondary=project_files, back_populates="files") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/demo_pricing.py b/demo_pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..887c8fcb6d3de94de2274de1e591727ee2b81001 --- /dev/null +++ b/demo_pricing.py @@ -0,0 +1,449 @@ +import streamlit as st +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import plotly.express as px +import plotly.graph_objects as go + +# ملاحظة: تم نقل إعداد الصفحة إلى ملف app.py الرئيسي +# لتجنب أخطاء set_page_config يجب أن يكون في ملف واحد فقط + +st.title("عرض تحسينات واجهة المستخدم") + +# بيانات تجريبية للعرض +@st.cache_data +def get_sample_data(): + items = pd.DataFrame({ + 'رقم البند': ['UB1', 'UB2', 'UB3', 'UB4', 'UB5'], + 'وصف البند': ['حفر أساسات', 'صب خرسانة مسلحة', 'أعمال طوب', 'أعمال تشطيبات', 'أعمال كهرباء'], + 'الوحدة': ['م3', 'م3', 'م2', 'م2', 'نقطة'], + 'الكمية': [350.0, 120.0, 500.0, 800.0, 150.0], + 'سعر الوحدة': [80.0, 950.0, 45.0, 120.0, 90.0], + 'الإجمالي': [28000.0, 114000.0, 22500.0, 96000.0, 13500.0], + 'إستراتيجية التسعير': ['نقص', 'زيادة', 'متوازن', 'زيادة', 'نقص'] + }) + return items + +items = get_sample_data() + +# 1. عرض الجدول مع تنسيق محسن +st.markdown("

بنود التسعير غير المتوازن

", unsafe_allow_html=True) + +# تعيين ألوان للإستراتيجيات وتنسيق الجدول بشكل متقدم +def highlight_row(row): + strategy = row['إستراتيجية التسعير'] + styles = [''] * len(row) + + # تطبيق لون خلفية لكل صف حسب الإستراتيجية + if strategy == 'زيادة': + background = 'linear-gradient(90deg, rgba(168, 230, 207, 0.3), rgba(168, 230, 207, 0.1))' + text_color = '#1F7A8C' + elif strategy == 'نقص': + background = 'linear-gradient(90deg, rgba(255, 154, 162, 0.3), rgba(255, 154, 162, 0.1))' + text_color = '#9D2A45' + else: + background = 'linear-gradient(90deg, rgba(220, 237, 255, 0.3), rgba(220, 237, 255, 0.1))' + text_color = '#555555' + + # تطبيق النمط على جميع الخلايا في الصف + for i in range(len(styles)): + styles[i] = f'background: {background}; color: {text_color}; border-bottom: 1px solid #ddd;' + + # تطبيق نمط خاص على خلية الإستراتيجية + if strategy == 'زيادة': + styles[list(row.index).index('إستراتيجية التسعير')] = 'background-color: #a8e6cf; color: #007263; font-weight: bold; border-radius: 5px; text-align: center;' + elif strategy == 'نقص': + styles[list(row.index).index('إستراتيجية التسعير')] = 'background-color: #ff9aa2; color: #9D2A45; font-weight: bold; border-radius: 5px; text-align: center;' + else: + styles[list(row.index).index('إستراتيجية التسعير')] = 'background-color: #dceeff; color: #555555; font-weight: bold; border-radius: 5px; text-align: center;' + + # تنسيق عمود السعر + price_idx = list(row.index).index('سعر الوحدة') + styles[price_idx] = styles[price_idx] + 'font-weight: bold;' + + # تنسيق عمود الإجمالي + total_idx = list(row.index).index('الإجمالي') + styles[total_idx] = styles[total_idx] + 'font-weight: bold;' + + return styles + +# تطبيق التنسيق على الجدول +styled_items = items.style.apply(highlight_row, axis=1) + +# تنسيق تنسيق الأرقام +styled_items = styled_items.format({ + 'الكمية': '{:,.2f}', + 'سعر الوحدة': '{:,.2f}', + 'الإجمالي': '{:,.2f}' +}) + +st.dataframe(styled_items, use_container_width=True, height=None) + +# 2. عرض المقارنة مع تصميم محسن +st.markdown("

مقارنة التسعير المتوازن وغير المتوازن

", unsafe_allow_html=True) + +# بيانات المقارنة +original_items = items.copy() +original_items['سعر الوحدة'] = [70.0, 820.0, 45.0, 100.0, 110.0] +original_items['الإجمالي'] = original_items['الكمية'] * original_items['سعر الوحدة'] + +original_total = original_items['الإجمالي'].sum() +unbalanced_total = items['الإجمالي'].sum() + +# عرض بطاقات المقارنة بتصميم متقدم +st.markdown(""" + +""", unsafe_allow_html=True) + +col1, col2, col3 = st.columns(3) + +with col1: + st.markdown(""" +
+
إجمالي التسعير المتوازن
+
{:,.2f} ريال
+
التسعير الأصلي
+
+ """.format(original_total), unsafe_allow_html=True) + +with col2: + st.markdown(""" +
+
إجمالي التسعير غير المتوازن
+
{:,.2f} ريال
+
بعد إعادة توزيع الأسعار
+
+ """.format( + unbalanced_total, + "positive-delta" if unbalanced_total > original_total else "negative-delta" if unbalanced_total < original_total else "neutral-delta" + ), unsafe_allow_html=True) + +with col3: + diff = unbalanced_total - original_total + delta_percent = diff/original_total*100 if original_total > 0 else 0 + + st.markdown(""" +
+
الفرق بين التسعيرين
+
{:,.2f} ريال
+
نسبة الفرق: {:+.1f}%
+
+ """.format( + diff, + "positive-delta" if diff > 0 else "negative-delta" if diff < 0 else "neutral-delta", + delta_percent + ), unsafe_allow_html=True) + +# 3. رسم بياني للمقارنة +st.markdown("

تحليل بصري للتسعير غير المتوازن

", unsafe_allow_html=True) + +# إعداد البيانات للرسم البياني +chart_data = pd.DataFrame({ + 'وصف البند': original_items['وصف البند'], + 'التسعير المتوازن': original_items['الإجمالي'], + 'التسعير غير المتوازن': items['الإجمالي'] +}) + +# إضافة عمود للنسبة المئوية للتغيير +chart_data['نسبة التغيير'] = (chart_data['التسعير غير المتوازن'] - chart_data['التسعير المتوازن']) / chart_data['التسعير المتوازن'] * 100 + +# تحديد لون الأعمدة بناءً على نسبة التغيير +bar_colors = [] +for change in chart_data['نسبة التغيير']: + if change > 5: # زيادة كبيرة + bar_colors.append('#1F7A8C') # أزرق مخضر + elif change > 0: # زيادة صغيرة + bar_colors.append('#81B29A') # أخضر فاتح + elif change > -5: # نقص صغير + bar_colors.append('#F2CC8F') # أصفر + else: # نقص كبير + bar_colors.append('#E07A5F') # أحمر + +# التبويب بين مخططات مختلفة للمقارنة +chart_tabs = st.tabs(["مخطط شريطي", "مخطط مقارنة", "مخطط نسبة التغيير"]) + +with chart_tabs[0]: # رسم بياني شريطي + # رسم بياني شريطي للمقارنة + fig = go.Figure() + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير المتوازن'], + name='التسعير المتوازن', + marker_color='rgba(55, 83, 109, 0.7)' + )) + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير غير المتوازن'], + name='التسعير غير المتوازن', + marker_color=bar_colors + )) + + fig.update_layout( + title='مقارنة بين التسعير المتوازن وغير المتوازن', + xaxis_tickfont_size=14, + yaxis=dict( + title='الإجمالي (ريال)', + titlefont_size=16, + tickfont_size=14, + ), + legend=dict( + x=0.01, + y=0.99, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='rgba(0, 0, 0, 0.1)', + borderwidth=1 + ), + barmode='group', + bargap=0.15, + bargroupgap=0.1, + plot_bgcolor='rgba(240, 249, 255, 0.5)', + margin=dict(t=50, b=50, l=20, r=20) + ) + + st.plotly_chart(fig, use_container_width=True) + +with chart_tabs[1]: # رسم مقارنة + # رسم مقارنة بين التسعيرين + fig = go.Figure() + + # إضافة خط للتسعير المتوازن + fig.add_trace(go.Scatter( + x=chart_data['وصف البند'], + y=chart_data['التسعير المتوازن'], + name='التسعير المتوازن', + mode='lines+markers', + line=dict(color='rgb(55, 83, 109)', width=3), + marker=dict(size=10, color='rgb(55, 83, 109)') + )) + + # إضافة نقاط للتسعير غير المتوازن + fig.add_trace(go.Scatter( + x=chart_data['وصف البند'], + y=chart_data['التسعير غير المتوازن'], + name='التسعير غير المتوازن', + mode='lines+markers', + line=dict(color='rgb(26, 118, 255)', width=3), + marker=dict( + size=12, + color=bar_colors, + line=dict(width=2, color='white') + ) + )) + + # تحديثات التخطيط + fig.update_layout( + title='مقارنة مرئية بين استراتيجيات التسعير', + xaxis_tickfont_size=14, + yaxis=dict( + title='القيمة الإجمالية (ريال)', + titlefont_size=16, + tickfont_size=14, + gridcolor='rgba(200, 200, 200, 0.2)' + ), + legend=dict( + x=0.01, + y=0.99, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='rgba(0, 0, 0, 0.1)', + borderwidth=1 + ), + plot_bgcolor='rgba(240, 249, 255, 0.5)', + margin=dict(t=50, b=50, l=20, r=20) + ) + + st.plotly_chart(fig, use_container_width=True) + +with chart_tabs[2]: # مخطط نسبة التغيير + # مخطط للنسبة المئوية للتغيير + fig = go.Figure() + + # إضافة أعمدة لنسبة التغيير مع ألوان مختلفة حسب القيمة + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['نسبة التغيير'], + name='نسبة التغيير', + marker_color=bar_colors, + text=[f"{val:.1f}%" for val in chart_data['نسبة التغيير']], + textposition='auto' + )) + + # إضافة خط أفقي عند الصفر + fig.add_shape( + type="line", + x0=-0.5, + y0=0, + x1=len(chart_data['وصف البند'])-0.5, + y1=0, + line=dict( + color="black", + width=2, + dash="dash", + ) + ) + + # تحديثات التخطيط + fig.update_layout( + title='نسبة التغيير في أسعار البنود (%)', + xaxis_tickfont_size=14, + yaxis=dict( + title='نسبة التغيير (%)', + titlefont_size=16, + tickfont_size=14, + gridcolor='rgba(200, 200, 200, 0.2)', + zeroline=True, + zerolinecolor='black', + zerolinewidth=2 + ), + plot_bgcolor='rgba(240, 249, 255, 0.5)', + margin=dict(t=50, b=50, l=20, r=20) + ) + + st.plotly_chart(fig, use_container_width=True) + + # إضافة جدول مع نسب التغيير + st.markdown("#### جدول مفصل بنسب التغيير") + + # إعداد بيانات الجدول + table_data = chart_data[['وصف البند', 'التسعير المتوازن', 'التسعير غير المتوازن', 'نسبة التغيير']] + + # تنسيق الجدول + def highlight_change(row): + change = row['نسبة التغيير'] + if change > 5: + return ['', '', '', 'background-color: rgba(31, 122, 140, 0.3); color: #1F7A8C; font-weight: bold;'] + elif change > 0: + return ['', '', '', 'background-color: rgba(129, 178, 154, 0.3); color: #2A9D8F; font-weight: bold;'] + elif change > -5: + return ['', '', '', 'background-color: rgba(242, 204, 143, 0.3); color: #BC6C25; font-weight: bold;'] + else: + return ['', '', '', 'background-color: rgba(224, 122, 95, 0.3); color: #AE2012; font-weight: bold;'] + + # تطبيق التنسيق + styled_table = table_data.style.apply(highlight_change, axis=1).format({ + 'التسعير المتوازن': '{:,.2f} ريال', + 'التسعير غير المتوازن': '{:,.2f} ريال', + 'نسبة التغيير': '{:+.1f}%' + }) + + st.dataframe(styled_table, use_container_width=True) + +# 4. أزرار الحفظ والتصدير مع تصميم محسن +st.markdown("
", unsafe_allow_html=True) +st.markdown("

حفظ وتصدير البيانات

", unsafe_allow_html=True) + +st.markdown(""" + +""", unsafe_allow_html=True) + +col1, col2 = st.columns(2) + +with col1: + # بطاقة حفظ التسعير + st.markdown(""" +
+
💾
+
حفظ التسعير غير المتوازن
+
قم بحفظ التسعير الحالي في المشروع لاستخدامه لاحقاً في التقارير وفي إجمالي التسعير.
+
+ """, unsafe_allow_html=True) + + # زر حفظ التسعير غير المتوازن + if st.button("حفظ التسعير غير المتوازن", type="primary", use_container_width=True): + st.success("تم حفظ التسعير غير المتوازن بنجاح!") + st.balloons() # إضافة تأثير احتفالي عند الحفظ + +with col2: + # بطاقة تصدير التسعير + st.markdown(""" +
+
📊
+
تصدير البيانات
+
قم بتصدير جدول التسعير الحالي بصيغة CSV لاستخدامه في برامج أخرى مثل Excel.
+
+ """, unsafe_allow_html=True) + + # زر تصدير التسعير + export_button = st.button("تجهيز ملف للتصدير", use_container_width=True) + if export_button: + # تحويل البيانات إلى CSV + csv = items.to_csv(index=False) + st.success("تم تجهيز ملف التصدير بنجاح! يمكنك تنزيله الآن.") + # تقديم البيانات للتنزيل + st.download_button( + label="تنزيل ملف CSV", + data=csv, + file_name="unbalanced_pricing.csv", + mime="text/csv", + use_container_width=True + ) \ No newline at end of file diff --git a/docs/technical_docs.md b/docs/technical_docs.md new file mode 100644 index 0000000000000000000000000000000000000000..2f564c7975880e3a89c0cd9b4bdbe98401a0819f --- /dev/null +++ b/docs/technical_docs.md @@ -0,0 +1,165 @@ +# التوثيق التقني +## نظام تحليل العقود والمناقصات بالذكاء الاصطناعي - شركة شبه الجزيرة للمقاولات + +

+ شعار النظام +
+ إصدار التوثيق: 1.0.2 - تاريخ التحديث: 2025/03/01 +

+ +## جدول المحتويات + +1. [نظرة عامة](#نظرة-عامة) +2. [المعمارية التقنية](#المعمارية-التقنية) +3. [متطلبات النظام](#متطلبات-النظام) +4. [الإعداد والتثبيت](#الإعداد-والتثبيت) +5. [بيئة Hybrid Face](#بيئة-hybrid-face) +6. [هيكل قاعدة البيانات](#هيكل-قاعدة-البيانات) +7. [وحدات النظام](#وحدات-النظام) +8. [واجهات برمجة التطبيقات (APIs)](#واجهات-برمجة-التطبيقات-apis) +9. [الأمان والمصادقة](#الأمان-والمصادقة) +10. [الأداء وقابلية التوسع](#الأداء-وقابلية-التوسع) +11. [استراتيجية النسخ الاحتياطي واستعادة البيانات](#استراتيجية-النسخ-الاحتياطي-واستعادة-البيانات) +12. [إرشادات التطوير](#إرشادات-التطوير) +13. [اختبار النظام](#اختبار-النظام) +14. [التكامل مع الأنظمة الخارجية](#التكامل-مع-الأنظمة-الخارجية) +15. [سجل التغييرات](#سجل-التغييرات) + +## نظرة عامة + +### عن النظام + +نظام تحليل العقود والمناقصات بالذكاء الاصطناعي هو منصة متكاملة تعتمد على تقنيات الذكاء الاصطناعي ومعالجة اللغة العربية الطبيعية لمساعدة شركة شبه الجزيرة للمقاولات في تحليل وتسعير المناقصات وإدارة المشاريع. + +### المكونات الرئيسية + +1. **واجهة المستخدم (Frontend)**: تطبيق ويب تفاعلي مبني بواسطة Streamlit +2. **خدمات الخلفية (Backend)**: مجموعة من الخدمات والوحدات البرمجية بلغة Python +3. **قاعدة البيانات**: SQLite للتطوير والنشر المحلي، MySQL للنشر المؤسسي +4. **محركات الذكاء الاصطناعي**: نماذج معالجة اللغة الطبيعية والتعلم الآلي +5. **خدمات التكامل**: واجهات برمجة للتكامل مع الأنظمة الخارجية + +## المعمارية التقنية + +### المخطط العام للنظام + +```mermaid +graph TD + User[المستخدم] --> UI[واجهة المستخدم Streamlit] + UI --> API[طبقة API] + API --> Core[النواة] + Core --> DB[(قاعدة البيانات)] + Core --> NLP[معالجة اللغة العربية] + Core --> ML[نماذج التعلم الآلي] + Core --> FS[نظام الملفات] + Core --> External[أنظمة خارجية] + + subgraph Core Modules + NLP + ML + Doc[تحليل المستندات] + Pricing[التسعير] + Risk[تحليل المخاطر] + Res[إدارة الموارد] + Proj[إدارة المشاريع] + Rep[التقارير] + end +``` + +### نمط المعمارية + +النظام يعتمد على نمط المعمارية طبقية (Layered Architecture) ونمط وحدات الخدمة (Service Modules): + +1. **طبقة العرض**: واجهة المستخدم Streamlit +2. **طبقة الخدمات**: واجهات برمجة التطبيقات RESTful +3. **طبقة الأعمال**: وحدات المعالجة المنطقية +4. **طبقة البيانات**: الوصول إلى قاعدة البيانات وتخزين الملفات + +## متطلبات النظام + +### متطلبات الأجهزة + +| المكون | الحد الأدنى | الموصى به | +|--------|-------------|-----------| +| المعالج | Intel Core i5 (8 أنوية) | Intel Core i7 (12 أنوية) أو أعلى | +| الذاكرة | 16GB RAM | 32GB RAM أو أكثر | +| التخزين | 10GB + مساحة للمستندات | SSD بسعة 50GB أو أكثر | +| الشبكة | اتصال إنترنت 10Mbps | اتصال إنترنت 50Mbps أو أسرع | +| الشاشة | دقة 1080p | دقة 1440p أو أعلى | + +### متطلبات البرمجيات + +| البرمجيات | الإصدار المطلوب | +|-----------|-----------------| +| نظام التشغيل | Windows 10/11، MacOS 12+، Ubuntu 20.04+ | +| Python | 3.9 أو أحدث | +| بيئة Hybrid Face | 2.5 أو أحدث | +| متصفح | Chrome 90+، Firefox 88+، Edge 90+ | +| MySQL (اختياري) | 8.0 أو أحدث | + +### المكتبات الأساسية + +```python +# المكتبات الأساسية المستخدمة +streamlit==1.10.0 +pandas==1.5.0 +numpy==1.23.0 +scikit-learn==1.1.0 +nltk==3.7.0 +spacy==3.4.0 +transformers==4.20.0 +pyarabic==0.6.15 +sqlalchemy==1.4.40 +plotly==5.9.0 +pymysql==1.0.2 +pdfplumber==0.7.0 +python-docx==0.8.11 +openpyxl==3.0.10 +ezdxf==0.17.2 +``` + +## الإعداد والتثبيت + +### إعداد بيئة التطوير + +```bash +# إنشاء بيئة Python افتراضية +python -m venv venv +source venv/bin/activate # Linux/MacOS +venv\Scripts\activate # Windows + +# تثبيت المكتبات المطلوبة +pip install -r requirements.txt +pip install -r arabic_support_requirements.txt +``` + +### تثبيت نماذج معالجة اللغة العربية + +```bash +# تثبيت نموذج اللغة العربية لـ SpaCy +python -m spacy download ar_core_news_lg + +# تحميل موارد NLTK للغة العربية +python -m nltk.downloader stopwords +python -m nltk.downloader punkt +python -m nltk.downloader wordnet +``` + +### إعداد قاعدة البيانات + +#### SQLite (للتطوير المحلي) + +```bash +# إنشاء قاعدة بيانات SQLite +python setup_db.py --mode=local +``` + +#### MySQL (للنشر المؤسسي) + +```bash +# إعداد قاعدة بيانات MySQL +python setup_db.py --mode=enterprise \ + --db-host=YOUR_DB_HOST \ + --db-user=YOUR_DB_USER \ + --db-pass=YOUR_DB_PASS \ + --db-name=tender_analysis_system \ No newline at end of file diff --git a/docs/user_manual.md b/docs/user_manual.md new file mode 100644 index 0000000000000000000000000000000000000000..ea9bef924d060d015b043b636cb9b493988f5e70 --- /dev/null +++ b/docs/user_manual.md @@ -0,0 +1,594 @@ +# دليل المستخدم +## نظام تحليل العقود والمناقصات بالذكاء الاصطناعي - شركة شبه الجزيرة للمقاولات + +

+ شعار النظام +
+ الإصدار 2.0.0 +

+ +## جدول المحتويات + +1. [مقدمة](#مقدمة) +2. [بدء الاستخدام](#بدء-الاستخدام) +3. [الواجهة الرئيسية](#الواجهة-الرئيسية) +4. [إدارة المناقصات والعقود](#إدارة-المناقصات-والعقود) +5. [تحليل المستندات](#تحليل-المستندات) +6. [نظام التسعير الشامل](#نظام-التسعير-الشامل) +7. [حاسبة تكاليف البناء](#حاسبة-تكاليف-البناء) +8. [إدارة الموارد والتكاليف](#إدارة-الموارد-والتكاليف) +9. [تحليل المخاطر](#تحليل-المخاطر) +10. [إدارة المشاريع المرساة](#إدارة-المشاريع-المرساة) +11. [الخرائط والمواقع](#الخرائط-والمواقع) +12. [الإشعارات الذكية](#الإشعارات-الذكية) +13. [الجدول الزمني التفاعلي](#الجدول-الزمني-التفاعلي) +14. [مساعد الذكاء الاصطناعي](#مساعد-الذكاء-الاصطناعي) +15. [مقارنة المستندات](#مقارنة-المستندات) +16. [التقارير والتحليلات](#التقارير-والتحليلات) +17. [إعدادات النظام](#إعدادات-النظام) +18. [الأسئلة الشائعة](#الأسئلة-الشائعة) +19. [استكشاف الأخطاء وإصلاحها](#استكشاف-الأخطاء-وإصلاحها) + +## مقدمة + +### حول النظام + +نظام تحليل العقود والمناقصات بالذكاء الاصطناعي هو منصة متكاملة تهدف إلى مساعدة شركة شبه الجزيرة للمقاولات في تحليل وتسعير المناقصات بكفاءة عالية. يعتمد النظام على تقنيات الذكاء الاصطناعي ومعالجة اللغة العربية الطبيعية لتحليل المستندات والمساعدة في عملية التسعير واتخاذ القرارات. + +### مزايا النظام + +- تحليل متقدم لكراسات الشروط والعقود باللغة العربية +- تسعير دقيق ومنهجي للمناقصات +- حاسبة تكاليف بناء شاملة مع مكونات متعددة +- تحديد المخاطر وتقييمها بشكل آلي +- إدارة الموارد والتكاليف بكفاءة +- دعم المحتوى المحلي السعودي +- جدول زمني تفاعلي مع تتبع المراحل +- مساعد ذكاء اصطناعي متطور +- متابعة شاملة للمناقصات والمشاريع +- تقارير وتحليلات متقدمة لدعم اتخاذ القرار +- خرائط تفاعلية لمواقع المشاريع +- نظام إشعارات ذكي + +## بدء الاستخدام + +### تسجيل الدخول + +1. افتح تطبيق نظام تحليل العقود والمناقصات +2. أدخل اسم المستخدم وكلمة المرور +3. انقر على زر "تسجيل الدخول" + +![شاشة تسجيل الدخول](../static/images/screenshots/login.png) + +### الصلاحيات ومستويات الوصول + +النظام يدعم عدة مستويات من الصلاحيات: + +| المستوى | الوصف | الصلاحيات | +|---------|-------|-----------| +| مدير النظام | المسؤول الرئيسي عن النظام | كامل الصلاحيات | +| مدير المناقصات | مسؤول عن إدارة المناقصات | إضافة وتعديل وحذف المناقصات، التسعير | +| محلل عقود | مختص بتحليل العقود والمستندات | قراءة وتحليل المستندات | +| محاسب | مسؤول عن الجوانب المالية | الوصول للتكاليف والتسعير | +| مستخدم عادي | مستخدم بصلاحيات محدودة | عرض المناقصات والتقارير فقط | + +## الواجهة الرئيسية + +### مكونات الواجهة + +![الواجهة الرئيسية](../static/images/screenshots/dashboard.png) + +1. **شريط القوائم**: للوصول إلى الوظائف الرئيسية +2. **لوحة المعلومات**: عرض ملخص للمناقصات والمشاريع +3. **المناقصات النشطة**: قائمة بالمناقصات قيد الدراسة +4. **المواعيد الهامة**: تنبيهات بالمواعيد النهائية +5. **المؤشرات الرئيسية**: إحصائيات ومؤشرات أداء رئيسية +6. **معلومات الشركة**: بيان "هذا النظام يعمل لصالح شركة شبه الجزيرة للمقاولات، جميع الحقوق محفوظة 2025" + +### التنقل في النظام + +تم تصميم شريط القوائم للوصول السريع إلى جميع وظائف النظام: + +- **لوحة المعلومات**: الصفحة الرئيسية +- **المناقصات والعقود**: إدارة المناقصات وتحليل العقود +- **التسعير**: نظام التسعير الشامل +- **حاسبة تكاليف البناء**: حساب تكاليف البناء بالتفصيل +- **الموارد والتكاليف**: إدارة المواد والمعدات والعمالة +- **تحليل المخاطر**: تقييم وإدارة المخاطر +- **المشاريع**: إدارة المشاريع المرساة +- **الجدول الزمني**: الجدول الزمني التفاعلي للمشاريع +- **الخرائط**: خرائط مواقع المشاريع +- **الإشعارات**: نظام الإشعارات الذكي +- **المساعد الذكي**: مساعد الذكاء الاصطناعي التفاعلي +- **مقارنة المستندات**: أدوات مقارنة المستندات المتطورة +- **التقارير**: التقارير والتحليلات +- **الإعدادات**: إعدادات النظام والمستخدمين + +## إدارة المناقصات والعقود + +### إضافة مناقصة جديدة + +1. انقر على "المناقصات والعقود" من شريط القوائم +2. اختر "إضافة مناقصة جديدة" +3. املأ النموذج بالمعلومات المطلوبة: + - اسم المناقصة + - الجهة المالكة + - رقم المناقصة + - تاريخ الطرح + - تاريخ الإقفال + - موقع المشروع + - نوع المشروع + +![إضافة مناقصة](../static/images/screenshots/add_tender.png) + +### رفع المستندات + +1. من صفحة تفاصيل المناقصة، انقر على "رفع مستند" +2. اختر نوع المستند: + - كراسة شروط + - جدول كميات + - مخططات + - عقد + - ملحق +3. انقر على "استعراض" واختر الملف من جهازك +4. يدعم النظام صيغ المستندات التالية: PDF, DOCX, XLSX, DWG +5. **جديد**: يمكنك الآن رفع صور موقع المشروع ومقاطع الفيديو ومعلومات المزايا/المخاطر واستفسارات المالك + +### متابعة حالة المناقصات + +يوفر النظام لوحة متابعة للمناقصات تعرض: + +- المناقصات قيد الدراسة +- المناقصات المقدمة +- المناقصات المرساة +- المناقصات المستبعدة + +لكل مناقصة، يعرض النظام: +- الحالة الحالية +- نسبة الإنجاز +- المواعيد النهائية +- المهام المتبقية +- **جديد**: مؤقت لبدء الدراسة ومواعيد التسليم النهائية + +### معلومات الموقع وسهولة الوصول + +**جديد**: يمكنك الآن إضافة معلومات مفصلة عن الموقع وتفاصيل الوصول إليه بجانب زر موقع المشروع، مما يساعد فرق العمل الميدانية. + +## تحليل المستندات + +### كيفية تحليل المستندات + +1. من صفحة تفاصيل المناقصة، اختر المستند المراد تحليله +2. انقر على زر "تحليل المستند" +3. اختر نوع التحليل: + - تحليل كامل + - استخراج البنود والشروط + - تحديد المخاطر + - استخراج معلومات التسعير + +![تحليل المستندات](../static/images/screenshots/document_analysis.png) + +### مراجعة نتائج التحليل + +بعد اكتمال التحليل، يعرض النظام: + +1. **البنود المستخرجة**: قائمة بالبنود والشروط المهمة مرتبة حسب أهميتها +2. **المخاطر المحددة**: المخاطر المحتملة مصنفة حسب نوعها وأهميتها +3. **المتطلبات الرئيسية**: قائمة بالمتطلبات الأساسية للمناقصة +4. **الكلمات المفتاحية**: الكلمات والمصطلحات المهمة في المستند +5. **جديد**: متطلبات المحتوى المحلي في المشاريع السعودية + +يمكنك النقر على أي بند لعرض النص الأصلي في المستند وسياقه. + +## نظام التسعير الشامل + +### بدء عملية التسعير + +1. من صفحة تفاصيل المناقصة، انقر على "بدء التسعير" +2. اختر جدول الكميات المراد تسعيره +3. حدد نوع التسعير: + - تسعير قياسي + - تسعير غير متزن + - تسعير مختلط + +![بدء التسعير](../static/images/screenshots/pricing_start.png) + +### تسعير البنود + +1. لكل بند في جدول الكميات، يعرض النظام: + - وصف البند + - الوحدة + - الكمية + - التكاليف المقدرة (المواد، العمالة، المعدات) +2. يمكنك تعديل التكاليف يدوياً أو الاعتماد على التقديرات الآلية +3. النظام يحسب تلقائياً: + - المصاريف العامة + - هامش الربح + - السعر الإجمالي + +### التسعير غير المتزن + +لتطبيق استراتيجية التسعير غير المتزن: + +1. انقر على "التسعير غير المتزن" من صفحة التسعير +2. اختر نوع الاستراتيجية: + - التحميل الأمامي + - التحميل الخلفي + - التسعير الاستراتيجي + - التسعير القائم على المخاطر +3. عدل المعلمات حسب الحاجة +4. راجع التغييرات في توزيع التكاليف والأسعار + +![التسعير غير المتزن](../static/images/screenshots/unbalanced_pricing.png) + +### المحتوى المحلي + +لحساب وتحسين نسبة المحتوى المحلي: + +1. انقر على "المحتوى المحلي" من صفحة التسعير +2. قم بتقييم المعايير المختلفة: + - نسبة الموظفين السعوديين + - نسبة المواد المحلية + - نسبة المعدات المحلية + - نسبة المقاولين من الباطن المحليين +3. راجع الدرجة الإجمالية للمحتوى المحلي والأفضلية السعرية المقابلة + +## حاسبة تكاليف البناء + +### نظرة عامة + +**جديد**: حاسبة تكاليف البناء المتكاملة تتيح لك حساب تكاليف المشاريع بالتفصيل، مع تقسيم واضح للعناصر المختلفة: + +- المواد الخام +- المعدات +- العمالة +- المصاريف الإدارية +- هوامش الربح + +### استخدام الحاسبة + +1. انقر على "حاسبة تكاليف البناء" من شريط القوائم +2. اختر نوع المشروع من القائمة +3. أدخل مواصفات المشروع الأساسية (المساحة، الموقع، نوع البناء) +4. استعرض التكاليف المقدرة لكل مكون +5. عدل البنود حسب الحاجة +6. راجع تفصيل الأسعار والتكلفة الإجمالية + +### كتالوج القوالب الإنشائية + +**جديد**: يتضمن النظام الآن كتالوجًا شاملاً للقوالب الإنشائية لمختلف أنواع المشاريع: + +1. مباني سكنية +2. مباني تجارية +3. مشاريع بنية تحتية +4. منشآت صناعية +5. مرافق عامة + +استخدم هذه القوالب لبدء حسابات التكلفة بسرعة، ثم قم بتخصيصها حسب احتياجات مشروعك. + +## إدارة الموارد والتكاليف + +### إدارة المواد + +1. انقر على "الموارد والتكاليف" من شريط القوائم +2. اختر "المواد" +3. يمكنك: + - استعراض قائمة المواد + - إضافة مواد جديدة + - تحديث أسعار المواد + - ربط المواد بالموردين + - **جديد**: تقديم طلبات أسعار للمواد الخام + +![إدارة المواد](../static/images/screenshots/materials.png) + +### إدارة المعدات + +1. انقر على "الموارد والتكاليف" من شريط القوائم +2. اختر "المعدات" +3. يمكنك: + - استعراض قائمة المعدات + - تسجيل معدلات الأداء + - تحديث أسعار التأجير + - تسجيل تكاليف التشغيل + - **جديد**: إدارة المعدات الخاصة والمستأجرة + +### إدارة العمالة + +1. انقر على "الموارد والتكاليف" من شريط القوائم +2. اختر "العمالة" +3. يمكنك: + - استعراض فئات العمالة + - تسجيل معدلات الإنتاجية + - تحديث أسعار العمالة + - تكوين فرق العمل النموذجية + +## تحليل المخاطر + +### تقييم المخاطر + +1. انقر على "تحليل المخاطر" من شريط القوائم +2. اختر المناقصة المراد تقييم مخاطرها +3. يعرض النظام المخاطر المحددة مصنفة إلى: + - مخاطر تعاقدية + - مخاطر مالية + - مخاطر فنية + - مخاطر لوجستية + +![تحليل المخاطر](../static/images/screenshots/risk_analysis.png) + +### إدارة المخاطر + +لكل خطر محدد، يمكنك: + +1. مراجعة تفاصيل الخطر +2. تعديل تقييم احتمالية الحدوث والتأثير +3. إضافة إجراءات التخفيف +4. تعيين مسؤول المتابعة +5. تحديد تكلفة التخفيف + +## إدارة المشاريع المرساة + +### متابعة المشاريع + +1. انقر على "المشاريع" من شريط القوائم +2. اختر المشروع المراد متابعته +3. يعرض النظام: + - ملخص المشروع + - حالة التنفيذ + - المستخلصات + - المراسلات + +![متابعة المشاريع](../static/images/screenshots/project_management.png) + +### إدارة المستخلصات + +1. من صفحة تفاصيل المشروع، انقر على "المستخلصات" +2. يمكنك: + - إنشاء مستخلص جديد + - متابعة حالة المستخلصات + - الاطلاع على المدفوعات + +## الخرائط والمواقع + +### نظرة عامة + +**جديد**: نظام الخرائط التفاعلي يتيح لك: + +1. عرض مواقع جميع المشاريع على خريطة واحدة +2. تصفية المشاريع حسب الحالة والنوع والمنطقة +3. عرض معلومات تفصيلية عن كل موقع +4. تحليل التوزيع الجغرافي للمشاريع +5. حساب المسافات واتجاهات السير إلى المواقع + +### استخدام الخرائط + +1. انقر على "الخرائط" من شريط القوائم +2. استخدم أدوات التصفية لعرض المشاريع المطلوبة +3. انقر على أي علامة موقع لعرض تفاصيل المشروع +4. استخدم خيار "تفاصيل الوصول" لعرض معلومات الوصول إلى الموقع +5. يمكنك تصدير معلومات الموقع أو مشاركتها مع فريق العمل + +## الإشعارات الذكية + +### نظرة عامة + +**جديد**: نظام الإشعارات الذكية يقوم بتنبيهك تلقائيًا بشأن: + +1. المواعيد النهائية للمناقصات +2. تحديثات حالة المناقصات والمشاريع +3. المهام المستحقة +4. تغييرات الأسعار في المواد الرئيسية +5. الفرص الجديدة المحتملة + +### إعدادات الإشعارات + +1. انقر على "الإعدادات" ثم "إعدادات الإشعارات" +2. خصص أنواع الإشعارات التي ترغب في تلقيها +3. حدد طريقة التنبيه (داخل النظام، بريد إلكتروني، رسائل نصية) +4. ضبط مستوى الأهمية والتكرار + +## الجدول الزمني التفاعلي + +### نظرة عامة + +**جديد**: الجدول الزمني التفاعلي يتيح لك: + +1. عرض مراحل المشروع بتنسيق رسومي سهل الفهم +2. تتبع المراحل والإنجازات الرئيسية +3. تحديث حالة المهام في الوقت الفعلي +4. توقع المشكلات المحتملة قبل حدوثها +5. مشاهدة تأثير التأخيرات على الخطة الزمنية الكلية + +### استخدام الجدول الزمني + +1. انقر على "الجدول الزمني" من شريط القوائم +2. اختر المشروع المراد عرض جدوله الزمني +3. استعرض المراحل والمهام +4. انقر على أي مرحلة لعرض التفاصيل أو تحديث الحالة +5. استخدم ميزة "ماذا لو" لتقييم تأثير التغييرات المحتملة + +## مساعد الذكاء الاصطناعي + +### نظرة عامة + +**جديد**: مساعد الذكاء الاصطناعي التفاعلي يمكنه: + +1. الإجابة على الأسئلة حول المناقصات والعقود +2. توفير تحليلات سريعة للمستندات +3. اقتراح حلول للمشكلات الشائعة +4. مساعدتك في فهم البنود القانونية المعقدة +5. توفير ملخصات دقيقة للمستندات الطويلة + +### استخدام المساعد + +1. انقر على رمز المساعد في أي صفحة من صفحات النظام +2. اكتب سؤالك أو طلبك بلغة طبيعية +3. يمكنك تحميل مستند للتحليل أو الإشارة إلى مستند موجود +4. راجع الإجابة واطرح أسئلة متابعة إذا لزم الأمر + +## مقارنة المستندات + +### نظرة عامة + +**جديد**: أدوات مقارنة المستندات المتطورة تتيح لك: + +1. مقارنة نسخ مختلفة من العقود أو المناقصات +2. تحديد التغييرات بين المستندات بدقة +3. تقييم تأثير التعديلات على المخاطر والتكاليف +4. اكتشاف التناقضات بين البنود المختلفة +5. مقارنة العقود بالنماذج القياسية + +### استخدام أدوات المقارنة + +1. انقر على "مقارنة المستندات" من شريط القوائم +2. حدد المستندين المراد مقارنتهما +3. اختر نوع المقارنة (نصية، هيكلية، دلالية) +4. راجع نتائج المقارنة مع تمييز الاختلافات +5. يمكنك تصدير تقرير المقارنة + +## التقارير والتحليلات + +### إنشاء التقارير + +1. انقر على "التقارير" من شريط القوائم +2. اختر نوع التقرير: + - تقرير المناقصات + - تقرير المشاريع + - تقرير مالي + - تقرير المخاطر + - **جديد**: تقرير المحتوى المحلي + - **جديد**: تقرير توقعات الفرص المستقبلية +3. حدد معايير التقرير +4. انقر على "إنشاء التقرير" + +![التقارير](../static/images/screenshots/reports.png) + +### تصدير التقارير + +يمكن تصدير التقارير بصيغ متعددة: +- PDF +- Excel +- Word +- PowerPoint +- **جديد**: صيغة JSON للتكامل مع الأنظمة الأخرى + +## إعدادات النظام + +### نظرة عامة + +**جديد**: صفحة الإعدادات المحسنة تتيح لك: + +1. تخصيص واجهة المستخدم +2. تغيير لغة النظام +3. إدارة إعدادات الإشعارات +4. تكوين عمليات النسخ الاحتياطي التلقائية +5. إدارة حسابات المستخدمين والصلاحيات + +### الإعدادات الشخصية + +1. انقر على "الإعدادات" ثم "الإعدادات الشخصية" +2. اختر لغة النظام (العربية، الإنجليزية) +3. خصص الواجهة (الألوان، الخط، ترتيب العناصر) +4. ضبط إعدادات الإشعارات الشخصية + +### إدارة المستخدمين + +1. انقر على "الإعدادات" ثم "إدارة المستخدمين" (للمديرين فقط) +2. استعرض قائمة المستخدمين +3. أضف مستخدمًا جديدًا أو عدل بيانات مستخدم موجود +4. حدد صلاحيات الوصول والأدوار + +## الأسئلة الشائعة + +### أسئلة عامة + +**س: كيف يمكنني الحصول على حساب للنظام؟** +ج: يرجى التواصل مع مدير النظام في شركتك. + +**س: هل يمكن استخدام النظام عبر الأجهزة المحمولة؟** +ج: نعم، النظام متوافق مع جميع الأجهزة بما فيها الهواتف الذكية والأجهزة اللوحية. + +**س: هل يمكنني استخدام النظام دون اتصال بالإنترنت؟** +ج: بعض الوظائف متاحة دون اتصال، لكن معظم الميزات تتطلب اتصالًا بالإنترنت. + +### أسئلة عن تحليل المستندات + +**س: ما هي أنواع المستندات التي يدعمها النظام؟** +ج: يدعم النظام مستندات PDF وWord وExcel والمخططات DWG. + +**س: هل يستطيع النظام تحليل المستندات الممسوحة ضوئياً؟** +ج: نعم، يمكن للنظام تحليل المستندات الممسوحة ضوئياً، لكن دقة التحليل تعتمد على جودة المسح. + +**س: كم من الوقت يستغرق تحليل مستند كبير؟** +ج: يعتمد على حجم وتعقيد المستند، لكن معظم المستندات تحلل في غضون دقائق. + +### أسئلة عن التسعير وحاسبة التكاليف + +**س: كيف يحدد النظام تكاليف المواد والعمالة؟** +ج: يعتمد النظام على قاعدة بيانات الأسعار المتاحة ومعدلات الأداء المسجلة. + +**س: ما هو التسعير غير المتزن؟** +ج: هو استراتيجية لتوزيع التكاليف بشكل غير متساوٍ على بنود المناقصة لتحقيق ميزة تنافسية أو تحسين التدفق النقدي. + +**س: هل يمكن إضافة عناصر مخصصة لحاسبة تكاليف البناء؟** +ج: نعم، يمكنك إضافة عناصر مخصصة وتعديل المعلمات حسب متطلبات المشروع. + +## استكشاف الأخطاء وإصلاحها + +### مشاكل تسجيل الدخول + +**المشكلة: لا يمكن تسجيل الدخول** +الحل: +1. تأكد من صحة اسم المستخدم وكلمة المرور +2. تأكد من اتصالك بالإنترنت +3. امسح ذاكرة التخزين المؤقت للمتصفح +4. إذا استمرت المشكلة، تواصل مع الدعم الفني + +### مشاكل تحليل المستندات + +**المشكلة: فشل تحليل المستند** +الحل: +1. تأكد من أن المستند بتنسيق مدعوم +2. تحقق من جودة المسح إذا كان المستند ممسوحاً ضوئياً +3. قلل حجم الملف إذا كان كبيراً جداً +4. جرب تقسيم المستند إلى أجزاء أصغر + +### مشاكل التسعير + +**المشكلة: عدم ظهور التكاليف المقدرة** +الحل: +1. تأكد من تحديث قاعدة بيانات الأسعار +2. تحقق من صحة وحدات البنود +3. تأكد من ربط البنود بالمواد والعمالة المناسبة +4. أعد تشغيل عملية التسعير + +### مشاكل الجدول الزمني + +**المشكلة: عدم ظهور بعض المراحل في الجدول الزمني** +الحل: +1. تأكد من إضافة جميع المراحل في تفاصيل المشروع +2. تحقق من تواريخ البدء والانتهاء +3. تأكد من تسلسل المراحل المنطقي +4. حاول تحديث الصفحة أو إعادة تحميلها + +--- + +## حول النظام + +نظام تحليل العقود والمناقصات بالذكاء الاصطناعي هو منتج متطور تم تصميمه وتطويره خصيصًا لشركة شبه الجزيرة للمقاولات. يعمل النظام على تحسين كفاءة دراسة المناقصات وإدارة المشاريع من خلال الاستفادة من تقنيات الذكاء الاصطناعي وتحليل البيانات المتقدمة. + +**مزايا النظام الرئيسية:** +- تحليل متعمق للمناقصات والعقود باللغة العربية +- حاسبة تكاليف متكاملة مع تفاصيل دقيقة +- أدوات متقدمة لإدارة المشاريع +- تحليل المخاطر الآلي +- الجدول الزمني التفاعلي +- نظام الخرائط والمواقع +- مساعد الذكاء الاصطناعي + +--- + +لمزيد من المساعدة، يرجى التواصل مع: +- البريد الإلكتروني: support@peninsula-contracting.com +- رقم الهاتف: +966 123456789 +- نظام التذاكر: https://support.peninsula-contracting.com \ No newline at end of file diff --git a/fonts/Amiri-Bold.ttf b/fonts/Amiri-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..74ac7f3ec33a42005d7c376b2a8ecc24be980bc5 --- /dev/null +++ b/fonts/Amiri-Bold.ttf @@ -0,0 +1,1814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Skip to content + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/fonts/Amiri-Regular.ttf b/fonts/Amiri-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a1d50b71a435135df82fa8b39bb9d0daa1d912cb --- /dev/null +++ b/fonts/Amiri-Regular.ttf @@ -0,0 +1,1814 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Skip to content + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + diff --git a/huggingface_app.py b/huggingface_app.py new file mode 100644 index 0000000000000000000000000000000000000000..0aeb60a5817f762fe10c4300a76148246ebd3b26 --- /dev/null +++ b/huggingface_app.py @@ -0,0 +1,83 @@ +import os +import sys +import streamlit as st + +# إضافة المسارات للعثور على الوحدات +current_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(current_dir) + +# استيراد التطبيق الرئيسي +try: + from app import main +except ImportError: + # محاولة استيراد بطريقة بديلة إذا فشلت الطريقة الأولى + try: + from tender_analysis_system.app import main + except ImportError: + st.error("❌ فشل استيراد التطبيق الرئيسي. تأكد من هيكل المجلدات وتثبيت المكتبات.") + st.info("ℹ️ قم بالتحقق من ملف requirements.txt وتأكد من تثبيت جميع المكتبات المطلوبة.") + + # عرض تعليمات حول كيفية إصلاح المشكلة + with st.expander("🛠️ كيفية إصلاح المشكلة"): + st.markdown(""" + ## خطوات إصلاح مشكلة الاستيراد + + 1. تأكد من تثبيت جميع المكتبات المطلوبة: + ```bash + pip install -r requirements.txt + ``` + + 2. تأكد من هيكل المجلدات: + ``` + / + ├── huggingface_app.py # هذا الملف الحالي + ├── app.py # التطبيق الرئيسي + ├── config.py # ملف الإعدادات + └── modules/ # وحدات التطبيق + ├── pricing/ + ├── document_analysis/ + └── ... + ``` + + 3. قم بفحص سجل الأخطاء أدناه: + """) + st.code(str(sys.path), language="python") + + # إظهار واجهة بديلة بسيطة + st.header("🚧 نظام تحليل المناقصات والعقود") + st.subheader("لم يتم تحميل التطبيق بنجاح") + st.write("هناك مشكلة في تحميل تطبيق تحليل المناقصات. يرجى مراجعة الإعدادات وإعادة المحاولة.") + + # الخروج من السكريبت + sys.exit(1) + +# ملاحظة: تم نقل إعداد الصفحة إلى ملف app.py الرئيسي +# لتجنب أخطاء set_page_config يجب أن يكون في ملف واحد فقط +# إعدادات الصفحة المطلوبة: +# page_title="نظام تحليل المناقصات والعقود" +# page_icon="📊" +# layout="wide" +# initial_sidebar_state="expanded" + +# تهيئة متغيرات البيئة +def setup_environment(): + """تهيئة متغيرات البيئة اللازمة للتطبيق""" + # التحقق من وجود مفاتيح API + if os.environ.get("ANTHROPIC_API_KEY") is None: + st.warning("⚠️ مفتاح API لـ Anthropic غير موجود. بعض الميزات قد لا تعمل.") + api_key = st.text_input("أدخل مفتاح Anthropic API الخاص بك:", type="password") + if api_key: + os.environ["ANTHROPIC_API_KEY"] = api_key + st.success("✅ تم تعيين مفتاح Anthropic API!") + + if os.environ.get("HUGGINGFACE_API_KEY") is None: + st.warning("⚠️ مفتاح API لـ Hugging Face غير موجود. بعض الميزات قد لا تعمل.") + api_key = st.text_input("أدخل مفتاح Hugging Face API الخاص بك:", type="password") + if api_key: + os.environ["HUGGINGFACE_API_KEY"] = api_key + st.success("✅ تم تعيين مفتاح Hugging Face API!") + +# تشغيل التطبيق +if __name__ == "__main__": + setup_environment() + main() \ No newline at end of file diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000000000000000000000000000000000000..263d6c982d1737539c5c6fb28ecbcd1a05661a85 --- /dev/null +++ b/models/README.md @@ -0,0 +1,52 @@ +# نماذج التعلم الآلي + +يحتوي هذا المجلد على نماذج التعلم الآلي المستخدمة في نظام تسعير المناقصات. + +## هيكل المجلد + +- `trained/`: يحتوي على النماذج المدربة جاهزة للاستخدام +- `datasets/`: يحتوي على مجموعات البيانات المستخدمة في تدريب النماذج + +## النماذج المستخدمة + +يستخدم النظام مجموعة من نماذج التعلم الآلي تشمل: + +1. **نموذج التنبؤ بالتكاليف**: يستخدم لتقدير تكاليف المشاريع بناءً على خصائص المشروع +2. **نموذج تقييم المخاطر**: يقيم المخاطر المحتملة للمشروع ويقدر تأثيرها +3. **نموذج التنبؤ بالمحتوى المحلي**: يتنبأ بنسبة المحتوى المحلي المتوقعة للمشروع +4. **نموذج التصنيف الذكي للمستندات**: يصنف مستندات المناقصة تلقائيًا +5. **نموذج التعرف على الكيانات**: يستخرج الكيانات المهمة من مستندات المناقصة + +## كيفية استخدام النماذج + +لاستخدام النماذج في التطبيق: + +```python +from models.inference import load_cost_prediction_model, predict_cost + +# تحميل النموذج +model = load_cost_prediction_model() + +# التنبؤ +features = { + 'project_type': 'construction', + 'area': 5000, + 'location': 'Riyadh', + 'duration_months': 18 +} + +predicted_cost = predict_cost(model, features) +print(f"التكلفة المتوقعة: {predicted_cost} ريال") +``` + +## تدريب النماذج + +يمكن إعادة تدريب النماذج باستخدام البيانات الجديدة من خلال: + +```python +from models.training import train_cost_prediction_model + +# تدريب النموذج +train_cost_prediction_model(new_data_path="datasets/new_cost_data.csv", + output_model_path="trained/cost_prediction_v2.pkl") +``` \ No newline at end of file diff --git a/models/datasets/README.md b/models/datasets/README.md new file mode 100644 index 0000000000000000000000000000000000000000..984a6b34e16055d6164541a666c2d41c8d6e36cd --- /dev/null +++ b/models/datasets/README.md @@ -0,0 +1,61 @@ +# مجموعات البيانات + +يحتوي هذا المجلد على مجموعات البيانات المستخدمة لتدريب نماذج التعلم الآلي في نظام تسعير المناقصات. + +## المجموعات المتوفرة + +- `cost_data.csv`: بيانات تكاليف المشاريع السابقة +- `risk_data.csv`: بيانات المخاطر وتأثيراتها +- `local_content_data.csv`: بيانات المحتوى المحلي +- `documents_data.csv`: بيانات المستندات المصنفة +- `entities_data.csv`: بيانات الكيانات المستخرجة + +## هيكل مجموعات البيانات + +### cost_data.csv + +بيانات تكاليف المشاريع السابقة مع خصائص كل مشروع: + +| العمود | الوصف | النوع | +|--------|-------|------| +| project_id | رقم المشروع | نص | +| project_type | نوع المشروع | نص | +| location | الموقع | نص | +| area | المساحة (م²) | رقم | +| floors | عدد الطوابق | رقم | +| duration_months | مدة التنفيذ (شهور) | رقم | +| tender_type | نوع المناقصة | نص | +| client_type | نوع العميل | نص | +| total_cost | إجمالي التكلفة | رقم | +| cost_per_sqm | تكلفة المتر المربع | رقم | +| material_cost | تكلفة المواد | رقم | +| labor_cost | تكلفة العمالة | رقم | +| equipment_cost | تكلفة المعدات | رقم | +| overhead_percentage | نسبة المصاريف العامة | رقم | + +### risk_data.csv + +بيانات المخاطر وتأثيراتها: + +| العمود | الوصف | النوع | +|--------|-------|------| +| risk_id | رقم المخاطرة | نص | +| project_id | رقم المشروع | نص | +| risk_category | فئة المخاطرة | نص | +| risk_description | وصف المخاطرة | نص | +| impact | التأثير | نص | +| probability | الاحتمالية | نص | +| risk_score | درجة المخاطرة | رقم | +| response_strategy | استراتيجية الاستجابة | نص | +| actual_impact | التأثير الفعلي | نص | +| actual_cost | التكلفة الفعلية | رقم | + +## الإحصاءات + +- عدد المشاريع: 500+ +- الفترة الزمنية: 2018-2024 +- التوزيع الجغرافي: جميع مناطق المملكة العربية السعودية + +## الترخيص والقيود + +هذه البيانات للاستخدام الداخلي فقط ولا يجوز مشاركتها خارج الشركة. \ No newline at end of file diff --git a/models/trained/README.md b/models/trained/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c19dcfd219c982bd5639f7e97850af5470eb4cfa --- /dev/null +++ b/models/trained/README.md @@ -0,0 +1,27 @@ +# النماذج المدربة + +يحتوي هذا المجلد على النماذج المدربة الجاهزة للاستخدام في نظام تسعير المناقصات. + +## النماذج المتوفرة + +- `cost_prediction.pkl`: نموذج التنبؤ بالتكاليف (Random Forest) +- `risk_assessment.pkl`: نموذج تقييم المخاطر (Gradient Boosting) +- `local_content_prediction.pkl`: نموذج التنبؤ بالمحتوى المحلي (XGBoost) +- `document_classifier.pkl`: نموذج تصنيف المستندات (BERT فائق) +- `entity_recognition.pkl`: نموذج التعرف على الكيانات (BiLSTM-CRF) + +## إصدارات النماذج + +| النموذج | الإصدار | تاريخ التدريب | المؤشرات الرئيسية | مجموعة التدريب | +|---------|---------|----------------|-------------------|----------------| +| cost_prediction.pkl | v1.2 | 2024-02-15 | MAE: 45,000 ريال | 500 مشروع | +| risk_assessment.pkl | v1.1 | 2024-02-10 | Accuracy: 87% | 350 مشروع | +| local_content_prediction.pkl | v1.0 | 2024-01-25 | RMSE: 3.2% | 280 مشروع | +| document_classifier.pkl | v2.1 | 2024-03-01 | F1: 0.92 | 1200 مستند | +| entity_recognition.pkl | v1.3 | 2024-03-05 | F1: 0.88 | 800 مستند | + +## ملاحظات الاستخدام + +- تم تدريب النماذج على بيانات مشاريع البناء والإنشاءات في المملكة العربية السعودية +- يتم تحديث النماذج بشكل دوري كل 3 أشهر لضمان دقتها +- للحصول على أفضل النتائج، استخدم البيانات بنفس التنسيق المستخدم في التدريب \ No newline at end of file diff --git a/modules/achievements/__init__.py b/modules/achievements/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a90f2266bb52cfe152316012068d7d797b6a7a0c --- /dev/null +++ b/modules/achievements/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +وحدة نظام الإنجازات المحفز لمراحل المشروع +""" \ No newline at end of file diff --git a/modules/achievements/achievement_system.py b/modules/achievements/achievement_system.py new file mode 100644 index 0000000000000000000000000000000000000000..1e882fc6e37e64ec689e4438aade888759985410 --- /dev/null +++ b/modules/achievements/achievement_system.py @@ -0,0 +1,1033 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +نظام الإنجازات المحفز لمراحل المشروع +""" + +import os +import sys +import json +import streamlit as st +import pandas as pd +import numpy as np +import time +from datetime import datetime, timedelta +import random + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات قاعدة البيانات +try: + from database.db_connector import get_connection +except ImportError: + from utils.helpers import get_connection + +from utils.helpers import format_time, get_user_info, load_icons + + +class AchievementSystem: + """نظام الإنجازات المحفز لمراحل المشروع""" + + def __init__(self, user_id=None): + """تهيئة نظام الإنجازات المحفز""" + self.user_id = user_id or 1 # استخدام المستخدم الافتراضي إذا لم يتم توفير معرف المستخدم + self.conn = get_connection() + self.achievements_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'achievements') + os.makedirs(self.achievements_path, exist_ok=True) + self.user_data_file = os.path.join(self.achievements_path, f'user_{self.user_id}_achievements.json') + self.icons = load_icons() + + # تحميل بيانات المستخدم + self.load_user_data() + + # تعريف قائمة الإنجازات + self.define_achievements() + + def load_user_data(self): + """تحميل بيانات إنجازات المستخدم""" + try: + if os.path.exists(self.user_data_file): + with open(self.user_data_file, 'r', encoding='utf-8') as f: + self.user_data = json.load(f) + else: + # بيانات افتراضية عند عدم وجود ملف + self.user_data = { + 'user_id': self.user_id, + 'total_points': 0, + 'level': 1, + 'unlocked_achievements': [], + 'in_progress_achievements': [], + 'last_updated': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + self.save_user_data() + except Exception as e: + st.error(f"خطأ في تحميل بيانات المستخدم: {e}") + self.user_data = { + 'user_id': self.user_id, + 'total_points': 0, + 'level': 1, + 'unlocked_achievements': [], + 'in_progress_achievements': [], + 'last_updated': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + def save_user_data(self): + """حفظ بيانات إنجازات المستخدم""" + try: + with open(self.user_data_file, 'w', encoding='utf-8') as f: + json.dump(self.user_data, f, ensure_ascii=False, indent=2) + except Exception as e: + st.error(f"خطأ في حفظ بيانات المستخدم: {e}") + + def define_achievements(self): + """تعريف قائمة الإنجازات المتاحة""" + self.achievements = [ + { + 'id': 'first_project', + 'name': 'بداية الرحلة', + 'description': 'قم بإنشاء مشروعك الأول', + 'icon': '🏆', + 'points': 100, + 'category': 'مشاريع', + 'difficulty': 'سهل' + }, + { + 'id': 'five_projects', + 'name': 'محترف المشاريع', + 'description': 'قم بإنشاء خمسة مشاريع', + 'icon': '🏅', + 'points': 500, + 'category': 'مشاريع', + 'difficulty': 'متوسط' + }, + { + 'id': 'ten_projects', + 'name': 'خبير المشاريع', + 'description': 'قم بإنشاء عشرة مشاريع', + 'icon': '🎖️', + 'points': 1000, + 'category': 'مشاريع', + 'difficulty': 'صعب' + }, + { + 'id': 'first_document_analysis', + 'name': 'المحلل الأول', + 'description': 'قم بتحليل مستند للمرة الأولى', + 'icon': '📊', + 'points': 150, + 'category': 'تحليل', + 'difficulty': 'سهل' + }, + { + 'id': 'five_document_analysis', + 'name': 'محلل متمرس', + 'description': 'قم بتحليل خمسة مستندات', + 'icon': '📈', + 'points': 600, + 'category': 'تحليل', + 'difficulty': 'متوسط' + }, + { + 'id': 'complete_boq', + 'name': 'خبير جداول الكميات', + 'description': 'أكمل تحليل جدول كميات كامل', + 'icon': '📋', + 'points': 300, + 'category': 'تحليل', + 'difficulty': 'متوسط' + }, + { + 'id': 'risk_analysis', + 'name': 'محلل المخاطر', + 'description': 'أكمل تحليل مخاطر متقدم', + 'icon': '⚠️', + 'points': 400, + 'category': 'مخاطر', + 'difficulty': 'متوسط' + }, + { + 'id': 'ten_risk_identified', + 'name': 'متنبئ المخاطر', + 'description': 'تعرف على عشرة مخاطر في المشاريع', + 'icon': '🔍', + 'points': 700, + 'category': 'مخاطر', + 'difficulty': 'صعب' + }, + { + 'id': 'first_terms_analysis', + 'name': 'محلل الشروط', + 'description': 'قم بتحليل بنود الشروط والأحكام', + 'icon': '📝', + 'points': 250, + 'category': 'تحليل', + 'difficulty': 'متوسط' + }, + { + 'id': 'quick_analysis', + 'name': 'محلل سريع', + 'description': 'أكمل تحليل مستند في أقل من 5 دقائق', + 'icon': '⚡', + 'points': 500, + 'category': 'كفاءة', + 'difficulty': 'صعب' + }, + { + 'id': 'voice_narration', + 'name': 'مترجم صوتي', + 'description': 'استخدم ميزة الترجمة الصوتية لأول مرة', + 'icon': '🎙️', + 'points': 200, + 'category': 'ترجمة', + 'difficulty': 'سهل' + }, + { + 'id': 'multilingual_expert', + 'name': 'خبير متعدد اللغات', + 'description': 'استخدم الترجمة الصوتية بخمس لغات مختلفة', + 'icon': '🌍', + 'points': 800, + 'category': 'ترجمة', + 'difficulty': 'صعب' + }, + { + 'id': 'first_map', + 'name': 'مستكشف الخرائط', + 'description': 'استخدم ميزة الخريطة التفاعلية لأول مرة', + 'icon': '🗺️', + 'points': 200, + 'category': 'خرائط', + 'difficulty': 'سهل' + }, + { + 'id': 'ai_fine_tuning', + 'name': 'مدرب الذكاء', + 'description': 'قم بتدريب نموذج ذكاء اصطناعي مخصص', + 'icon': '🧠', + 'points': 1000, + 'category': 'ذكاء اصطناعي', + 'difficulty': 'خبير' + }, + { + 'id': 'pricing_master', + 'name': 'سيد التسعير', + 'description': 'أكمل حساب تكلفة مشروع بالكامل', + 'icon': '💰', + 'points': 500, + 'category': 'تسعير', + 'difficulty': 'متوسط' + } + ] + + def calculate_level(self, points): + """حساب مستوى المستخدم بناءً على النقاط""" + # صيغة بسيطة لحساب المستوى: كل 1000 نقطة = مستوى واحد + level = 1 + int(points / 1000) + return level + + def unlock_achievement(self, achievement_id): + """إلغاء قفل إنجاز جديد""" + # التحقق من وجود الإنجاز في القائمة + achievement = next((a for a in self.achievements if a['id'] == achievement_id), None) + if not achievement: + return False + + # التحقق من عدم وجود الإنجاز مسبقاً + if achievement_id in [a['id'] for a in self.user_data['unlocked_achievements']]: + return False + + # إزالة الإنجاز من قائمة "قيد التقدم" إذا كان موجوداً + self.user_data['in_progress_achievements'] = [ + a for a in self.user_data['in_progress_achievements'] + if a['id'] != achievement_id + ] + + # إضافة الإنجاز إلى القائمة + achievement['unlocked_date'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self.user_data['unlocked_achievements'].append(achievement) + + # تحديث النقاط والمستوى + self.user_data['total_points'] += achievement['points'] + self.user_data['level'] = self.calculate_level(self.user_data['total_points']) + + # حفظ البيانات + self.save_user_data() + + return achievement + + def update_achievement_progress(self, achievement_id, progress, total): + """تحديث تقدم إنجاز معين""" + # التحقق من وجود الإنجاز في القائمة + achievement = next((a for a in self.achievements if a['id'] == achievement_id), None) + if not achievement: + return False + + # التحقق من عدم وجود الإنجاز في قائمة "تم إلغاء قفله" + if achievement_id in [a['id'] for a in self.user_data['unlocked_achievements']]: + return False + + # البحث عن الإنجاز في قائمة "قيد التقدم" + in_progress_achievement = next( + (a for a in self.user_data['in_progress_achievements'] if a['id'] == achievement_id), + None + ) + + if in_progress_achievement: + # تحديث التقدم + in_progress_achievement['progress'] = progress + in_progress_achievement['total'] = total + in_progress_achievement['percentage'] = min(100, int((progress / total) * 100)) + in_progress_achievement['last_updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + else: + # إضافة الإنجاز إلى قائمة "قيد التقدم" + progress_data = achievement.copy() + progress_data['progress'] = progress + progress_data['total'] = total + progress_data['percentage'] = min(100, int((progress / total) * 100)) + progress_data['start_date'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + progress_data['last_updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self.user_data['in_progress_achievements'].append(progress_data) + + # إذا اكتمل التقدم، قم بإلغاء قفل الإنجاز + if progress >= total: + return self.unlock_achievement(achievement_id) + + # حفظ البيانات + self.save_user_data() + + return True + + def check_and_award_achievements(self, action_type, data=None): + """التحقق من ومنح الإنجازات بناءً على إجراءات المستخدم""" + try: + if action_type == 'create_project': + # حساب عدد المشاريع + projects_count = self._get_projects_count() + + # منح إنجازات المشاريع + if projects_count == 1: + self.unlock_achievement('first_project') + elif projects_count == 5: + self.unlock_achievement('five_projects') + elif projects_count == 10: + self.unlock_achievement('ten_projects') + + # تحديث تقدم الإنجاز + self.update_achievement_progress('five_projects', min(projects_count, 5), 5) + self.update_achievement_progress('ten_projects', min(projects_count, 10), 10) + + elif action_type == 'analyze_document': + # حساب عدد تحليلات المستندات + analysis_count = self._get_document_analysis_count() + + # منح إنجازات تحليل المستندات + if analysis_count == 1: + self.unlock_achievement('first_document_analysis') + elif analysis_count == 5: + self.unlock_achievement('five_document_analysis') + + # تحديث تقدم الإنجاز + self.update_achievement_progress('five_document_analysis', min(analysis_count, 5), 5) + + # التحقق من الوقت المستغرق للتحليل + if data and 'duration_seconds' in data and data['duration_seconds'] < 300: # أقل من 5 دقائق + self.unlock_achievement('quick_analysis') + + elif action_type == 'analyze_boq': + self.unlock_achievement('complete_boq') + + elif action_type == 'analyze_terms': + self.unlock_achievement('first_terms_analysis') + + elif action_type == 'analyze_risks': + self.unlock_achievement('risk_analysis') + + # حساب عدد المخاطر المحددة + if data and 'risks_count' in data: + risks_count = data['risks_count'] + risk_total = self._get_total_risks_identified() + new_total = risk_total + risks_count + + # تحديث تقدم إنجاز "متنبئ المخاطر" + self.update_achievement_progress('ten_risk_identified', min(new_total, 10), 10) + + if new_total >= 10 and risk_total < 10: + self.unlock_achievement('ten_risk_identified') + + elif action_type == 'use_voice_narration': + self.unlock_achievement('voice_narration') + + # حساب عدد اللغات المستخدمة + if data and 'language' in data: + languages_used = self._get_languages_used() + if data['language'] not in languages_used: + languages_used.append(data['language']) + self._save_languages_used(languages_used) + + # تحديث تقدم إنجاز "خبير متعدد اللغات" + self.update_achievement_progress('multilingual_expert', len(languages_used), 5) + + if len(languages_used) >= 5: + self.unlock_achievement('multilingual_expert') + + elif action_type == 'use_map': + self.unlock_achievement('first_map') + + elif action_type == 'train_ai_model': + self.unlock_achievement('ai_fine_tuning') + + elif action_type == 'complete_pricing': + self.unlock_achievement('pricing_master') + + except Exception as e: + st.error(f"خطأ في التحقق من الإنجازات: {e}") + + def _get_projects_count(self): + """الحصول على عدد المشاريع""" + try: + cursor = self.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM documents WHERE user_id = %s AND type = 'project'", (self.user_id,)) + count = cursor.fetchone()[0] + cursor.close() + return count + except Exception: + # في حالة عدم وجود جدول أو خطأ في الاتصال، استخدم بيانات تقديرية + projects_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'projects') + if os.path.exists(projects_dir): + return len([f for f in os.listdir(projects_dir) if os.path.isdir(os.path.join(projects_dir, f))]) + return 0 + + def _get_document_analysis_count(self): + """الحصول على عدد تحليلات المستندات""" + try: + cursor = self.conn.cursor() + cursor.execute("SELECT COUNT(*) FROM document_analysis WHERE document_id IN (SELECT id FROM documents WHERE user_id = %s)", (self.user_id,)) + count = cursor.fetchone()[0] + cursor.close() + return count + except Exception: + # في حالة عدم وجود جدول أو خطأ في الاتصال، استخدم بيانات تقديرية + return len(self.user_data['unlocked_achievements']) + + def _get_total_risks_identified(self): + """الحصول على إجمالي عدد المخاطر المحددة""" + risks_file = os.path.join(self.achievements_path, f'user_{self.user_id}_risks.json') + if os.path.exists(risks_file): + try: + with open(risks_file, 'r', encoding='utf-8') as f: + risks_data = json.load(f) + return risks_data.get('total_risks', 0) + except Exception: + return 0 + return 0 + + def _save_total_risks_identified(self, total): + """حفظ إجمالي عدد المخاطر المحددة""" + risks_file = os.path.join(self.achievements_path, f'user_{self.user_id}_risks.json') + try: + with open(risks_file, 'w', encoding='utf-8') as f: + json.dump({'total_risks': total}, f, ensure_ascii=False, indent=2) + except Exception: + pass + + def _get_languages_used(self): + """الحصول على قائمة اللغات المستخدمة""" + languages_file = os.path.join(self.achievements_path, f'user_{self.user_id}_languages.json') + if os.path.exists(languages_file): + try: + with open(languages_file, 'r', encoding='utf-8') as f: + languages_data = json.load(f) + return languages_data.get('languages', []) + except Exception: + return [] + return [] + + def _save_languages_used(self, languages): + """حفظ قائمة اللغات المستخدمة""" + languages_file = os.path.join(self.achievements_path, f'user_{self.user_id}_languages.json') + try: + with open(languages_file, 'w', encoding='utf-8') as f: + json.dump({'languages': languages}, f, ensure_ascii=False, indent=2) + except Exception: + pass + + def render_achievements_tab(self): + """عرض علامة تبويب الإنجازات""" + st.markdown("

إنجازاتك

", unsafe_allow_html=True) + + # عرض مستوى المستخدم والنقاط + col1, col2 = st.columns([1, 3]) + with col1: + st.markdown(f"
المستوى {self.user_data['level']}
", unsafe_allow_html=True) + with col2: + # حساب النقاط المطلوبة للمستوى التالي + next_level_points = (self.user_data['level']) * 1000 + current_level_points = (self.user_data['level'] - 1) * 1000 + progress = (self.user_data['total_points'] - current_level_points) / (next_level_points - current_level_points) + + st.markdown(f"
{self.user_data['total_points']} نقطة
", unsafe_allow_html=True) + st.progress(progress, text=f"المستوى التالي: {next_level_points} نقطة") + + # تقسيم الإنجازات إلى مجموعات + st.markdown("

الإنجازات المفتوحة

", unsafe_allow_html=True) + + if not self.user_data['unlocked_achievements']: + st.info("لم تقم بفتح أي إنجازات حتى الآن. أكمل المهام للحصول على الإنجازات!") + else: + # عرض الإنجازات المفتوحة بتنسيق الشبكة + cols = st.columns(3) + for i, achievement in enumerate(self.user_data['unlocked_achievements']): + with cols[i % 3]: + self._render_achievement_card(achievement, is_unlocked=True) + + # عرض الإنجازات قيد التقدم + st.markdown("

الإنجازات قيد التقدم

", unsafe_allow_html=True) + + if not self.user_data['in_progress_achievements']: + st.info("ليس لديك أي إنجازات قيد التقدم حالياً.") + else: + # عرض الإنجازات قيد التقدم + for achievement in self.user_data['in_progress_achievements']: + self._render_progress_achievement(achievement) + + # عرض الإنجازات المتاحة + st.markdown("

الإنجازات المتاحة

", unsafe_allow_html=True) + + # فلترة الإنجازات غير المفتوحة وغير قيد التقدم + unlocked_ids = [a['id'] for a in self.user_data['unlocked_achievements']] + in_progress_ids = [a['id'] for a in self.user_data['in_progress_achievements']] + available_achievements = [a for a in self.achievements if a['id'] not in unlocked_ids and a['id'] not in in_progress_ids] + + if not available_achievements: + st.success("رائع! لقد حققت جميع الإنجازات المتاحة.") + else: + # تقسيم الإنجازات المتاحة حسب الفئات + categories = sorted(set(a['category'] for a in available_achievements)) + for category in categories: + st.markdown(f"
{category}
", unsafe_allow_html=True) + + category_achievements = [a for a in available_achievements if a['category'] == category] + cols = st.columns(3) + for i, achievement in enumerate(category_achievements): + with cols[i % 3]: + self._render_achievement_card(achievement, is_unlocked=False) + + def _render_achievement_card(self, achievement, is_unlocked): + """عرض بطاقة إنجاز""" + if is_unlocked: + card_class = "achievement-card unlocked" + icon_class = "achievement-icon unlocked" + title_class = "achievement-name unlocked" + points_display = f"{achievement['points']} نقطة" + date_display = f"تم الفتح: {achievement.get('unlocked_date', 'غير معروف')}" + else: + card_class = "achievement-card locked" + icon_class = "achievement-icon locked" + title_class = "achievement-name locked" + points_display = f"{achievement['points']} نقطة" + date_display = f"صعوبة: {achievement['difficulty']}" + + html = f""" +
+
{achievement['icon']}
+
{achievement['name']}
+
{achievement['description']}
+ +
+ """ + st.markdown(html, unsafe_allow_html=True) + + def _render_progress_achievement(self, achievement): + """عرض إنجاز قيد التقدم""" + progress = achievement.get('percentage', 0) + + html = f""" +
+
+
{achievement['icon']}
+
+
{achievement['name']}
+
{achievement['description']}
+
+
{achievement['points']} نقطة
+
+
+ """ + st.markdown(html, unsafe_allow_html=True) + + st.progress(progress / 100, text=f"{progress}% ({achievement.get('progress', 0)}/{achievement.get('total', 1)})") + + def render_achievements_summary(self): + """عرض ملخص الإنجازات في لوحة التحكم""" + # حساب الإحصائيات + total_achievements = len(self.achievements) + unlocked_count = len(self.user_data['unlocked_achievements']) + in_progress_count = len(self.user_data['in_progress_achievements']) + + st.markdown(f""" +
+
+
الإنجازات
+
المستوى {self.user_data['level']}
+
+
+
{int((unlocked_count / total_achievements) * 100)}%
+
{unlocked_count} / {total_achievements}
+
+ +
+ """, unsafe_allow_html=True) + + # عرض آخر 3 إنجازات تم فتحها + if self.user_data['unlocked_achievements']: + st.markdown("
آخر الإنجازات
", unsafe_allow_html=True) + + recent_achievements = sorted( + self.user_data['unlocked_achievements'], + key=lambda x: x.get('unlocked_date', ''), + reverse=True + )[:3] + + for achievement in recent_achievements: + st.markdown(f""" +
+
{achievement['icon']}
+
+
{achievement['name']}
+
{achievement.get('unlocked_date', '')}
+
+
+{achievement['points']}
+
+ """, unsafe_allow_html=True) + + def render(self): + """عرض واجهة نظام الإنجازات""" + st.markdown("

نظام الإنجازات المحفز لمراحل المشروع

", unsafe_allow_html=True) + + st.markdown(""" +
+ نظام الإنجازات يحفزك على إكمال المهام وتحقيق أهداف المشروع من خلال مكافآت + وإنجازات قابلة للفتح. اكتسب النقاط وارتقِ بمستواك وافتح إنجازات جديدة كلما تقدمت في استخدام نظام تحليل المناقصات. +
+ """, unsafe_allow_html=True) + + # عرض صندوق معلومات عند تشغيل الوحدة لأول مرة + if not self.user_data['unlocked_achievements'] and not self.user_data['in_progress_achievements']: + st.info(""" + 👋 مرحباً بك في نظام الإنجازات! + + استكشف الإنجازات المتاحة وابدأ في تحقيقها عن طريق إكمال المهام في أنحاء النظام المختلفة. + كلما حققت المزيد من الإنجازات، حصلت على نقاط أكثر وارتقيت في المستويات. + + ابدأ الآن بإنشاء مشروع جديد أو تحليل مستند! + """) + + # إنشاء علامات تبويب لعرض محتوى مختلف + tab1, tab2, tab3 = st.tabs(["الإنجازات", "المستويات والمكافآت", "الإحصائيات"]) + + with tab1: + self.render_achievements_tab() + + with tab2: + st.markdown("

المستويات والمكافآت

", unsafe_allow_html=True) + + # عرض معلومات عن نظام المستويات + st.markdown(""" +
+

نظام المستويات يعتمد على النقاط التي تكتسبها من إنجاز المهام وفتح الإنجازات:

+
    +
  • المستوى 1: 0 - 999 نقطة
  • +
  • المستوى 2: 1000 - 1999 نقطة
  • +
  • المستوى 3: 2000 - 2999 نقطة
  • +
  • وهكذا...
  • +
+

كلما ارتقيت في المستويات، تفتح مكافآت وميزات جديدة في النظام!

+
+ """, unsafe_allow_html=True) + + # عرض قائمة المكافآت + st.markdown("

المكافآت المتاحة

", unsafe_allow_html=True) + + rewards = [ + {"level": 2, "name": "قوالب مخصصة", "description": "الوصول إلى قوالب مخصصة للتقارير والتحليلات"}, + {"level": 3, "name": "تنبيهات متقدمة", "description": "إعدادات إشعارات متقدمة للمشاريع والمواعيد النهائية"}, + {"level": 5, "name": "تحليل معزز", "description": "خيارات إضافية لتحليل المستندات والعقود"}, + {"level": 7, "name": "تخصيص متقدم", "description": "خيارات إضافية لتخصيص واجهة النظام والتقارير"}, + {"level": 10, "name": "وضع الخبراء", "description": "وضع متقدم مع ميزات خاصة متاحة فقط للمستخدمين المخضرمين"} + ] + + for reward in rewards: + status = "متاح" if self.user_data['level'] >= reward['level'] else "مقفل" + status_class = "available" if self.user_data['level'] >= reward['level'] else "locked" + + st.markdown(f""" +
+
المستوى {reward['level']}
+
+
{reward['name']}
+
{reward['description']}
+
+
{status}
+
+ """, unsafe_allow_html=True) + + with tab3: + st.markdown("

إحصائيات الإنجازات

", unsafe_allow_html=True) + + # إعداد بيانات للرسم البياني + categories = {} + for achievement in self.achievements: + category = achievement['category'] + if category not in categories: + categories[category] = {"total": 0, "unlocked": 0} + categories[category]["total"] += 1 + + # حساب الإنجازات المفتوحة لكل فئة + for achievement in self.user_data['unlocked_achievements']: + category = achievement['category'] + if category in categories: + categories[category]["unlocked"] += 1 + + # تحويل البيانات إلى DataFrame + df = pd.DataFrame([ + { + "الفئة": category, + "المفتوحة": data["unlocked"], + "الإجمالي": data["total"], + "النسبة": round((data["unlocked"] / data["total"]) * 100 if data["total"] > 0 else 0) + } + for category, data in categories.items() + ]) + + # عرض البيانات في جدول + st.dataframe( + df, + column_config={ + "النسبة": st.column_config.ProgressColumn( + "نسبة الإنجاز", + format="%d%%", + min_value=0, + max_value=100 + ) + }, + hide_index=True + ) + + # عرض معلومات إضافية + col1, col2, col3 = st.columns(3) + with col1: + total_points_possible = sum(a['points'] for a in self.achievements) + st.metric( + "إجمالي النقاط المحتملة", + f"{total_points_possible}", + f"{int((self.user_data['total_points'] / total_points_possible) * 100)}%" + ) + + with col2: + days_since_first = 0 + if self.user_data['unlocked_achievements']: + first_date = min([ + datetime.strptime(a.get('unlocked_date', datetime.now().strftime('%Y-%m-%d %H:%M:%S')), '%Y-%m-%d %H:%M:%S') + for a in self.user_data['unlocked_achievements'] + ]) + days_since_first = (datetime.now() - first_date).days + + st.metric("أيام النشاط", f"{days_since_first}") + + with col3: + if self.user_data['unlocked_achievements']: + achievements_per_day = round(len(self.user_data['unlocked_achievements']) / max(1, days_since_first), 2) + st.metric("معدل الإنجازات اليومي", f"{achievements_per_day}") + else: + st.metric("معدل الإنجازات اليومي", "0") + + # إضافة CSS مخصص للصفحة + st.markdown(""" + + """, unsafe_allow_html=True) \ No newline at end of file diff --git a/modules/achievements/achievements_app.py b/modules/achievements/achievements_app.py new file mode 100644 index 0000000000000000000000000000000000000000..908c740742312d5b2f9e45f4c13334b3c436500d --- /dev/null +++ b/modules/achievements/achievements_app.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة تطبيق نظام الإنجازات المحفز لمراحل المشروع +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import time +from datetime import datetime, timedelta + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات نظام الإنجازات +from modules.achievements.achievement_system import AchievementSystem + + +class AchievementsApp: + """وحدة تطبيق نظام الإنجازات المحفز لمراحل المشروع""" + + def __init__(self, user_id=None): + """تهيئة وحدة تطبيق نظام الإنجازات المحفز""" + self.achievement_system = AchievementSystem(user_id) + + def render(self): + """عرض واجهة وحدة تطبيق نظام الإنجازات المحفز""" + self.achievement_system.render() + + def render_dashboard_summary(self): + """عرض ملخص الإنجازات في لوحة التحكم""" + self.achievement_system.render_achievements_summary() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="نظام الإنجازات المحفز | WAHBi AI", + page_icon="🏆", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = AchievementsApp() + app.render() \ No newline at end of file diff --git a/modules/ai_assistant/__init__.py b/modules/ai_assistant/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d6e1f9b02e95d00ffe620203226b5ec04b5495a6 --- /dev/null +++ b/modules/ai_assistant/__init__.py @@ -0,0 +1,5 @@ +""" +وحدة المساعد الذكي +""" + +__version__ = '1.0.0' \ No newline at end of file diff --git a/modules/ai_assistant/ai_assistant.py b/modules/ai_assistant/ai_assistant.py new file mode 100644 index 0000000000000000000000000000000000000000..9ac352b74035d2f4360a95e1fb1913eef4b154c7 --- /dev/null +++ b/modules/ai_assistant/ai_assistant.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة المساعد الذكي التفاعلية +تتيح للمستخدمين التفاعل مع نماذج الذكاء الاصطناعي المتقدمة للحصول على مساعدة في تحليل العقود والمناقصات +""" + +import os +import sys +import json +import re +import time +import base64 +import tempfile +import logging +from datetime import datetime +import streamlit as st +import pandas as pd +import numpy as np +import requests +from io import BytesIO +from PIL import Image +import openai +import plotly.express as px +import plotly.graph_objects as go + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد المكونات المساعدة +from utils.helpers import create_directory_if_not_exists, format_time, get_user_info, render_credits, load_css + + +class AIAssistant: + """فئة المساعد الذكي التفاعلية""" + + def __init__(self): + """تهيئة المساعد الذكي""" + self.conversations_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'assistant_conversations') + create_directory_if_not_exists(self.conversations_dir) + + # تهيئة مفتاح OpenAI API + self.openai_api_key = os.environ.get("OPENAI_API_KEY") + if self.openai_api_key: + openai.api_key = self.openai_api_key + self.is_api_available = True + else: + self.is_api_available = False + + # نموذج OpenAI المستخدم + self.model = "gpt-4o" # النموذج الأحدث من OpenAI + + # تهيئة حالة المحادثة في الجلسة + if "assistant_messages" not in st.session_state: + st.session_state.assistant_messages = [] + + if "assistant_mode" not in st.session_state: + st.session_state.assistant_mode = "general" + + if "document_context" not in st.session_state: + st.session_state.document_context = None + + # الأنماط المتاحة للمساعد + self.assistant_modes = { + "general": "مساعد عام", + "contract_analysis": "تحليل العقود", + "cost_estimation": "تقدير التكاليف", + "risk_assessment": "تقييم المخاطر", + "project_planning": "تخطيط المشاريع" + } + + # توجيهات النظام للمساعد + self.system_prompts = { + "general": """ + أنت مساعد ذكي متخصص في شركة شبه الجزيرة للمقاولات، وتعمل ضمن نظام WAHBi لتحليل العقود والمناقصات. + دورك هو مساعدة المستخدمين في: + 1. تحليل المستندات والعقود، وتوضيح بنود العقود وفهم الالتزامات والشروط. + 2. المساعدة في تسعير المشاريع وحساب التكاليف والموارد. + 3. تقييم مخاطر العقود والمشاريع والمساعدة في اتخاذ القرارات. + 4. المساعدة في إدارة المشاريع ومتابعة الإنجاز. + + استخدم لغة مهنية واضحة ومباشرة. قدم إجابات دقيقة ومختصرة. + عند قيام المستخدم بسؤال عن كيفية استخدام النظام، قم بإرشاده إلى الوحدة المناسبة في النظام. + + معلومات هامة عن وحدات النظام: + - وحدة تحليل المستندات: لتحليل العقود والمناقصات باستخدام الذكاء الاصطناعي. + - وحدة مقارنة المستندات: لمقارنة نسخ مختلفة من المستندات وتحديد التغييرات. + - وحدة التسعير المتكاملة: لحساب تكاليف المشاريع بناءً على الموارد والمواد والعمالة. + - وحدة تقييم مخاطر العقود: لتحليل وتقييم المخاطر المحتملة في العقود والمشاريع. + - وحدة متتبع حالة المشروع: لمتابعة تقدم المشاريع وعرض مؤشرات الأداء. + - وحدة خريطة المشاريع: لعرض مواقع المشاريع على الخريطة بشكل تفاعلي. + - وحدة الإشعارات الذكية: لإرسال تنبيهات وإشعارات للمستخدمين حول المشاريع. + + تذكر أن تكون مفيداً ودقيقاً ومهنياً في جميع إجاباتك. + """, + + "contract_analysis": """ + أنت محلل عقود متخصص في تحليل العقود والمناقصات لشركات المقاولات. + مهمتك هي تحليل العقود وتحديد: + - الالتزامات الرئيسية + - المواعيد النهائية والتسليمات + - الشروط الجزائية والغرامات + - آلية الدفع والمستحقات المالية + - الشروط الخاصة والاستثناءات + - المخاطر المحتملة وكيفية التخفيف منها + + عند تحليل عقد، قم بتوضيح البنود غير المواتية التي قد تسبب مشاكل مستقبلية. + استخدم لغة قانونية دقيقة مع شرح المصطلحات القانونية بلغة مبسطة. + قدم توصيات عملية لكيفية التعامل مع بنود العقد وتجنب المخاطر. + """, + + "cost_estimation": """ + أنت خبير في تقدير تكاليف مشاريع البناء والمقاولات. + مهمتك هي مساعدة المستخدم في: + - تقدير تكاليف المشاريع بناءً على وصف المشروع ومتطلباته + - حساب تكاليف المواد والعمالة والمعدات والنفقات العامة + - توضيح كيفية تخصيص الميزانية بين مختلف عناصر المشروع + - تحديد التكاليف غير المباشرة التي قد يغفل عنها المستخدم + - اقتراح طرق لتقليل التكاليف دون التأثير على جودة المشروع + + استخدم أسلوب منهجي في تقدير التكاليف واشرح افتراضاتك بوضوح. + قدم نطاقات تقديرية بدلاً من أرقام دقيقة للتكاليف حيثما كان ذلك مناسباً. + عند الإشارة إلى تكاليف، وضح ما إذا كانت التكاليف تشمل ضريبة القيمة المضافة أم لا. + """, + + "risk_assessment": """ + أنت خبير في تقييم مخاطر مشاريع البناء والمقاولات. + مهمتك هي مساعدة المستخدم في: + - تحديد المخاطر المحتملة في المشاريع والعقود + - تقييم احتمالية وتأثير كل خطر + - اقتراح استراتيجيات للتخفيف من المخاطر + - تحليل السيناريوهات المحتملة وخطط الطوارئ + - تقديم أفضل الممارسات لإدارة المخاطر في مشاريع المقاولات + + صنف المخاطر إلى فئات (عالية، متوسطة، منخفضة) بناءً على احتماليتها وتأثيرها. + اشرح كيف يمكن للشركة أن تحول بعض المخاطر إلى فرص. + قدم أمثلة عملية من مشاريع مماثلة لتوضيح كيفية إدارة المخاطر المحددة. + """, + + "project_planning": """ + أنت خبير في تخطيط وإدارة مشاريع البناء والمقاولات. + مهمتك هي مساعدة المستخدم في: + - تخطيط المشاريع وتقسيمها إلى مراحل ومهام + - تحديد الموارد اللازمة والجداول الزمنية + - إنشاء مخطط جانت وتحديد المسار الحرج + - التخطيط للموارد البشرية والمعدات والمواد + - متابعة تقدم المشروع ومؤشرات الأداء + + قدم نصائح عملية لإدارة المشاريع بكفاءة وتجنب التأخيرات. + اشرح كيفية التعامل مع التغييرات والمطالبات خلال تنفيذ المشروع. + قدم أفضل الممارسات للتواصل مع أصحاب المصلحة وإدارة التوقعات. + """ + } + + def _call_openai_api(self, messages, model=None, max_tokens=2000): + """استدعاء OpenAI API للحصول على استجابة""" + if not self.is_api_available: + return { + "choices": [{"message": {"content": "عذراً، مفتاح OpenAI API غير متوفر. يرجى التواصل مع مسؤول النظام."}}] + } + + try: + if model is None: + model = self.model + + response = openai.ChatCompletion.create( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=0.7, + top_p=0.9, + frequency_penalty=0, + presence_penalty=0 + ) + + return response + except Exception as e: + logging.error(f"خطأ في استدعاء OpenAI API: {e}") + return { + "choices": [{"message": {"content": f"عذراً، حدث خطأ في الاتصال بـ OpenAI API: {str(e)}"}}] + } + + def _call_backend_api(self, endpoint, data): + """استدعاء واجهة API الخلفية للنظام""" + try: + response = requests.post( + f"http://localhost:5000/api/{endpoint}", + json=data, + timeout=60 + ) + + if response.status_code == 200: + return response.json() + else: + logging.error(f"خطأ في استدعاء واجهة API الخلفية: {response.status_code} - {response.text}") + return {"error": f"خطأ في استدعاء واجهة API الخلفية: {response.status_code}"} + except Exception as e: + logging.error(f"خطأ في الاتصال بواجهة API الخلفية: {e}") + return {"error": f"خطأ في الاتصال بواجهة API الخلفية: {str(e)}"} + + def _process_user_message(self, user_message, mode=None): + """معالجة رسالة المستخدم والحصول على رد من المساعد الذكي""" + if mode is None: + mode = st.session_state.assistant_mode + + # إنشاء قائمة الرسائل للمحادثة + messages = [ + {"role": "system", "content": self.system_prompts[mode]} + ] + + # إضافة سياق المستند إذا كان متاحاً + if st.session_state.document_context: + messages.append({ + "role": "system", + "content": f"معلومات سياقية عن المستند: {st.session_state.document_context}" + }) + + # إضافة المحادثة السابقة + for msg in st.session_state.assistant_messages: + messages.append({ + "role": msg["role"], + "content": msg["content"] + }) + + # إضافة رسالة المستخدم الحالية + messages.append({ + "role": "user", + "content": user_message + }) + + # استدعاء API + response = self._call_openai_api(messages) + + # استخراج الرد + assistant_response = response["choices"][0]["message"]["content"] + + # تحديث سجل المحادثة + st.session_state.assistant_messages.append({"role": "user", "content": user_message}) + st.session_state.assistant_messages.append({"role": "assistant", "content": assistant_response}) + + return assistant_response + + def _clear_chat(self): + """مسح المحادثة الحالية""" + st.session_state.assistant_messages = [] + st.session_state.document_context = None + + def _save_conversation(self): + """حفظ المحادثة الحالية""" + if not st.session_state.assistant_messages: + st.warning("لا توجد محادثة لحفظها.") + return False + + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + user_info = get_user_info() + + conversation_data = { + "timestamp": timestamp, + "user": user_info["username"], + "mode": st.session_state.assistant_mode, + "messages": st.session_state.assistant_messages, + "document_context": st.session_state.document_context + } + + filename = f"conversation_{user_info['username']}_{timestamp}.json" + file_path = os.path.join(self.conversations_dir, filename) + + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(conversation_data, f, ensure_ascii=False, indent=2) + + return True + except Exception as e: + logging.error(f"خطأ في حفظ المحادثة: {e}") + return False + + def _load_conversation(self, filename): + """تحميل محادثة محفوظة""" + file_path = os.path.join(self.conversations_dir, filename) + + try: + with open(file_path, 'r', encoding='utf-8') as f: + conversation_data = json.load(f) + + st.session_state.assistant_messages = conversation_data["messages"] + st.session_state.assistant_mode = conversation_data["mode"] + st.session_state.document_context = conversation_data.get("document_context") + + return True + except Exception as e: + logging.error(f"خطأ في تحميل المحادثة: {e}") + return False + + def _get_saved_conversations(self): + """الحصول على قائمة المحادثات المحفوظة""" + conversations = [] + + try: + for filename in os.listdir(self.conversations_dir): + if filename.endswith(".json") and filename.startswith("conversation_"): + file_path = os.path.join(self.conversations_dir, filename) + + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + conversations.append({ + "filename": filename, + "timestamp": data.get("timestamp", ""), + "user": data.get("user", ""), + "mode": data.get("mode", "general"), + "message_count": len(data.get("messages", [])) + }) + except Exception as e: + logging.error(f"خطأ في قراءة المحادثات المحفوظة: {e}") + + # ترتيب المحادثات حسب التاريخ (الأحدث أولاً) + conversations.sort(key=lambda x: x["timestamp"], reverse=True) + + return conversations + + def render_chat_interface(self): + """عرض واجهة المحادثة الرئيسية""" + st.markdown("

المساعد الذكي

", unsafe_allow_html=True) + + # التحقق من توفر OpenAI API + if not self.is_api_available: + st.warning("⚠️ مفتاح OpenAI API غير متوفر. لن يكون المساعد الذكي قادراً على الرد. يرجى التواصل مع مسؤول النظام.") + + # إضافة CSS + st.markdown(""" + + """, unsafe_allow_html=True) + + # عرض أوضاع المساعد + st.markdown("#### اختر وضع المساعد الذكي") + + col1, col2, col3, col4, col5 = st.columns(5) + + with col1: + if st.button("مساعد عام", key="mode_general", + help="مساعد عام للإجابة على الأسئلة المتعلقة بالعقود والمناقصات"): + st.session_state.assistant_mode = "general" + st.rerun() + + with col2: + if st.button("تحليل العقود", key="mode_contract_analysis", + help="متخصص في تحليل العقود وتحديد البنود والشروط والمخاطر"): + st.session_state.assistant_mode = "contract_analysis" + st.rerun() + + with col3: + if st.button("تقدير التكاليف", key="mode_cost_estimation", + help="متخصص في تقدير تكاليف المشاريع والبنود"): + st.session_state.assistant_mode = "cost_estimation" + st.rerun() + + with col4: + if st.button("تقييم المخاطر", key="mode_risk_assessment", + help="متخصص في تحديد وتقييم المخاطر المحتملة في المشاريع والعقود"): + st.session_state.assistant_mode = "risk_assessment" + st.rerun() + + with col5: + if st.button("تخطيط المشاريع", key="mode_project_planning", + help="متخصص في تخطيط وإدارة المشاريع وتحديد المراحل والموارد"): + st.session_state.assistant_mode = "project_planning" + st.rerun() + + st.markdown(f"**الوضع الحالي:** {self.assistant_modes[st.session_state.assistant_mode]}") + + # تحميل سياق من مستند (اختياري) + st.markdown("---") + with st.expander("إضافة سياق من مستند", expanded=False): + context_text = st.text_area( + "نص المستند (اختياري)", + value=st.session_state.document_context if st.session_state.document_context else "", + height=150, + help="أضف نص المستند هنا ليتم استخدامه كسياق للمحادثة" + ) + + uploaded_file = st.file_uploader( + "أو قم بتحميل ملف نصي أو PDF", + type=["txt", "pdf"], + help="يمكنك تحميل ملف نصي أو PDF ليتم استخدامه كسياق للمحادثة" + ) + + doc_col1, doc_col2 = st.columns(2) + + with doc_col1: + if st.button("إضافة السياق", disabled=not context_text and not uploaded_file): + if uploaded_file: + try: + # قراءة الملف المرفوع + if uploaded_file.name.endswith(".pdf"): + import PyPDF2 + reader = PyPDF2.PdfReader(uploaded_file) + context = "" + for page in reader.pages: + context += page.extract_text() + "\n" + else: + context = uploaded_file.read().decode("utf-8") + + st.session_state.document_context = context + st.success("تم إضافة نص المستند كسياق للمحادثة بنجاح.") + except Exception as e: + st.error(f"حدث خطأ أثناء قراءة الملف: {str(e)}") + elif context_text: + st.session_state.document_context = context_text + st.success("تم إضافة نص المستند كسياق للمحادثة بنجاح.") + + with doc_col2: + if st.button("مسح السياق", disabled=not st.session_state.document_context): + st.session_state.document_context = None + st.success("تم مسح سياق المستند بنجاح.") + + # عرض المحادثة + st.markdown("---") + st.markdown("#### المحادثة مع المساعد الذكي") + + # عرض رسائل المحادثة + chat_container = st.container() + + with chat_container: + with st.container(): + if not st.session_state.assistant_messages: + st.markdown(""" +
+

مرحباً بك في المساعد الذكي!

+

يمكنك البدء بطرح سؤال أو طلب مساعدة.

+
+ """, unsafe_allow_html=True) + else: + message_html = "" + + for msg in st.session_state.assistant_messages: + if msg["role"] == "user": + message_html += f""" +
+
+
{msg["content"]}
+
+
أ
+
+ """ + else: + message_html += f""" +
+
W
+
+
{msg["content"]}
+
+
+ """ + + st.markdown(f""" +
+ {message_html} +
+ """, unsafe_allow_html=True) + + # ادخال الرسالة + st.markdown("#### أدخل رسالتك") + + with st.container(): + with st.form(key="chat_form"): + user_message = st.text_area("رسالتك", height=100, placeholder="اكتب سؤالك أو طلبك هنا...") + + col1, col2, col3 = st.columns([2, 2, 1]) + + with col1: + send_button = st.form_submit_button( + "إرسال", + help="إرسال الرسالة إلى المساعد الذكي" + ) + + with col2: + suggested_questions = [ + "كيف يمكنني تحليل بنود الدفع في العقد؟", + "ما هي أفضل طريقة لتقدير تكاليف مشروع بناء؟", + "كيف أحدد المخاطر المحتملة في مشروع جديد؟", + "كيف يمكنني إنشاء جدول زمني فعال للمشروع؟", + "ما هي أهم البنود التي يجب الانتباه إليها في عقود المقاولات؟" + ] + + if st.session_state.assistant_mode == "contract_analysis": + suggested_questions = [ + "كيف أحدد البنود غير المواتية في العقد؟", + "ما هي العناصر الأساسية التي يجب أن يتضمنها عقد المقاولة؟", + "كيف أتعامل مع بنود الغرامات والتعويضات؟", + "كيف يمكنني التفاوض على تحسين شروط الدفع؟", + "ما هي الفروق الرئيسية بين عقد الثمن الثابت وعقد التكلفة زائد أتعاب؟" + ] + elif st.session_state.assistant_mode == "cost_estimation": + suggested_questions = [ + "كيف أقدر تكلفة المواد في مشروع بناء؟", + "ما هي نسبة النفقات العامة المعقولة لمشروع مقاولات؟", + "كيف أحسب تكلفة العمالة بدقة؟", + "ما هي العوامل التي تؤثر على تكلفة المعدات؟", + "كيف أقدر هامش الربح المناسب للمشروع؟" + ] + + selected_question = st.selectbox( + "أو اختر سؤال مقترح", + [""] + suggested_questions, + index=0 + ) + + with col3: + clear_button = st.form_submit_button( + "مسح المحادثة", + help="مسح جميع الرسائل في المحادثة الحالية" + ) + + if send_button and user_message: + # معالجة رسالة المستخدم + with st.spinner("جاري معالجة الرسالة..."): + self._process_user_message(user_message) + st.rerun() + + if send_button and selected_question and not user_message: + # استخدام السؤال المقترح + with st.spinner("جاري معالجة الرسالة..."): + self._process_user_message(selected_question) + st.rerun() + + if clear_button: + self._clear_chat() + st.rerun() + + # زر لحفظ المحادثة + col1, col2, col3 = st.columns([1, 1, 2]) + + with col1: + if st.button("حفظ المحادثة", key="save_conversation", disabled=not st.session_state.assistant_messages): + if self._save_conversation(): + st.success("تم حفظ المحادثة بنجاح.") + else: + st.error("حدث خطأ أثناء حفظ المحادثة.") + + with col2: + if st.button("تحميل محادثة سابقة", key="show_load_conversation"): + st.session_state.show_conversations = True + st.rerun() + + # عرض المحادثات المحفوظة + if "show_conversations" in st.session_state and st.session_state.show_conversations: + st.markdown("---") + st.markdown("#### المحادثات المحفوظة") + + conversations = self._get_saved_conversations() + + if not conversations: + st.info("لا توجد محادثات محفوظة.") + else: + # عرض المحادثات في جدول + conversation_data = [] + for conv in conversations: + timestamp = datetime.strptime(conv["timestamp"], "%Y%m%d%H%M%S") if conv["timestamp"] else "" + formatted_time = timestamp.strftime("%Y-%m-%d %H:%M:%S") if timestamp else "" + + conversation_data.append({ + "التاريخ": formatted_time, + "المستخدم": conv["user"], + "وضع المساعد": self.assistant_modes.get(conv["mode"], "غير معروف"), + "عدد الرسائل": conv["message_count"], + "الملف": conv["filename"] + }) + + df = pd.DataFrame(conversation_data) + st.dataframe(df, height=300) + + # اختيار محادثة لتحميلها + selected_filename = st.selectbox( + "اختر محادثة لتحميلها", + options=[""] + [conv["filename"] for conv in conversations], + format_func=lambda x: next((f"{c['user']} - {datetime.strptime(c['timestamp'], '%Y%m%d%H%M%S').strftime('%Y-%m-%d %H:%M:%S')}" for c in conversations if c["filename"] == x), x), + index=0 + ) + + col1, col2 = st.columns(2) + + with col1: + if st.button("تحميل المحادثة المختارة", disabled=not selected_filename): + if self._load_conversation(selected_filename): + st.success("تم تحميل المحادثة بنجاح.") + st.session_state.show_conversations = False + st.rerun() + else: + st.error("حدث خطأ أثناء تحميل المحادثة.") + + with col2: + if st.button("إلغاء", key="cancel_load_conversation"): + st.session_state.show_conversations = False + st.rerun() + + # عرض المعلومات عن وضع المساعد الحالي + st.markdown("---") + st.markdown(f"#### معلومات عن وضع المساعد: {self.assistant_modes[st.session_state.assistant_mode]}") + + if st.session_state.assistant_mode == "general": + st.markdown(""" + المساعد العام يمكنه مساعدتك في مجموعة متنوعة من المهام المتعلقة بالعقود والمناقصات وإدارة المشاريع. يمكنه: + - الإجابة على الأسئلة العامة حول العقود والمناقصات + - توجيهك إلى الوحدات المناسبة في النظام + - تقديم معلومات عامة عن إدارة المشاريع وأفضل الممارسات + - المساعدة في فهم المصطلحات والمفاهيم المتعلقة بمجال المقاولات + """) + elif st.session_state.assistant_mode == "contract_analysis": + st.markdown(""" + مساعد تحليل العقود متخصص في: + - تحليل بنود العقود وتوضيح معانيها + - تحديد الالتزامات والحقوق لكل طرف + - تسليط الضوء على البنود غير المواتية أو الغامضة + - تقديم توصيات للتفاوض على تحسين شروط العقد + - مقارنة العقد مع أفضل الممارسات في القطاع + """) + elif st.session_state.assistant_mode == "cost_estimation": + st.markdown(""" + مساعد تقدير التكاليف متخصص في: + - حساب تكاليف المشاريع بناءً على المتطلبات والمواصفات + - تقدير تكاليف المواد والعمالة والمعدات + - تحليل التكاليف المباشرة وغير المباشرة + - تقديم نصائح لتقليل التكاليف وزيادة الكفاءة + - تحديد العوامل التي قد تؤثر على التكلفة الإجمالية + """) + elif st.session_state.assistant_mode == "risk_assessment": + st.markdown(""" + مساعد تقييم المخاطر متخصص في: + - تحديد المخاطر المحتملة في المشاريع والعقود + - تقييم احتمالية وتأثير كل خطر + - اقتراح استراتيجيات للتخفيف من المخاطر + - إنشاء خطط للطوارئ والاستجابة للمخاطر + - تحليل تأثير المخاطر على الجدول الزمني والتكلفة + """) + elif st.session_state.assistant_mode == "project_planning": + st.markdown(""" + مساعد تخطيط المشاريع متخصص في: + - تقسيم المشروع إلى مراحل ومهام وأنشطة + - تحديد الموارد اللازمة لكل نشاط + - إنشاء الجداول الزمنية والمسار الحرج + - التخطيط للموارد البشرية والمعدات والمواد + - مراقبة تقدم المشروع وإدارة التغييرات + """) + + # عرض معلومات حقوق الملكية + render_credits() + + def render(self): + """عرض واجهة المساعد الذكي الرئيسية""" + # تحميل CSS المخصص + load_css() + + # عرض واجهة المحادثة + self.render_chat_interface() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="المساعد الذكي | WAHBi AI", + page_icon="🤖", + layout="wide", + initial_sidebar_state="expanded" + ) + + assistant = AIAssistant() + assistant.render() \ No newline at end of file diff --git a/modules/ai_assistant/ai_assistant_app.py b/modules/ai_assistant/ai_assistant_app.py new file mode 100644 index 0000000000000000000000000000000000000000..4f667ece6e9a58e33ef6805b933cd551aeb2d2be --- /dev/null +++ b/modules/ai_assistant/ai_assistant_app.py @@ -0,0 +1,3175 @@ +# -*- coding: utf-8 -*- +""" +وحدة المساعد الذكي + +هذا الملف يحتوي على الفئة الرئيسية لتطبيق المساعد الذكي مع دعم نموذج Claude AI. +""" + +import streamlit as st +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import plotly.express as px +import requests +import json +import time +import base64 +import logging +import os +from datetime import datetime, timedelta +import io +import tempfile +import random +from io import BytesIO +from tempfile import NamedTemporaryFile +from PIL import Image + +# استيراد النماذج المطلوبة +try: + from models.inference import ( + load_cost_prediction_model, + load_document_classifier_model, + load_risk_assessment_model, + load_local_content_model, + load_entity_recognition_model + ) +except ImportError: + # إنشاء دوال وهمية في حال عدم توفر النماذج + def load_cost_prediction_model(): + return None + + def load_document_classifier_model(): + return None + + def load_risk_assessment_model(): + return None + + def load_local_content_model(): + return None + + def load_entity_recognition_model(): + return None + +try: + # استيراد مكتبة pdf2image للتعامل مع ملفات PDF + from pdf2image import convert_from_path + pdf_conversion_available = True +except ImportError: + pdf_conversion_available = False + logging.warning("لم يتم العثور على مكتبة pdf2image. لن يمكن تحويل ملفات PDF إلى صور.") + + +class ClaudeAIService: + """ + فئة خدمة Claude AI للتحليل الذكي + """ + def __init__(self): + """تهيئة خدمة Claude AI""" + self.api_url = "https://api.anthropic.com/v1/messages" + + def get_api_key(self): + """الحصول على مفتاح API من متغيرات البيئة""" + api_key = os.environ.get("anthropic") + if not api_key: + raise ValueError("مفتاح API لـ Claude غير موجود في متغيرات البيئة") + return api_key + + def get_available_models(self): + """ + الحصول على قائمة بالنماذج المتاحة + + العوائد: + dict: قائمة بالنماذج مع وصفها + """ + return { + "claude-3-7-sonnet": "Claude 3.7 Sonnet - نموذج ذكي للمهام المتقدمة", + "claude-3-5-haiku": "Claude 3.5 Haiku - أسرع نموذج للمهام اليومية" + } + + def get_model_full_name(self, short_name): + """ + تحويل الاسم المختصر للنموذج إلى الاسم الكامل + + المعلمات: + short_name: الاسم المختصر للنموذج + + العوائد: + str: الاسم الكامل للنموذج + """ + valid_models = { + "claude-3-7-sonnet": "claude-3-7-sonnet-20250219", + "claude-3-5-haiku": "claude-3-5-haiku-20240307" + } + + return valid_models.get(short_name, short_name) + + def analyze_image(self, image_path, prompt, model_name="claude-3-7-sonnet"): + """ + تحليل صورة باستخدام نموذج Claude AI + + المعلمات: + image_path: مسار الصورة المراد تحليلها + prompt: التوجيه للنموذج + model_name: اسم نموذج Claude المراد استخدامه + + العوائد: + dict: نتائج التحليل + """ + try: + # الحصول على مفتاح API + api_key = self.get_api_key() + + # قراءة محتوى الصورة + with open(image_path, 'rb') as f: + file_content = f.read() + + # تحويل المحتوى إلى Base64 + file_base64 = base64.b64encode(file_content).decode('utf-8') + + # تحديد نوع الملف من امتداده + _, ext = os.path.splitext(image_path) + ext = ext.lower() + + if ext in ('.jpg', '.jpeg'): + file_type = "image/jpeg" + elif ext == '.png': + file_type = "image/png" + elif ext == '.gif': + file_type = "image/gif" + elif ext == '.webp': + file_type = "image/webp" + else: + file_type = "image/jpeg" # افتراضي + + # التحقق من اسم النموذج وتصحيحه إذا لزم الأمر + model_name = self.get_model_full_name(model_name) + + # إعداد البيانات للطلب + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01" + } + + payload = { + "model": model_name, + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": file_type, + "data": file_base64 + } + } + ] + } + ] + } + + # إرسال الطلب إلى API + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=60 + ) + + # التحقق من نجاح الطلب + if response.status_code != 200: + error_message = f"فشل طلب API: {response.status_code}" + try: + error_details = response.json() + error_message += f"\nتفاصيل: {error_details}" + except: + error_message += f"\nتفاصيل: {response.text}" + + return {"error": error_message} + + # معالجة الاستجابة + result = response.json() + + return { + "success": True, + "content": result["content"][0]["text"], + "model": result["model"], + "usage": result.get("usage", {}) + } + + except Exception as e: + logging.error(f"خطأ أثناء تحليل الصورة: {str(e)}") + import traceback + stack_trace = traceback.format_exc() + return {"error": f"فشل في تحليل الصورة: {str(e)}\n{stack_trace}"} + + def chat_completion(self, messages, model_name="claude-3-7-sonnet"): + """ + إكمال محادثة باستخدام نموذج Claude AI + + المعلمات: + messages: سجل المحادثة + model_name: اسم نموذج Claude المراد استخدامه + + العوائد: + dict: نتائج الإكمال + """ + try: + # الحصول على مفتاح API + api_key = self.get_api_key() + + # تحويل رسائل streamlit إلى تنسيق Claude API + claude_messages = [] + for msg in messages: + claude_messages.append({ + "role": msg["role"], + "content": msg["content"] + }) + + # التحقق من اسم النموذج وتصحيحه إذا لزم الأمر + model_name = self.get_model_full_name(model_name) + + # إعداد البيانات للطلب + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01" + } + + payload = { + "model": model_name, + "max_tokens": 2048, + "messages": claude_messages, + "temperature": 0.7 + } + + # إرسال الطلب إلى API + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=30 + ) + + # التحقق من نجاح الطلب + if response.status_code != 200: + error_message = f"فشل طلب API: {response.status_code}" + try: + error_details = response.json() + error_message += f"\nتفاصيل: {error_details}" + except: + error_message += f"\nتفاصيل: {response.text}" + + return {"error": error_message} + + # معالجة الاستجابة + result = response.json() + + return { + "success": True, + "content": result["content"][0]["text"], + "model": result["model"], + "usage": result.get("usage", {}) + } + + except Exception as e: + logging.error(f"خطأ أثناء إكمال المحادثة: {str(e)}") + import traceback + stack_trace = traceback.format_exc() + return {"error": f"فشل في إكمال المحادثة: {str(e)}\n{stack_trace}"} + + +class AIAssistantApp: + """وحدة المساعد الذكي""" + + def __init__(self): + """تهيئة وحدة المساعد الذكي""" + # تحميل النماذج عند بدء التشغيل + self.cost_model = load_cost_prediction_model() + self.document_model = load_document_classifier_model() + self.risk_model = load_risk_assessment_model() + self.local_content_model = load_local_content_model() + self.entity_model = load_entity_recognition_model() + + # إنشاء خدمة Claude AI + self.claude_service = ClaudeAIService() + + # تهيئة قائمة الأسئلة والإجابات الشائعة + self.faqs = [ + { + "question": "كيف يمكنني إضافة مشروع جديد؟", + "answer": "يمكنك إضافة مشروع جديد من خلال الانتقال إلى وحدة إدارة المشاريع، ثم النقر على زر 'إضافة مشروع جديد'، وملء النموذج بالبيانات المطلوبة." + }, + { + "question": "ما هي خطوات تسعير المناقصة؟", + "answer": "تتضمن خطوات تسعير المناقصة: 1) تحليل مستندات المناقصة، 2) تحديد بنود العمل، 3) تقدير التكاليف المباشرة، 4) إضافة المصاريف العامة والأرباح، 5) احتساب المحتوى المحلي، 6) مراجعة النتائج النهائية." + }, + { + "question": "كيف يتم حساب المحتوى المحلي؟", + "answer": "يتم حساب المحتوى المحلي بتحديد نسبة المنتجات والخدمات والقوى العاملة المحلية من إجمالي التكاليف. يتم استخدام قاعدة بيانات الموردين المعتمدين وتطبيق معادلات خاصة حسب متطلبات هيئة المحتوى المحلي." + }, + { + "question": "كيف يمكنني تصدير التقارير؟", + "answer": "يمكنك تصدير التقارير من وحدة التقارير والتحليلات، حيث يوجد زر 'تصدير' في كل تقرير. يمكن تصدير التقارير بتنسيقات مختلفة مثل Excel و PDF و CSV." + }, + { + "question": "كيف يمكنني تقييم المخاطر للمشروع؟", + "answer": "يمكنك تقييم المخاطر للمشروع من خلال وحدة المخاطر، حيث يمكنك إضافة المخاطر المحتملة وتقييم تأثيرها واحتماليتها، ثم وضع خطة الاستجابة المناسبة." + }, + { + "question": "ما هي طرق التسعير المتاحة في النظام؟", + "answer": "يوفر النظام أربع طرق للتسعير: 1) التسعير القياسي، 2) التسعير غير المتزن، 3) التسعير التنافسي، 4) التسعير الموجه بالربحية. يمكنك اختيار الطريقة المناسبة حسب طبيعة المشروع واستراتيجية الشركة." + }, + { + "question": "كيف يمكنني معالجة مستندات المناقصة ضخمة الحجم؟", + "answer": "يمكنك استخدام وحدة تحليل المستندات لمعالجة مستندات المناقصة ضخمة الحجم، حيث تقوم الوحدة بتحليل المستندات واستخراج المعلومات المهمة مثل مواصفات المشروع ومتطلباته وشروطه تلقائياً." + } + ] + + def render(self): + """عرض واجهة وحدة المساعد الذكي""" + + st.markdown("

وحدة المساعد الذكي

", unsafe_allow_html=True) + + tabs = st.tabs([ + "المساعد الذكي", + "التنبؤ بالتكاليف", + "تحليل المخاطر", + "تحليل المستندات", + "المحتوى المحلي", + "الأسئلة الشائعة" + ]) + + with tabs[0]: + self._render_ai_assistant_tab() + + with tabs[1]: + self._render_cost_prediction_tab() + + with tabs[2]: + self._render_risk_analysis_tab() + + with tabs[3]: + self._render_document_analysis_tab() + + with tabs[4]: + self._render_local_content_tab() + + with tabs[5]: + self._render_faq_tab() + + def _render_ai_assistant_tab(self): + """عرض تبويب المساعد الذكي مع دعم Claude AI""" + + st.markdown("### المساعد الذكي لتسعير المناقصات") + + # اختيار نموذج Claude + claude_models = self.claude_service.get_available_models() + + selected_model = st.radio( + "اختر نموذج الذكاء الاصطناعي", + options=list(claude_models.keys()), + format_func=lambda x: claude_models[x], + horizontal=True, + key="assistant_ai_model" + ) + + # عرض واجهة المحادثة + st.markdown(""" +
+
+

المساعد الذكي

+

تحدث مع المساعد الذكي للحصول على المساعدة في تسعير المناقصات وتحليل البيانات

+
+
+ """, unsafe_allow_html=True) + + # تهيئة محفوظات المحادثة في حالة الجلسة إذا لم تكن موجودة + if 'ai_assistant_messages' not in st.session_state: + st.session_state.ai_assistant_messages = [ + {"role": "assistant", "content": "مرحباً! أنا المساعد الذكي لنظام تسعير المناقصات. كيف يمكنني مساعدتك اليوم؟"} + ] + + # عرض محفوظات المحادثة بتنسيق محسن + chat_container = st.container() + with chat_container: + for message in st.session_state.ai_assistant_messages: + if message["role"] == "user": + st.markdown(f""" +
+
+ {message["content"]} +
+
+ """, unsafe_allow_html=True) + else: + st.markdown(f""" +
+
+ {message["content"]} +
+
+ """, unsafe_allow_html=True) + + # إضافة خيار رفع الملفات + uploaded_file = st.file_uploader( + "اختياري: ارفع ملفًا للمساعدة (صورة، PDF)", + type=["jpg", "jpeg", "png", "pdf"], + key="assistant_file_upload" + ) + + # مربع إدخال الرسالة + user_input = st.text_input("اكتب رسالتك هنا", key="ai_assistant_input") + + # التحقق من وجود مفتاح API + api_available = True + try: + self.claude_service.get_api_key() + except ValueError: + api_available = False + st.warning("مفتاح API لـ Claude غير متوفر. يرجى التأكد من تعيين متغير البيئة 'anthropic'.") + + if user_input and api_available: + # إضافة رسالة المستخدم إلى المحفوظات + st.session_state.ai_assistant_messages.append({"role": "user", "content": user_input}) + + # عرض محفوظات المحادثة المحدثة + with chat_container: + st.markdown(f""" +
+
+ {user_input} +
+
+ """, unsafe_allow_html=True) + + # معالجة الرد + with st.spinner("جاري التفكير..."): + # التحقق مما إذا كان هناك ملف مرفق + if uploaded_file: + # حفظ الملف المرفوع مؤقتاً + with tempfile.NamedTemporaryFile(delete=False, suffix=f".{uploaded_file.name.split('.')[-1]}") as temp_file: + temp_file.write(uploaded_file.getbuffer()) + temp_file_path = temp_file.name + + # إذا كان الملف PDF، تحويله إلى صورة + if uploaded_file.name.lower().endswith('.pdf'): + if pdf_conversion_available: + try: + # تحويل الصفحة الأولى فقط + images = convert_from_path(temp_file_path, first_page=1, last_page=1) + if images: + # حفظ الصورة بشكل مؤقت + temp_image_path = f"{temp_file_path}_image.jpg" + images[0].save(temp_image_path, 'JPEG') + # استخدام مسار الصورة بدلاً من PDF + os.remove(temp_file_path) + temp_file_path = temp_image_path + except Exception as e: + st.error(f"فشل في تحويل ملف PDF إلى صورة: {str(e)}") + else: + st.error("تحليل ملفات PDF يتطلب تثبيت مكتبة pdf2image.") + response = "عذراً، لا يمكنني تحليل ملفات PDF في الوقت الحالي. يرجى تحويل الملف إلى صورة أو مشاركة المعلومات كنص." + + # تحليل الصورة باستخدام Claude + prompt = f"المستخدم قام برفع هذه الصورة وسأل: {user_input}\nقم بتحليل الصورة والرد على سؤال المستخدم بشكل تفصيلي." + results = self.claude_service.analyze_image(temp_file_path, prompt, model_name=selected_model) + + # حذف الملف المؤقت + try: + os.remove(temp_file_path) + except: + pass + + if "error" in results: + response = f"عذراً، حدث خطأ أثناء تحليل الملف: {results['error']}" + else: + response = results["content"] + else: + # استخدام خدمة Claude للرد على الرسائل النصية + results = self.claude_service.chat_completion(st.session_state.ai_assistant_messages, model_name=selected_model) + + if "error" in results: + response = f"عذراً، حدث خطأ أثناء معالجة طلبك: {results['error']}" + else: + response = results["content"] + + # إضافة رد المساعد إلى المحفوظات + st.session_state.ai_assistant_messages.append({"role": "assistant", "content": response}) + + # عرض رد المساعد + with chat_container: + st.markdown(f""" +
+
+ {response} +
+
+ """, unsafe_allow_html=True) + + # إعادة تعيين قيمة الإدخال + st.text_input("اكتب رسالتك هنا", value="", key="ai_assistant_input_reset") + + def _generate_ai_response(self, user_input, model_name="claude-3-7-sonnet"): + """توليد رد المساعد الذكي باستخدام Claude AI""" + + # التحقق من وجود مفتاح API + try: + self.claude_service.get_api_key() + except ValueError: + return "عذراً، لا يمكنني الاتصال بخدمة الذكاء الاصطناعي في الوقت الحالي. يرجى التحقق من إعدادات API." + + # البحث في الأسئلة الشائعة أولاً + for faq in self.faqs: + if any(keyword in user_input.lower() for keyword in faq["question"].lower().split()): + return f"{faq['answer']}\n\nهل تحتاج إلى مساعدة أخرى؟" + + # إنشاء محادثة لإرسالها إلى Claude + messages = [ + {"role": "user", "content": user_input} + ] + + # استدعاء خدمة Claude + results = self.claude_service.chat_completion(messages, model_name=model_name) + + if "error" in results: + # إذا فشل الاتصال، استخدم التوليد الافتراضي + logging.warning(f"فشل الاتصال بـ Claude AI: {results['error']}. استخدام التوليد الافتراضي.") + return self._generate_default_response(user_input) + else: + return results["content"] + + def _generate_default_response(self, user_input): + """توليد رد افتراضي في حالة عدم توفر Claude AI""" + + if "تسعير" in user_input or "سعر" in user_input or "تكلفة" in user_input: + return "يمكنك استخدام وحدة التنبؤ بالتكاليف لتقدير تكاليف المشروع بناءً على خصائصه. انتقل إلى تبويب 'التنبؤ بالتكاليف' وأدخل بيانات المشروع لتحصل على تقدير دقيق للتكاليف." + + elif "مخاطر" in user_input or "مخاطرة" in user_input: + return "يمكنك استخدام وحدة تحليل المخاطر لتقييم المخاطر المحتملة للمشروع. انتقل إلى تبويب 'تحليل المخاطر' وأدخل بيانات المشروع وعوامل المخاطرة لتحصل على تحليل شامل للمخاطر واستراتيجيات الاستجابة المقترحة." + + elif "مستند" in user_input or "ملف" in user_input or "وثيقة" in user_input or "مناقصة" in user_input: + return "يمكنك استخدام وحدة تحليل المستندات لتحليل مستندات المناقصة واستخراج المعلومات المهمة منها. انتقل إلى تبويب 'تحليل المستندات' وقم بتحميل ملفات المناقصة لتحصل على تحليل تفصيلي للمستندات." + + elif "محتوى محلي" in user_input or "محلي" in user_input: + return "يمكنك استخدام وحدة المحتوى المحلي لحساب وتحسين نسبة المحتوى المحلي في مشروعك. انتقل إلى تبويب 'المحتوى المحلي' وأدخل بيانات مكونات المشروع لتحصل على تحليل شامل للمحتوى المحلي واقتراحات لتحسينه." + + elif "تقرير" in user_input or "إحصائيات" in user_input or "بيانات" in user_input: + return "يمكنك استخدام وحدة التقارير والتحليلات للحصول على تقارير تفصيلية وإحصائيات عن المشاريع. يمكنك الوصول إليها من القائمة الرئيسية للنظام." + + else: + return "شكراً لاستفسارك. يمكنني مساعدتك في تسعير المناقصات، وتحليل المخاطر، وتحليل المستندات، وحساب المحتوى المحلي. يرجى توضيح استفسارك أكثر أو اختيار أحد الخيارات في الأعلى للحصول على المساعدة المطلوبة." + + def _render_cost_prediction_tab(self): + """عرض تبويب التنبؤ بالتكاليف""" + + st.markdown("### التنبؤ بالتكاليف") + + # عرض نموذج إدخال بيانات المشروع + st.markdown("#### بيانات المشروع") + + col1, col2 = st.columns(2) + + with col1: + project_type = st.selectbox( + "نوع المشروع", + [ + "مباني سكنية", + "مباني تجارية", + "مباني حكومية", + "مراكز صحية", + "مدارس", + "بنية تحتية", + "طرق", + "جسور", + "صرف صحي", + "مياه", + "كهرباء" + ], + key="cost_project_type" + ) + + location = st.selectbox( + "الموقع", + [ + "الرياض", + "جدة", + "الدمام", + "مكة", + "المدينة", + "تبوك", + "حائل", + "عسير", + "جازان", + "نجران", + "الباحة", + "الجوف", + "القصيم" + ], + key="cost_location" + ) + + client_type = st.selectbox( + "نوع العميل", + [ + "حكومي", + "شبه حكومي", + "شركة كبيرة", + "شركة متوسطة", + "شركة صغيرة", + "أفراد" + ], + key="cost_client_type" + ) + + with col2: + area = st.number_input("المساحة (م²)", min_value=100, max_value=1000000, value=5000, key="cost_area") + + floors = st.number_input("عدد الطوابق", min_value=1, max_value=100, value=3, key="cost_floors") + + duration = st.number_input("مدة التنفيذ (شهور)", min_value=1, max_value=60, value=12, key="cost_duration") + + tender_type = st.selectbox( + "نوع المناقصة", + [ + "عامة", + "خاصة", + "أمر مباشر" + ], + key="cost_tender_type" + ) + + st.markdown("#### متغيرات إضافية") + + col1, col2, col3 = st.columns(3) + + with col1: + has_basement = st.checkbox("يتضمن بدروم", key="cost_has_basement") + has_special_finishing = st.checkbox("تشطيبات خاصة", key="cost_has_special_finishing") + + with col2: + has_landscape = st.checkbox("أعمال تنسيق المواقع", key="cost_has_landscape") + has_parking = st.checkbox("مواقف متعددة الطوابق", key="cost_has_parking") + + with col3: + has_smart_systems = st.checkbox("أنظمة ذكية", key="cost_has_smart_systems") + has_sustainability = st.checkbox("متطلبات استدامة", key="cost_has_sustainability") + + # زر التنبؤ بالتكلفة مع دعم Claude AI + col1, col2 = st.columns([1, 3]) + + with col1: + predict_button = st.button("التنبؤ بالتكلفة", use_container_width=True, key="cost_predict_button") + + with col2: + use_claude = st.checkbox("استخدام Claude AI للتحليل المتقدم", value=True, key="cost_use_claude") + + if predict_button: + with st.spinner("جاري تحليل البيانات والتنبؤ بالتكاليف..."): + # محاكاة وقت المعالجة + time.sleep(2) + + # تجهيز البيانات للنموذج + features = { + 'project_type': project_type, + 'location': location, + 'area': area, + 'floors': floors, + 'duration_months': duration, + 'tender_type': tender_type, + 'client_type': client_type, + 'has_basement': has_basement, + 'has_special_finishing': has_special_finishing, + 'has_landscape': has_landscape, + 'has_parking': has_parking, + 'has_smart_systems': has_smart_systems, + 'has_sustainability': has_sustainability + } + + # استدعاء النموذج للتنبؤ + cost_prediction_results = self._predict_cost(features) + + # إضافة تحليل إضافي باستخدام Claude AI إذا تم تفعيل الخيار + if use_claude: + try: + # إنشاء نص الميزات للتحليل + features_text = f""" + بيانات المشروع: + - نوع المشروع: {project_type} + - الموقع: {location} + - المساحة: {area} م² + - عدد الطوابق: {floors} + - مدة التنفيذ: {duration} شهر + - نوع المناقصة: {tender_type} + - نوع العميل: {client_type} + - يتضمن بدروم: {'نعم' if has_basement else 'لا'} + - تشطيبات خاصة: {'نعم' if has_special_finishing else 'لا'} + - أعمال تنسيق المواقع: {'نعم' if has_landscape else 'لا'} + - مواقف متعددة الطوابق: {'نعم' if has_parking else 'لا'} + - أنظمة ذكية: {'نعم' if has_smart_systems else 'لا'} + - متطلبات استدامة: {'نعم' if has_sustainability else 'لا'} + + نتائج التنبؤ الأولية: + - التكلفة الإجمالية المقدرة: {cost_prediction_results['total_cost']:,.0f} ريال + - تكلفة المتر المربع: {cost_prediction_results['cost_per_sqm']:,.0f} ريال/م² + - تكلفة المواد: {cost_prediction_results['material_cost']:,.0f} ريال + - تكلفة العمالة: {cost_prediction_results['labor_cost']:,.0f} ريال + - تكلفة المعدات: {cost_prediction_results['equipment_cost']:,.0f} ريال + """ + + prompt = f"""تحليل بيانات مشروع وتكاليفه: + + {features_text} + + المطلوب: + 1. تحليل التكاليف المتوقعة ومعقوليتها مقارنة بمشاريع مماثلة في السوق السعودي + 2. تقديم توصيات وملاحظات لتحسين التكلفة + 3. تحديد أي مخاطر محتملة قد تؤثر على التكلفة + 4. تقديم نصائح لزيادة فعالية التكلفة + 5. تقديم رأي حول مدى تنافسية هذه التكلفة في السوق الحالي + + يرجى تقديم تحليل مهني ومختصر يركز على الجوانب الأكثر أهمية. + """ + + # استدعاء Claude للتحليل + claude_analysis = self.claude_service.chat_completion( + [{"role": "user", "content": prompt}] + ) + + if "error" not in claude_analysis: + # إضافة تحليل Claude إلى النتائج + cost_prediction_results["claude_analysis"] = claude_analysis["content"] + except Exception as e: + st.warning(f"تعذر إجراء التحليل المتقدم: {str(e)}") + + # عرض نتائج التنبؤ + self._display_cost_prediction_results(cost_prediction_results) + + def _predict_cost(self, features): + """التنبؤ بتكاليف المشروع""" + + # في البيئة الحقيقية، سيتم استدعاء نموذج التنبؤ بالتكاليف + # محاكاة نتائج التنبؤ للعرض + + # حساب القيمة الأساسية للمتر المربع حسب نوع المشروع + base_cost_per_sqm = { + "مباني سكنية": 2500, + "مباني تجارية": 3000, + "مباني حكومية": 3500, + "مراكز صحية": 4000, + "مدارس": 3200, + "بنية تحتية": 2000, + "طرق": 1500, + "جسور": 5000, + "صرف صحي": 2200, + "مياه": 2000, + "كهرباء": 2500 + }.get(features['project_type'], 2500) + + # تطبيق معاملات التعديل حسب المتغيرات + location_factor = { + "الرياض": 1.1, + "جدة": 1.15, + "الدمام": 1.05, + "مكة": 1.2, + "المدينة": 1.1, + "تبوك": 0.95, + "حائل": 0.9, + "عسير": 0.95, + "جازان": 0.9, + "نجران": 0.85, + "الباحة": 0.9, + "الجوف": 0.85, + "القصيم": 0.9 + }.get(features['location'], 1.0) + + client_factor = { + "حكومي": 1.05, + "شبه حكومي": 1.0, + "شركة كبيرة": 0.95, + "شركة متوسطة": 0.9, + "شركة صغيرة": 0.85, + "أفراد": 0.8 + }.get(features['client_type'], 1.0) + + tender_factor = { + "عامة": 1.0, + "خاصة": 0.95, + "أمر مباشر": 0.9 + }.get(features['tender_type'], 1.0) + + # معاملات للميزات الإضافية + basement_factor = 1.1 if features['has_basement'] else 1.0 + special_finishing_factor = 1.2 if features['has_special_finishing'] else 1.0 + landscape_factor = 1.05 if features['has_landscape'] else 1.0 + parking_factor = 1.1 if features['has_parking'] else 1.0 + smart_systems_factor = 1.15 if features['has_smart_systems'] else 1.0 + sustainability_factor = 1.1 if features['has_sustainability'] else 1.0 + + # معامل لعدد الطوابق + floors_factor = 1.0 + (features['floors'] - 1) * 0.05 + + # حساب التكلفة الإجمالية + total_sqm_cost = base_cost_per_sqm * location_factor * client_factor * tender_factor * \ + basement_factor * special_finishing_factor * landscape_factor * \ + parking_factor * smart_systems_factor * sustainability_factor * \ + floors_factor + + total_cost = total_sqm_cost * features['area'] + + # حساب التكاليف المفصلة + material_cost = total_cost * 0.6 + labor_cost = total_cost * 0.25 + equipment_cost = total_cost * 0.15 + + # إضافة هامش خطأ عشوائي للمحاكاة + error_margin = 0.05 # 5% + total_cost = total_cost * (1 + np.random.uniform(-error_margin, error_margin)) + + # إعداد النتائج + results = { + "total_cost": total_cost, + "cost_per_sqm": total_cost / features['area'], + "material_cost": material_cost, + "labor_cost": labor_cost, + "equipment_cost": equipment_cost, + "breakdown": { + "structural_works": total_cost * 0.35, + "architectural_works": total_cost * 0.25, + "mep_works": total_cost * 0.25, + "site_works": total_cost * 0.1, + "general_requirements": total_cost * 0.05 + }, + "confidence_level": 0.85, # مستوى الثقة في التنبؤ + "comparison": { + "market_average": total_cost * 1.1, + "historical_projects": total_cost * 0.95 + } + } + + return results + + def _display_cost_prediction_results(self, results): + """عرض نتائج التنبؤ بالتكاليف""" + + st.markdown("### نتائج التنبؤ بالتكاليف") + + # عرض التكلفة الإجمالية وتكلفة المتر المربع + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + "التكلفة الإجمالية المتوقعة", + f"{results['total_cost']:,.0f} ريال", + delta=f"{(results['total_cost'] - results['comparison']['historical_projects']):,.0f} ريال" + ) + + with col2: + st.metric( + "تكلفة المتر المربع", + f"{results['cost_per_sqm']:,.0f} ريال/م²" + ) + + with col3: + st.metric( + "مستوى الثقة في التنبؤ", + f"{results['confidence_level'] * 100:.0f}%" + ) + + # عرض تفصيل التكاليف + st.markdown("#### تفصيل التكاليف") + + # رسم مخطط دائري للتكاليف المفصلة + fig = px.pie( + values=[ + results['material_cost'], + results['labor_cost'], + results['equipment_cost'] + ], + names=["تكلفة المواد", "تكلفة العمالة", "تكلفة المعدات"], + title="توزيع التكاليف الرئيسية" + ) + + st.plotly_chart(fig, use_container_width=True) + + # رسم مخطط شريطي لتفصيل الأعمال + breakdown_data = pd.DataFrame({ + 'فئة الأعمال': [ + "الأعمال الإنشائية", + "الأعمال المعمارية", + "الأعمال الكهروميكانيكية", + "أعمال الموقع", + "المتطلبات العامة" + ], + 'التكلفة': [ + results['breakdown']['structural_works'], + results['breakdown']['architectural_works'], + results['breakdown']['mep_works'], + results['breakdown']['site_works'], + results['breakdown']['general_requirements'] + ] + }) + + fig = px.bar( + breakdown_data, + x='فئة الأعمال', + y='التكلفة', + title="تفصيل التكاليف حسب فئة الأعمال", + text_auto='.3s' + ) + + fig.update_traces(texttemplate='%{text:,.0f} ريال', textposition='outside') + + st.plotly_chart(fig, use_container_width=True) + + # عرض مقارنة مع متوسط السوق + st.markdown("#### مقارنة مع متوسط السوق") + + comparison_data = pd.DataFrame({ + 'المصدر': [ + "التكلفة المتوقعة", + "متوسط السوق", + "مشاريع مماثلة سابقة" + ], + 'التكلفة': [ + results['total_cost'], + results['comparison']['market_average'], + results['comparison']['historical_projects'] + ] + }) + + fig = px.bar( + comparison_data, + x='المصدر', + y='التكلفة', + title="مقارنة التكلفة المتوقعة مع السوق", + text_auto='.3s', + color='المصدر', + color_discrete_map={ + "التكلفة المتوقعة": "#1f77b4", + "متوسط السوق": "#ff7f0e", + "مشاريع مماثلة سابقة": "#2ca02c" + } + ) + + fig.update_traces(texttemplate='%{text:,.0f} ريال', textposition='outside') + + st.plotly_chart(fig, use_container_width=True) + + # عرض تحليل Claude AI إذا كان متوفراً + if "claude_analysis" in results: + st.markdown("### تحليل Claude AI المتقدم") + st.info(results["claude_analysis"]) + + # عرض ملاحظات وتوصيات + st.markdown("#### ملاحظات وتوصيات") + + st.info(""" + - تم التنبؤ بالتكاليف بناءً على البيانات المدخلة ونماذج التعلم الآلي المدربة على مشاريع مماثلة. + - مستوى الثقة في التنبؤ جيد، ولكن يجب مراجعة التكاليف بشكل تفصيلي قبل اتخاذ القرار النهائي. + - تكلفة المتر المربع متوافقة مع متوسط السوق لهذا النوع من المشاريع. + - ينصح بمراجعة التصميم لتحسين التكلفة وزيادة الكفاءة. + """) + + # زر تصدير التقرير + if st.button("تصدير تقرير التكاليف"): + st.success("تم تصدير تقرير التكاليف بنجاح!") + + def _render_risk_analysis_tab(self): + """عرض تبويب تحليل المخاطر""" + + st.markdown("### تحليل المخاطر") + + # عرض نموذج إدخال بيانات المشروع للمخاطر + st.markdown("#### بيانات المشروع") + + col1, col2 = st.columns(2) + + with col1: + project_type = st.selectbox( + "نوع المشروع", + [ + "مباني سكنية", + "مباني تجارية", + "مباني حكومية", + "مراكز صحية", + "مدارس", + "بنية تحتية", + "طرق", + "جسور", + "صرف صحي", + "مياه", + "كهرباء" + ], + key="risk_project_type" + ) + + location = st.selectbox( + "الموقع", + [ + "الرياض", + "جدة", + "الدمام", + "مكة", + "المدينة", + "تبوك", + "حائل", + "عسير", + "جازان", + "نجران", + "الباحة", + "الجوف", + "القصيم" + ], + key="risk_location" + ) + + with col2: + client_type = st.selectbox( + "نوع العميل", + [ + "حكومي", + "شبه حكومي", + "شركة كبيرة", + "شركة متوسطة", + "شركة صغيرة", + "أفراد" + ], + key="risk_client_type" + ) + + tender_type = st.selectbox( + "نوع المناقصة", + [ + "عامة", + "خاصة", + "أمر مباشر" + ], + key="risk_tender_type" + ) + + st.markdown("#### عوامل المخاطرة") + + col1, col2, col3 = st.columns(3) + + with col1: + payment_terms = st.slider("شروط الدفع (1-10)", 1, 10, 5, + help="1: شروط دفع سيئة جداً، 10: شروط دفع ممتازة", + key="risk_payment_terms") + completion_deadline = st.slider("مهلة الإنجاز (1-10)", 1, 10, 5, + help="1: مهلة قصيرة جداً، 10: مهلة مريحة", + key="risk_completion_deadline") + + with col2: + penalty_clause = st.slider("شروط الغرامات (1-10)", 1, 10, 5, + help="1: غرامات مرتفعة جداً، 10: غرامات معقولة", + key="risk_penalty_clause") + technical_complexity = st.slider("التعقيد الفني (1-10)", 1, 10, 5, + help="1: بسيط جداً، 10: معقد للغاية", + key="risk_technical_complexity") + + with col3: + company_experience = st.slider("خبرة الشركة (1-10)", 1, 10, 7, + help="1: لا توجد خبرة، 10: خبرة عالية", + key="risk_company_experience") + market_volatility = st.slider("تقلبات السوق (1-10)", 1, 10, 5, + help="1: مستقر جداً، 10: متقلب للغاية", + key="risk_market_volatility") + + # زر تحليل المخاطر مع دعم Claude AI + col1, col2 = st.columns([1, 3]) + + with col1: + analyze_button = st.button("تحليل المخاطر", use_container_width=True, key="risk_analyze_button") + + with col2: + # Añadimos un key único para este checkbox + use_claude = st.checkbox("استخدام Claude AI للتحليل المتقدم", value=True, key="risk_use_claude") + + if analyze_button: + with st.spinner("جاري تحليل المخاطر..."): + # محاكاة وقت المعالجة + time.sleep(2) + + # تجهيز البيانات للنموذج + features = { + 'project_type': project_type, + 'location': location, + 'client_type': client_type, + 'tender_type': tender_type, + 'payment_terms': payment_terms, + 'completion_deadline': completion_deadline, + 'penalty_clause': penalty_clause, + 'technical_complexity': technical_complexity, + 'company_experience': company_experience, + 'market_volatility': market_volatility + } + + # استدعاء النموذج لتحليل المخاطر + risk_analysis_results = self._analyze_risks(features) + + # إضافة تحليل إضافي باستخدام Claude AI إذا تم تفعيل الخيار + if use_claude: + try: + # إنشاء نص الميزات للتحليل + features_text = f""" + بيانات المشروع: + - نوع المشروع: {project_type} + - الموقع: {location} + - نوع العميل: {client_type} + - نوع المناقصة: {tender_type} + + عوامل المخاطرة: + - شروط الدفع: {payment_terms}/10 + - مهلة الإنجاز: {completion_deadline}/10 + - شروط الغرامات: {penalty_clause}/10 + - التعقيد الفني: {technical_complexity}/10 + - خبرة الشركة: {company_experience}/10 + - تقلبات السوق: {market_volatility}/10 + + ملخص التحليل الأولي: + - متوسط درجة المخاطرة: {risk_analysis_results['avg_risk_score']:.1f}/10 + - عدد المخاطر العالية: {risk_analysis_results['high_risks']} + - عدد المخاطر المتوسطة: {risk_analysis_results['medium_risks']} + - عدد المخاطر المنخفضة: {risk_analysis_results['low_risks']} + + أعلى المخاطر: + """ + + # إضافة تفاصيل أعلى المخاطر + for i, risk in enumerate(risk_analysis_results['top_risks'][:3]): + features_text += f""" + {i+1}. {risk['name']} ({risk['category']}) + - الاحتمالية: {risk['probability'] * 100:.0f}% + - التأثير: {risk['impact'] * 100:.0f}% + - درجة المخاطرة: {risk['risk_score']}/10 + """ + + prompt = f"""تحليل مخاطر مشروع: + + {features_text} + + المطلوب: + 1. تحليل عوامل المخاطرة وتأثيرها على المشروع + 2. تقديم توصيات إضافية لإدارة المخاطر + 3. اقتراح استراتيجيات استجابة للمخاطر الرئيسية + 4. تقديم نصائح لتحسين شروط العقد لتقليل المخاطر + 5. تقييم مدى ملاءمة المشروع لاستراتيجية الشركة + + يرجى تقديم تحليل مهني ومختصر يركز على الجوانب الأكثر أهمية. + """ + + # استدعاء Claude للتحليل + claude_analysis = self.claude_service.chat_completion( + [{"role": "user", "content": prompt}] + ) + + if "error" not in claude_analysis: + # إضافة تحليل Claude إلى النتائج + risk_analysis_results["claude_analysis"] = claude_analysis["content"] + except Exception as e: + st.warning(f"تعذر إجراء التحليل المتقدم: {str(e)}") + + # عرض نتائج تحليل المخاطر + self._display_risk_analysis_results(risk_analysis_results) + + def _analyze_risks(self, features): + """تحليل مخاطر المشروع""" + + # في البيئة الحقيقية، سيتم استدعاء نموذج تحليل المخاطر + # محاكاة نتائج تحليل المخاطر للعرض + + # تعريف قائمة من المخاطر المحتملة + potential_risks = [ + { + "id": "R-001", + "name": "غرامة تأخير مرتفعة", + "category": "مخاطر مالية", + "description": "غرامة تأخير مرتفعة تصل إلى 10% من قيمة العقد، مما قد يؤثر سلباً على ربحية المشروع في حال التأخير.", + "probability": 0.6, + "impact": 0.8, + "risk_score": 7.8, + "response_strategy": "تخطيط مفصل للمشروع مع وضع مخزون زمني مناسب وتحديد نقاط التسليم المبكر." + }, + { + "id": "R-002", + "name": "تقلبات أسعار المواد", + "category": "مخاطر السوق", + "description": "ارتفاع محتمل في أسعار المواد الخام خلال فترة تنفيذ المشروع، مما يؤثر على التكلفة الإجمالية.", + "probability": 0.7, + "impact": 0.7, + "risk_score": 7.5, + "response_strategy": "التعاقد المبكر مع الموردين وتثبيت الأسعار، أو إضافة بند تعديل سعري في العقد." + }, + { + "id": "R-003", + "name": "ضعف تدفق المدفوعات", + "category": "مخاطر مالية", + "description": "تأخر العميل في سداد المستخلصات مما يؤثر على التدفق النقدي للمشروع.", + "probability": 0.5, + "impact": 0.8, + "risk_score": 7.2, + "response_strategy": "التفاوض على شروط دفع واضحة ومواعيد محددة، وإمكانية طلب دفعة مقدمة." + }, + { + "id": "R-004", + "name": "نقص العمالة الماهرة", + "category": "مخاطر الموارد", + "description": "صعوبة توفير عمالة ماهرة لتنفيذ أجزاء محددة من المشروع.", + "probability": 0.5, + "impact": 0.6, + "risk_score": 6.5, + "response_strategy": "التخطيط المبكر للموارد البشرية وتوقيع عقود مع مقاولي الباطن المتخصصين." + }, + { + "id": "R-005", + "name": "تغييرات في نطاق العمل", + "category": "مخاطر تعاقدية", + "description": "طلبات تغيير من العميل تؤدي إلى زيادة نطاق العمل دون تعديل مناسب للتكلفة والجدول الزمني.", + "probability": 0.6, + "impact": 0.6, + "risk_score": 6.0, + "response_strategy": "تضمين آلية واضحة لإدارة التغيير في العقد وتقييم تأثير أي تغييرات على التكلفة والزمن." + }, + { + "id": "R-006", + "name": "مشاكل في الموقع", + "category": "مخاطر فنية", + "description": "ظروف موقع غير متوقعة تؤثر على تنفيذ الأعمال، مثل مشاكل في التربة أو مرافق تحت الأرض.", + "probability": 0.4, + "impact": 0.7, + "risk_score": 5.8, + "response_strategy": "إجراء دراسات واختبارات مفصلة للموقع قبل بدء التنفيذ، وتخصيص احتياطي للطوارئ." + }, + { + "id": "R-007", + "name": "تضارب في التصاميم", + "category": "مخاطر فنية", + "description": "تعارض بين مختلف تخصصات التصميم (معماري، إنشائي، كهروميكانيكي) يؤدي إلى تأخير وإعادة عمل.", + "probability": 0.4, + "impact": 0.6, + "risk_score": 5.4, + "response_strategy": "مراجعة شاملة للتصاميم قبل البدء في التنفيذ واستخدام نمذجة معلومات البناء (BIM) لكشف التعارضات." + }, + { + "id": "R-008", + "name": "تأخر الموافقات", + "category": "مخاطر تنظيمية", + "description": "تأخر في الحصول على الموافقات والتصاريح اللازمة من الجهات المختصة.", + "probability": 0.5, + "impact": 0.5, + "risk_score": 5.0, + "response_strategy": "التخطيط المبكر للتصاريح المطلوبة وبناء علاقات جيدة مع الجهات التنظيمية." + }, + { + "id": "R-009", + "name": "عدم توفر المعدات", + "category": "مخاطر الموارد", + "description": "صعوبة في توفير المعدات المتخصصة في الوقت المطلوب.", + "probability": 0.3, + "impact": 0.6, + "risk_score": 4.8, + "response_strategy": "حجز المعدات مبكراً وتوفير بدائل محتملة في حالة عدم توفر المعدات الأساسية." + }, + { + "id": "R-010", + "name": "ظروف جوية قاسية", + "category": "مخاطر خارجية", + "description": "تأثير الظروف الجوية القاسية (حرارة شديدة، أمطار غزيرة، عواصف رملية) على سير العمل.", + "probability": 0.3, + "impact": 0.5, + "risk_score": 4.5, + "response_strategy": "تخطيط الجدول الزمني مع مراعاة المواسم وإضافة مخزون زمني للظروف الجوية غير المتوقعة." + } + ] + + # حساب درجات المخاطرة بناءً على الميزات المدخلة + for risk in potential_risks: + # تعديل احتمالية حدوث المخاطر بناءً على العوامل المدخلة + if risk["id"] == "R-001": # غرامة تأخير + risk["probability"] = risk["probability"] * (10 - features["penalty_clause"]) / 10 + risk["probability"] = risk["probability"] * (10 - features["completion_deadline"]) / 10 + + elif risk["id"] == "R-002": # تقلبات أسعار المواد + risk["probability"] = risk["probability"] * features["market_volatility"] / 10 + + elif risk["id"] == "R-003": # ضعف تدفق المدفوعات + risk["probability"] = risk["probability"] * (10 - features["payment_terms"]) / 10 + + if features["client_type"] == "حكومي": + risk["probability"] = risk["probability"] * 0.6 # احتمالية أقل مع العملاء الحكوميين + elif features["client_type"] == "أفراد": + risk["probability"] = risk["probability"] * 1.3 # احتمالية أعلى مع العملاء الأفراد + + elif risk["id"] == "R-004": # نقص العمالة الماهرة + risk["probability"] = risk["probability"] * features["technical_complexity"] / 10 + + elif risk["id"] == "R-005": # تغييرات في نطاق العمل + risk["probability"] = risk["probability"] * features["technical_complexity"] / 10 + + if features["client_type"] == "حكومي": + risk["probability"] = risk["probability"] * 1.2 # احتمالية أعلى للتغييرات مع العملاء الحكوميين + + # تعديل تأثير المخاطر بناءً على العوامل المدخلة + if risk["category"] == "مخاطر فنية": + risk["impact"] = risk["impact"] * (10 - features["company_experience"]) / 10 + + # إعادة حساب درجة المخاطرة + risk["risk_score"] = round(risk["probability"] * risk["impact"] * 10, 1) + + # ترتيب المخاطر تنازلياً حسب درجة المخاطرة + sorted_risks = sorted(potential_risks, key=lambda x: x["risk_score"], reverse=True) + + # حساب عدد المخاطر حسب شدتها + high_risks = sum(1 for risk in sorted_risks if risk["risk_score"] >= 6.0) + medium_risks = sum(1 for risk in sorted_risks if 3.0 <= risk["risk_score"] < 6.0) + low_risks = sum(1 for risk in sorted_risks if risk["risk_score"] < 3.0) + + # حساب متوسط درجة المخاطرة + avg_risk_score = sum(risk["risk_score"] for risk in sorted_risks) / len(sorted_risks) + + # تجهيز النتائج + results = { + "top_risks": sorted_risks, + "high_risks": high_risks, + "medium_risks": medium_risks, + "low_risks": low_risks, + "avg_risk_score": avg_risk_score, + "risk_profile": { + "financial_risk": sum(risk["risk_score"] for risk in sorted_risks if risk["category"] == "مخاطر مالية") / sum(1 for risk in sorted_risks if risk["category"] == "مخاطر مالية") if sum(1 for risk in sorted_risks if risk["category"] == "مخاطر مالية") > 0 else 0, + "technical_risk": sum(risk["risk_score"] for risk in sorted_risks if risk["category"] == "مخاطر فنية") / sum(1 for risk in sorted_risks if risk["category"] == "مخاطر فنية") if sum(1 for risk in sorted_risks if risk["category"] == "مخاطر فنية") > 0 else 0, + "market_risk": sum(risk["risk_score"] for risk in sorted_risks if risk["category"] == "مخاطر السوق") / sum(1 for risk in sorted_risks if risk["category"] == "مخاطر السوق") if sum(1 for risk in sorted_risks if risk["category"] == "مخاطر السوق") > 0 else 0, + "resource_risk": sum(risk["risk_score"] for risk in sorted_risks if risk["category"] == "مخاطر الموارد") / sum(1 for risk in sorted_risks if risk["category"] == "مخاطر الموارد") if sum(1 for risk in sorted_risks if risk["category"] == "مخاطر الموارد") > 0 else 0, + "contract_risk": sum(risk["risk_score"] for risk in sorted_risks if risk["category"] == "مخاطر تعاقدية") / sum(1 for risk in sorted_risks if risk["category"] == "مخاطر تعاقدية") if sum(1 for risk in sorted_risks if risk["category"] == "مخاطر تعاقدية") > 0 else 0, + "regulatory_risk": sum(risk["risk_score"] for risk in sorted_risks if risk["category"] == "مخاطر تنظيمية") / sum(1 for risk in sorted_risks if risk["category"] == "مخاطر تنظيمية") if sum(1 for risk in sorted_risks if risk["category"] == "مخاطر تنظيمية") > 0 else 0 + }, + "overall_assessment": "", + "recommendation": "" + } + + # تقييم شامل للمخاطر + if avg_risk_score >= 6.0: + results["overall_assessment"] = "مشروع عالي المخاطر" + results["recommendation"] = "ينصح بإعادة التفاوض على شروط العقد أو إضافة هامش ربح أعلى لتغطية المخاطر." + elif avg_risk_score >= 4.0: + results["overall_assessment"] = "مشروع متوسط المخاطر" + results["recommendation"] = "متابعة دقيقة للمخاطر العالية ووضع خطط استجابة مفصلة لها." + else: + results["overall_assessment"] = "مشروع منخفض المخاطر" + results["recommendation"] = "مراقبة المخاطر بشكل دوري والتركيز على تحسين الأداء." + + return results + + def _display_risk_analysis_results(self, results): + """عرض نتائج تحليل المخاطر""" + + st.markdown("### نتائج تحليل المخاطر") + + # عرض ملخص تقييم المخاطر + st.markdown(f"#### التقييم العام: {results['overall_assessment']}") + + # عرض الإحصائيات الرئيسية للمخاطر + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.metric("متوسط درجة المخاطرة", f"{results['avg_risk_score']:.1f}/10") + + with col2: + st.metric("المخاطر العالية", f"{results['high_risks']}") + + with col3: + st.metric("المخاطر المتوسطة", f"{results['medium_risks']}") + + with col4: + st.metric("المخاطر المنخفضة", f"{results['low_risks']}") + + # عرض ملف المخاطر حسب الفئة + st.markdown("#### ملف المخاطر حسب الفئة") + + # تجهيز البيانات للرسم البياني + risk_profile_data = pd.DataFrame({ + 'الفئة': [ + "مخاطر مالية", + "مخاطر فنية", + "مخاطر السوق", + "مخاطر الموارد", + "مخاطر تعاقدية", + "مخاطر تنظيمية" + ], + 'درجة المخاطرة': [ + results['risk_profile']['financial_risk'], + results['risk_profile']['technical_risk'], + results['risk_profile']['market_risk'], + results['risk_profile']['resource_risk'], + results['risk_profile']['contract_risk'], + results['risk_profile']['regulatory_risk'] + ] + }) + + # رسم مخطط شعاعي لملف المخاطر + fig = px.line_polar( + risk_profile_data, + r='درجة المخاطرة', + theta='الفئة', + line_close=True, + range_r=[0, 10], + title="ملف المخاطر حسب الفئة" + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض المخاطر الرئيسية + st.markdown("#### المخاطر الرئيسية") + + # إنشاء جدول المخاطر + risk_table_data = [] + + for risk in results['top_risks'][:5]: # عرض أعلى 5 مخاطر فقط + risk_level = "عالية" if risk["risk_score"] >= 6.0 else "متوسطة" if risk["risk_score"] >= 3.0 else "منخفضة" + risk_color = "red" if risk_level == "عالية" else "orange" if risk_level == "متوسطة" else "green" + + risk_table_data.append({ + "المعرف": risk["id"], + "الوصف": risk["name"], + "الفئة": risk["category"], + "الاحتمالية": f"{risk['probability'] * 100:.0f}%", + "التأثير": f"{risk['impact'] * 100:.0f}%", + "درجة المخاطرة": risk["risk_score"], + "المستوى": risk_level, + "استراتيجية الاستجابة": risk["response_strategy"], + "color": risk_color + }) + + # عرض جدول المخاطر + risk_df = pd.DataFrame(risk_table_data) + + # استخدام تنسيق HTML مخصص لعرض المخاطر الرئيسية + for index, row in risk_df.iterrows(): + with st.container(): + st.markdown(f""" +
+
{row['المعرف']} - {row['الوصف']} {row['المستوى']}
+

الفئة: {row['الفئة']} | الاحتمالية: {row['الاحتمالية']} | التأثير: {row['التأثير']} | درجة المخاطرة: {row['درجة المخاطرة']}/10

+

استراتيجية الاستجابة: {row['استراتيجية الاستجابة']}

+
+ """, unsafe_allow_html=True) + + # عرض توصيات عامة + st.markdown("#### التوصيات العامة") + st.info(results["recommendation"]) + + # عرض تحليل Claude AI إذا كان متوفراً + if "claude_analysis" in results: + st.markdown("### تحليل Claude AI المتقدم") + st.success(results["claude_analysis"]) + + # زر تصدير تقرير المخاطر + if st.button("تصدير تقرير المخاطر"): + st.success("تم تصدير تقرير المخاطر بنجاح!") + + def _render_document_analysis_tab(self): + """عرض تبويب تحليل المستندات""" + + st.markdown("### تحليل المستندات") + + # خيارات رفع الملفات + st.markdown("#### رفع ملفات المناقصة") + + # رفع الملفات + uploaded_files = st.file_uploader( + "اختر ملفات المناقصة للتحليل", + type=["pdf", "docx", "doc", "xls", "xlsx", "jpg", "jpeg", "png"], + accept_multiple_files=True, + key="document_analysis_files" + ) + + # اختيار نموذج التحليل + analysis_model = st.radio( + "اختر نموذج التحليل", + [ + "استخراج البنود والمواصفات", + "استخراج الشروط التعاقدية", + "تحليل الكميات", + "تحليل المتطلبات القانونية", + "تحليل شامل (يستخدم Claude AI)" + ], + horizontal=True + ) + + # زر بدء التحليل + if uploaded_files and st.button("بدء تحليل المستندات"): + with st.spinner("جاري تحليل المستندات..."): + # محاكاة وقت المعالجة + time.sleep(3) + + # معالجة الملفات المرفوعة + analysis_results = self._analyze_documents(uploaded_files, analysis_model) + + # عرض نتائج التحليل + self._display_document_analysis_results(analysis_results) + + def _analyze_documents(self, files, analysis_model): + """تحليل المستندات المرفوعة""" + + # في البيئة الحقيقية، سيتم استدعاء نموذج تحليل المستندات + # محاكاة نتائج تحليل المستندات للعرض + + # نتائج التحليل المبدئية + basic_results = { + "file_count": len(files), + "file_names": [file.name for file in files], + "file_sizes": [f"{file.size / 1024:.1f} KB" for file in files], + "file_types": [file.type or "غير محدد" for file in files], + "extracted_text_samples": {}, + "entities": [], + "tender_items": [], + "contract_terms": [], + "quantities": [], + "legal_requirements": [], + "summary": "" + } + + # محاكاة استخراج نص من الملفات + for file in files: + # استخراج عينة نصية (في البيئة الحقيقية سيتم استخراج النص الكامل) + sample_text = f"عينة نصية مستخرجة من الملف {file.name}. هذا النص لأغراض العرض فقط." + basic_results["extracted_text_samples"][file.name] = sample_text + + # محاكاة تحليل المحتوى حسب نموذج التحليل المختار + if analysis_model == "استخراج البنود والمواصفات" or analysis_model == "تحليل شامل (يستخدم Claude AI)": + basic_results["tender_items"] = [ + { + "id": "T-001", + "description": "أعمال الحفر والردم", + "unit": "م³", + "quantity": 1500, + "estimated_price": 85, + "specifications": "حفر في أي نوع من التربة بما في ذلك الصخور والردم باستخدام مواد معتمدة." + }, + { + "id": "T-002", + "description": "أعمال الخرسانة المسلحة للأساسات", + "unit": "م³", + "quantity": 750, + "estimated_price": 1200, + "specifications": "خرسانة مسلحة بقوة 30 نيوتن/مم² بعد 28 يوم، مع حديد تسليح من الفئة 60." + }, + { + "id": "T-003", + "description": "أعمال الخرسانة المسلحة للهيكل", + "unit": "م³", + "quantity": 1200, + "estimated_price": 1350, + "specifications": "خرسانة مسلحة بقوة 30 نيوتن/مم² بعد 28 يوم، مع حديد تسليح من الفئة 60." + }, + { + "id": "T-004", + "description": "أعمال الطابوق", + "unit": "م²", + "quantity": 3500, + "estimated_price": 120, + "specifications": "جدران طابوق مفرغ سمك 20 سم مع مونة إسمنتية." + }, + { + "id": "T-005", + "description": "أعمال التشطيبات الداخلية", + "unit": "م²", + "quantity": 5000, + "estimated_price": 200, + "specifications": "تشطيبات داخلية تشمل اللياسة والدهان والأرضيات حسب المواصفات المرفقة." + } + ] + + if analysis_model == "استخراج الشروط التعاقدية" or analysis_model == "تحليل شامل (يستخدم Claude AI)": + basic_results["contract_terms"] = [ + { + "id": "C-001", + "title": "مدة تنفيذ المشروع", + "description": "يجب إنجاز جميع الأعمال خلال 18 شهراً من تاريخ تسليم الموقع.", + "risk_level": "متوسط", + "notes": "مدة تنفيذ معقولة نسبياً للحجم المتوقع من الأعمال." + }, + { + "id": "C-002", + "title": "غرامة التأخير", + "description": "تفرض غرامة تأخير بنسبة 0.1% من قيمة العقد عن كل يوم تأخير، بحد أقصى 10% من القيمة الإجمالية للعقد.", + "risk_level": "عالي", + "notes": "غرامة مرتفعة نسبياً، تتطلب جدولة دقيقة وإدارة استباقية للمخاطر." + }, + { + "id": "C-003", + "title": "شروط الدفع", + "description": "يتم صرف المستخلصات خلال 45 يوماً من تاريخ تقديمها، مع خصم نسبة 10% كضمان حسن التنفيذ تسترد بعد فترة الضمان.", + "risk_level": "متوسط", + "notes": "فترة 45 يوماً طويلة نسبياً وقد تؤثر على التدفق النقدي." + }, + { + "id": "C-004", + "title": "التزامات المحتوى المحلي", + "description": "يجب أن لا تقل نسبة المحتوى المحلي عن 30% من إجمالي قيمة العقد.", + "risk_level": "منخفض", + "notes": "يمكن تحقيق النسبة المطلوبة من خلال توريد المواد والعمالة المحلية." + }, + { + "id": "C-005", + "title": "التغييرات والأعمال الإضافية", + "description": "يحق للمالك طلب تغييرات بنسبة ±10% من قيمة العقد دون تعديل أسعار الوحدات.", + "risk_level": "متوسط", + "notes": "نسبة معقولة، لكن يجب مراعاة احتمالية الطلبات الإضافية عند تسعير البنود." + } + ] + + if analysis_model == "تحليل الكميات" or analysis_model == "تحليل شامل (يستخدم Claude AI)": + basic_results["quantities"] = [ + { + "category": "أعمال الحفر والردم", + "volume": 1500, + "unit": "م³", + "estimated_cost": 127500 + }, + { + "category": "أعمال الخرسانة", + "volume": 1950, + "unit": "م³", + "estimated_cost": 2437500 + }, + { + "category": "أعمال الطابوق", + "volume": 3500, + "unit": "م²", + "estimated_cost": 420000 + }, + { + "category": "أعمال التشطيبات الداخلية", + "volume": 5000, + "unit": "م²", + "estimated_cost": 1000000 + }, + { + "category": "أعمال التشطيبات الخارجية", + "volume": 2200, + "unit": "م²", + "estimated_cost": 660000 + }, + { + "category": "أعمال الكهروميكانيكية", + "volume": 1, + "unit": "مقطوعية", + "estimated_cost": 1750000 + } + ] + + if analysis_model == "تحليل المتطلبات القانونية" or analysis_model == "تحليل شامل (يستخدم Claude AI)": + basic_results["legal_requirements"] = [ + { + "id": "L-001", + "title": "متطلبات التراخيص", + "description": "يجب أن يكون المقاول حاصلاً على تصنيف في الفئة الأولى في مجال المباني.", + "compliance_status": "مطلوب التحقق", + "required_documents": "شهادة التصنيف سارية المفعول" + }, + { + "id": "L-002", + "title": "متطلبات التأمين", + "description": "يجب تقديم بوليصة تأمين شاملة تغطي جميع مخاطر المشروع بقيمة لا تقل عن 100% من قيمة العقد.", + "compliance_status": "مطلوب التحقق", + "required_documents": "وثائق التأمين الشاملة" + }, + { + "id": "L-003", + "title": "متطلبات الضمان البنكي", + "description": "يجب تقديم ضمان بنكي ابتدائي بنسبة 2% من قيمة العطاء، وضمان نهائي بنسبة 5% من قيمة العقد.", + "compliance_status": "مطلوب التحقق", + "required_documents": "نماذج الضمانات البنكية" + }, + { + "id": "L-004", + "title": "متطلبات السعودة", + "description": "يجب الالتزام بنسبة السعودة المطلوبة حسب برنامج نطاقات وأن يكون المقاول في النطاق الأخضر.", + "compliance_status": "مطلوب التحقق", + "required_documents": "شهادة نطاقات سارية المفعول" + }, + { + "id": "L-005", + "title": "متطلبات الزكاة والدخل", + "description": "يجب تقديم شهادة سداد الزكاة والضريبة سارية المفعول.", + "compliance_status": "مطلوب التحقق", + "required_documents": "شهادة الزكاة والدخل" + } + ] + + # إعداد ملخص التحليل + basic_results["summary"] = f""" + تم تحليل {len(files)} ملفات بإجمالي حجم {sum([file.size for file in files]) / 1024 / 1024:.2f} ميجابايت. + + نتائج التحليل الرئيسية: + - تم استخراج {len(basic_results.get('tender_items', []))} بنود رئيسية للمناقصة. + - تم تحديد {len(basic_results.get('contract_terms', []))} شروط تعاقدية هامة. + - تم تحليل الكميات لـ {len(basic_results.get('quantities', []))} فئات من الأعمال. + - تم تحديد {len(basic_results.get('legal_requirements', []))} متطلبات قانونية. + + التوصيات: + - مراجعة شروط التعاقد وخاصة البنود المتعلقة بالغرامات والدفعات. + - تدقيق جداول الكميات والتأكد من تغطية جميع البنود اللازمة للتنفيذ. + - التحقق من استيفاء جميع المتطلبات القانونية قبل تقديم العطاء. + """ + + # إضافة تحليل متقدم باستخدام Claude AI إذا تم اختياره + if analysis_model == "تحليل شامل (يستخدم Claude AI)": + try: + # إنشاء مدخلات للتحليل + analysis_input = f""" + المناقصة: تطوير مبنى إداري متعدد الطوابق + + ملفات تم تحليلها: + {', '.join(basic_results['file_names'])} + + بنود رئيسية: + - أعمال الحفر والردم: 1500 م³ + - أعمال الخرسانة المسلحة للأساسات: 750 م³ + - أعمال الخرسانة المسلحة للهيكل: 1200 م³ + - أعمال الطابوق: 3500 م² + - أعمال التشطيبات الداخلية: 5000 م² + + شروط تعاقدية رئيسية: + - مدة التنفيذ: 18 شهر + - غرامة التأخير: 0.1% يومياً بحد أقصى 10% + - شروط الدفع: 45 يوم للمستخلصات مع خصم 10% ضمان + - المحتوى المحلي: 30% كحد أدنى + + متطلبات قانونية: + - تصنيف الفئة الأولى مباني + - تأمين شامل بنسبة 100% + - ضمان بنكي ابتدائي 2% ونهائي 5% + - الالتزام بمتطلبات السعودة (النطاق الأخضر) + + من فضلك قم بتحليل هذه المناقصة وتقديم: + 1. تقييم عام للمناقصة وجاذبيتها + 2. نقاط القوة والضعف الرئيسية + 3. المخاطر المحتملة التي يجب مراعاتها + 4. توصيات للتسعير المناسب + 5. استراتيجية مقترحة للتنافس على المناقصة + """ + + # استدعاء خدمة Claude للتحليل + claude_response = self.claude_service.chat_completion( + [{"role": "user", "content": analysis_input}] + ) + + if "error" not in claude_response: + # إضافة تحليل Claude إلى النتائج + basic_results["claude_analysis"] = claude_response["content"] + except Exception as e: + logging.error(f"فشل في تحليل المستندات باستخدام Claude AI: {str(e)}") + + return basic_results + + def _display_document_analysis_results(self, results): + """عرض نتائج تحليل المستندات""" + + st.markdown("### نتائج تحليل المستندات") + + # عرض ملخص التحليل + st.markdown("#### ملخص التحليل") + st.info(results["summary"]) + + # عرض البنود المستخرجة من المناقصة إذا وجدت + if results["tender_items"]: + st.markdown("#### بنود المناقصة المستخرجة") + + # إنشاء DataFrame للبنود + items_df = pd.DataFrame(results["tender_items"]) + + # عرض الجدول بشكل منسق + st.dataframe( + items_df[["id", "description", "unit", "quantity", "estimated_price"]], + use_container_width=True + ) + + # عرض مخطط للتكاليف المقدرة + costs = [item["quantity"] * item["estimated_price"] for item in results["tender_items"]] + labels = [item["description"] for item in results["tender_items"]] + + fig = px.pie( + names=labels, + values=costs, + title="توزيع التكاليف المقدرة حسب البنود" + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض الشروط التعاقدية إذا وجدت + if results["contract_terms"]: + st.markdown("#### الشروط التعاقدية الهامة") + + # عرض كل شرط في قسم منفصل + for term in results["contract_terms"]: + risk_color = "red" if term["risk_level"] == "عالي" else "orange" if term["risk_level"] == "متوسط" else "green" + + st.markdown(f""" +
+
{term['id']} - {term['title']} مستوى الخطورة: {term['risk_level']}
+

{term['description']}

+

ملاحظات: {term['notes']}

+
+ """, unsafe_allow_html=True) + + # عرض تحليل الكميات إذا وجد + if results["quantities"]: + st.markdown("#### تحليل الكميات") + + # إنشاء DataFrame للكميات + quantities_df = pd.DataFrame(results["quantities"]) + + # عرض الجدول بشكل منسق + st.dataframe(quantities_df, use_container_width=True) + + # عرض مخطط شريطي للتكاليف المقدرة + fig = px.bar( + quantities_df, + x="category", + y="estimated_cost", + title="التكاليف المقدرة حسب فئة الأعمال", + labels={"category": "فئة الأعمال", "estimated_cost": "التكلفة المقدرة (ريال)"} + ) + + fig.update_traces(text=quantities_df["estimated_cost"], textposition="outside") + + st.plotly_chart(fig, use_container_width=True) + + # عرض المتطلبات القانونية إذا وجدت + if results["legal_requirements"]: + st.markdown("#### المتطلبات القانونية") + + # عرض المتطلبات في جدول + legal_df = pd.DataFrame(results["legal_requirements"]) + + # عرض الجدول بشكل منسق + st.dataframe( + legal_df[["id", "title", "description", "compliance_status", "required_documents"]], + use_container_width=True + ) + + # عرض قائمة تحقق للمتطلبات القانونية + st.markdown("##### قائمة التحقق من المتطلبات القانونية") + + for req in results["legal_requirements"]: + st.checkbox(f"{req['title']} - {req['description']}", key=f"req_{req['id']}") + + # عرض تحليل Claude AI المتقدم إذا وجد + if "claude_analysis" in results: + st.markdown("### تحليل Claude AI المتقدم") + st.success(results["claude_analysis"]) + + # أزرار إضافية + col1, col2 = st.columns(2) + + with col1: + if st.button("تصدير تقرير تحليل المستندات"): + st.success("تم تصدير تقرير تحليل المستندات بنجاح!") + + with col2: + if st.button("استخراج جدول الكميات"): + st.success("تم استخراج جدول الكميات بنجاح!") + + def _render_local_content_tab(self): + """عرض تبويب المحتوى المحلي""" + + st.markdown("### المحتوى المحلي") + + st.markdown(""" + وحدة حساب المحتوى المحلي تساعدك في تحليل وتحسين نسبة المحتوى المحلي في مشروعك طبقاً لمتطلبات هيئة المحتوى المحلي والمشتريات الحكومية. + """) + + # عرض علامات تبويب فرعية + lc_tabs = st.tabs([ + "حساب المحتوى المحلي", + "قاعدة بيانات الموردين", + "التقارير", + "التحسين" + ]) + + with lc_tabs[0]: + self._render_lc_calculator_tab() + + with lc_tabs[1]: + self._render_lc_suppliers_tab() + + with lc_tabs[2]: + self._render_lc_reports_tab() + + with lc_tabs[3]: + self._render_lc_optimization_tab() + + def _render_lc_calculator_tab(self): + """عرض تبويب حساب المحتوى المحلي""" + + st.markdown("#### حساب المحتوى المحلي") + + # نموذج إدخال بيانات المشروع + st.markdown("##### بيانات المشروع") + + col1, col2 = st.columns(2) + + with col1: + project_name = st.text_input("اسم المشروع", "مبنى إداري الرياض") + project_value = st.number_input("القيمة الإجمالية للمشروع (ريال)", min_value=1000, value=10000000) + + with col2: + target_lc = st.slider("نسبة المحتوى المحلي المستهدفة (%)", 0, 100, 40) + calculation_method = st.selectbox( + "طريقة الحساب", + [ + "الطريقة القياسية (المدخلات)", + "طريقة القيمة المضافة", + "الطريقة المختلطة" + ] + ) + + # جدول مكونات المشروع + st.markdown("##### مكونات المشروع") + + # إعداد بيانات المكونات الافتراضية + if 'lc_components' not in st.session_state: + st.session_state.lc_components = [ + { + "id": 1, + "name": "الخرسانة المسلحة", + "category": "مواد", + "value": 3000000, + "local_content": 85, + "supplier": "شركة الإنشاءات السعودية" + }, + { + "id": 2, + "name": "الأعمال الكهربائية", + "category": "أنظمة", + "value": 1500000, + "local_content": 65, + "supplier": "مؤسسة الطاقة المتقدمة" + }, + { + "id": 3, + "name": "أعمال التكييف", + "category": "أنظمة", + "value": 1200000, + "local_content": 55, + "supplier": "شركة التبريد العالمية" + }, + { + "id": 4, + "name": "الواجهات والنوافذ", + "category": "مواد", + "value": 800000, + "local_content": 45, + "supplier": "شركة الزجاج المتطورة" + }, + { + "id": 5, + "name": "أعمال التشطيبات", + "category": "مواد وعمالة", + "value": 1200000, + "local_content": 80, + "supplier": "مؤسسة التشطيبات الحديثة" + }, + { + "id": 6, + "name": "الأثاث والتجهيزات", + "category": "أثاث", + "value": 900000, + "local_content": 30, + "supplier": "شركة الأثاث المكتبي" + }, + { + "id": 7, + "name": "أنظمة الأمن والمراقبة", + "category": "أنظمة", + "value": 600000, + "local_content": 40, + "supplier": "شركة الأنظمة الأمنية المتقدمة" + }, + { + "id": 8, + "name": "العمالة المباشرة", + "category": "عمالة", + "value": 800000, + "local_content": 50, + "supplier": "داخلي" + } + ] + + # عرض جدول المكونات للتعديل + for i, component in enumerate(st.session_state.lc_components): + col1, col2, col3, col4, col5, col6 = st.columns([2, 1, 1, 1, 2, 1]) + + with col1: + st.session_state.lc_components[i]["name"] = st.text_input( + "المكون", + component["name"], + key=f"comp_name_{i}" + ) + + with col2: + st.session_state.lc_components[i]["category"] = st.selectbox( + "الفئة", + ["مواد", "أنظمة", "عمالة", "مواد وعمالة", "أثاث", "خدمات"], + index=["مواد", "أنظمة", "عمالة", "مواد وعمالة", "أثاث", "خدمات"].index(component["category"]), + key=f"comp_category_{i}" + ) + + with col3: + st.session_state.lc_components[i]["value"] = st.number_input( + "القيمة (ريال)", + min_value=0, + value=int(component["value"]), + key=f"comp_value_{i}" + ) + + with col4: + st.session_state.lc_components[i]["local_content"] = st.slider( + "المحتوى المحلي (%)", + 0, 100, int(component["local_content"]), + key=f"comp_lc_{i}" + ) + + with col5: + st.session_state.lc_components[i]["supplier"] = st.text_input( + "المورد", + component["supplier"], + key=f"comp_supplier_{i}" + ) + + with col6: + if st.button("حذف", key=f"delete_comp_{i}"): + st.session_state.lc_components.pop(i) + st.rerun() + + # زر إضافة مكون جديد + if st.button("إضافة مكون جديد"): + new_id = max([c["id"] for c in st.session_state.lc_components]) + 1 if st.session_state.lc_components else 1 + st.session_state.lc_components.append({ + "id": new_id, + "name": f"مكون جديد {new_id}", + "category": "مواد", + "value": 100000, + "local_content": 50, + "supplier": "غير محدد" + }) + st.rerun() + + # زر حساب المحتوى المحلي + col1, col2 = st.columns([1, 3]) + + with col1: + calculate_button = st.button("حساب المحتوى المحلي", use_container_width=True) + + with col2: + use_claude = st.checkbox("استخدام Claude AI للتحليل المتقدم", value=True, key="lc_use_claude") + + if calculate_button: + with st.spinner("جاري حساب وتحليل المحتوى المحلي..."): + # محاكاة وقت المعالجة + time.sleep(2) + + # حساب المحتوى المحلي + lc_results = self._calculate_local_content(st.session_state.lc_components, target_lc, calculation_method) + + # إضافة تحليل إضافي باستخدام Claude AI إذا تم تفعيل الخيار + if use_claude: + try: + # إنشاء نص المكونات للتحليل + components_text = "" + for comp in st.session_state.lc_components: + components_text += f""" + - {comp['name']} ({comp['category']}): + القيمة: {comp['value']:,} ريال | المحتوى المحلي: {comp['local_content']}% | المورد: {comp['supplier']} + """ + + prompt = f"""تحليل وتحسين المحتوى المحلي: + + بيانات المشروع: + - اسم المشروع: {project_name} + - القيمة الإجمالية: {project_value:,} ريال + - نسبة المحتوى المحلي المستهدفة: {target_lc}% + - النسبة المحسوبة: {lc_results['total_local_content']:.1f}% + + مكونات المشروع: + {components_text} + + المطلوب: + 1. تحليل نسبة المحتوى المحلي المحسوبة ومقارنتها بالمستهدف + 2. تحديد المكونات ذات المحتوى المحلي المنخفض التي يمكن تحسينها + 3. اقتراح بدائل محلية أو استراتيجيات لزيادة المحتوى المحلي + 4. تقديم توصيات عملية لتحقيق النسبة المستهدفة + 5. تحديد أي فرص إضافية لتحسين المحتوى المحلي في المشروع + + يرجى تقديم تحليل مهني ومختصر يركز على الجوانب الأكثر أهمية. + """ + + # استدعاء Claude للتحليل + claude_analysis = self.claude_service.chat_completion( + [{"role": "user", "content": prompt}] + ) + + if "error" not in claude_analysis: + # إضافة تحليل Claude إلى النتائج + lc_results["claude_analysis"] = claude_analysis["content"] + except Exception as e: + st.warning(f"تعذر إجراء التحليل المتقدم: {str(e)}") + + # عرض نتائج حساب المحتوى المحلي + self._display_local_content_results(lc_results, target_lc) + + def _calculate_local_content(self, components, target_lc, calculation_method): + """حساب المحتوى المحلي""" + + # حساب إجمالي قيمة المشروع + total_value = sum([comp["value"] for comp in components]) + + # حساب المحتوى المحلي الإجمالي + total_local_content_value = sum([comp["value"] * comp["local_content"] / 100 for comp in components]) + + # حساب نسبة المحتوى المحلي الإجمالية + total_local_content_percent = (total_local_content_value / total_value) * 100 if total_value > 0 else 0 + + # تحليل المحتوى المحلي حسب الفئة + categories = {} + for comp in components: + category = comp["category"] + if category not in categories: + categories[category] = { + "total_value": 0, + "local_content_value": 0 + } + + categories[category]["total_value"] += comp["value"] + categories[category]["local_content_value"] += comp["value"] * comp["local_content"] / 100 + + # حساب نسبة المحتوى المحلي لكل فئة + for category in categories: + if categories[category]["total_value"] > 0: + categories[category]["local_content_percent"] = (categories[category]["local_content_value"] / categories[category]["total_value"]) * 100 + else: + categories[category]["local_content_percent"] = 0 + + # تحديد المكونات ذات المحتوى المحلي المنخفض + low_lc_components = sorted( + [comp for comp in components if comp["local_content"] < 50], + key=lambda x: x["local_content"] + ) + + # تحديد المكونات ذات المحتوى المحلي المرتفع + high_lc_components = sorted( + [comp for comp in components if comp["local_content"] >= 80], + key=lambda x: x["local_content"], + reverse=True + ) + + # تقديم توصيات لتحسين المحتوى المحلي + improvement_recommendations = [] + + # توصيات للمكونات ذات المحتوى المحلي المنخفض + for comp in low_lc_components[:3]: # أخذ أقل 3 مكونات + improvement_recommendations.append({ + "component": comp["name"], + "current_lc": comp["local_content"], + "recommendation": f"البحث عن بدائل محلية لـ {comp['name']} التي تمثل {comp['value'] / total_value * 100:.1f}% من قيمة المشروع." + }) + + # حساب الفجوة بين المحتوى المحلي الفعلي والمستهدف + lc_gap = target_lc - total_local_content_percent + + # إعداد النتائج + results = { + "total_value": total_value, + "total_local_content_value": total_local_content_value, + "total_local_content": total_local_content_percent, + "target_lc": target_lc, + "lc_gap": lc_gap, + "categories": categories, + "low_lc_components": low_lc_components, + "high_lc_components": high_lc_components, + "improvement_recommendations": improvement_recommendations, + "calculation_method": calculation_method, + "components": components + } + + # تحديد حالة المحتوى المحلي + if lc_gap <= 0: + results["status"] = "تم تحقيق المستهدف" + results["color"] = "green" + elif lc_gap <= 5: + results["status"] = "قريب من المستهدف" + results["color"] = "orange" + else: + results["status"] = "بعيد عن المستهدف" + results["color"] = "red" + + return results + + def _display_local_content_results(self, results, target_lc): + """عرض نتائج حساب المحتوى المحلي""" + + st.markdown("### نتائج حساب المحتوى المحلي") + + # عرض نسبة المحتوى المحلي الإجمالية + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + "نسبة المحتوى المحلي الحالية", + f"{results['total_local_content']:.1f}%", + delta=f"{results['lc_gap']:.1f}%" if results['lc_gap'] < 0 else f"-{results['lc_gap']:.1f}%", + delta_color="normal" if results['lc_gap'] < 0 else "inverse" + ) + + with col2: + st.metric( + "النسبة المستهدفة", + f"{target_lc}%" + ) + + with col3: + # Aquí está el problema - no podemos usar 'green' como valor para delta_color + # En lugar de eso, usamos un texto formateado para mostrar el estado + st.markdown(f""" +
+

{results["status"]}

+
+ """, unsafe_allow_html=True) + + # Alternativa sin usar delta_color + # st.metric( + # "حالة المحتوى المحلي", + # results["status"] + # ) + + + # عرض مخطط مقارنة بين النسبة الحالية والمستهدفة + comparison_data = pd.DataFrame({ + 'النوع': ['النسبة الحالية', 'النسبة المستهدفة'], + 'النسبة': [results['total_local_content'], target_lc] + }) + + fig = px.bar( + comparison_data, + x='النوع', + y='النسبة', + title="مقارنة نسبة المحتوى المحلي الحالية مع المستهدفة", + color='النوع', + color_discrete_map={ + 'النسبة الحالية': results["color"], + 'النسبة المستهدفة': 'blue' + } + ) + + fig.update_layout(yaxis_range=[0, 100]) + + st.plotly_chart(fig, use_container_width=True) + + # عرض توزيع المحتوى المحلي حسب الفئة + st.markdown("#### توزيع المحتوى المحلي حسب الفئة") + + categories_data = [] + for category, data in results["categories"].items(): + categories_data.append({ + 'الفئة': category, + 'القيمة الإجمالية': data["total_value"], + 'قيمة المحتوى المحلي': data["local_content_value"], + 'نسبة المحتوى المحلي': data["local_content_percent"] + }) + + categories_df = pd.DataFrame(categories_data) + + col1, col2 = st.columns(2) + + with col1: + fig = px.pie( + categories_df, + values='القيمة الإجمالية', + names='الفئة', + title="توزيع قيمة المشروع حسب الفئة" + ) + + st.plotly_chart(fig, use_container_width=True) + + with col2: + fig = px.bar( + categories_df, + x='الفئة', + y='نسبة المحتوى المحلي', + title="نسبة المحتوى المحلي لكل فئة", + text_auto='.1f' + ) + + fig.update_traces(texttemplate='%{text}%', textposition='outside') + fig.update_layout(yaxis_range=[0, 100]) + + st.plotly_chart(fig, use_container_width=True) + + # عرض المكونات ذات المحتوى المحلي المنخفض + st.markdown("#### المكونات ذات المحتوى المحلي المنخفض") + + if results["low_lc_components"]: + low_lc_df = pd.DataFrame([ + { + 'المكون': comp["name"], + 'الفئة': comp["category"], + 'القيمة': comp["value"], + 'نسبة المحتوى المحلي': comp["local_content"], + 'المورد': comp["supplier"] + } + for comp in results["low_lc_components"] + ]) + + st.dataframe(low_lc_df, use_container_width=True) + + # مخطط المكونات ذات المحتوى المحلي المنخفض + fig = px.bar( + low_lc_df, + x='المكون', + y='نسبة المحتوى المحلي', + color='القيمة', + title="المكونات ذات المحتوى المحلي المنخفض", + text_auto='.1f' + ) + + fig.update_traces(texttemplate='%{text}%', textposition='outside') + + st.plotly_chart(fig, use_container_width=True) + else: + st.info("لا توجد مكونات ذات محتوى محلي منخفض (أقل من 50%).") + + # عرض توصيات لتحسين المحتوى المحلي + st.markdown("#### توصيات لتحسين المحتوى المحلي") + + if results["improvement_recommendations"]: + for recommendation in results["improvement_recommendations"]: + st.markdown(f""" +
+
{recommendation['component']} (المحتوى المحلي الحالي: {recommendation['current_lc']}%)
+

{recommendation['recommendation']}

+
+ """, unsafe_allow_html=True) + else: + st.success("المحتوى المحلي جيد ولا توجد توصيات للتحسين.") + + # عرض تحليل Claude AI المتقدم إذا كان متوفراً + if "claude_analysis" in results: + st.markdown("### تحليل Claude AI المتقدم") + st.info(results["claude_analysis"]) + + def _render_lc_suppliers_tab(self): + """عرض تبويب قاعدة بيانات الموردين للمحتوى المحلي""" + + st.markdown("#### قاعدة بيانات الموردين المحليين") + + # قائمة الفئات + categories = [ + "جميع الفئات", + "مواد بناء", + "أنظمة كهربائية", + "أنظمة ميكانيكية", + "تشطيبات", + "أثاث ومفروشات", + "خدمات هندسية", + "أنظمة أمنية", + "معدات وآليات" + ] + + # اختيار الفئة + selected_category = st.selectbox("فئة الموردين", categories) + + # البحث + search_query = st.text_input("البحث عن مورد") + + # إعداد قائمة الموردين + suppliers = [ + { + "id": 1, + "name": "شركة الإنشاءات السعودية", + "category": "مواد بناء", + "lc_rating": 95, + "quality_rating": 4.5, + "location": "الرياض", + "contact": "info@saudiconstruction.com", + "description": "شركة متخصصة في توريد جميع أنواع مواد البناء ذات المنشأ المحلي." + }, + { + "id": 2, + "name": "مؤسسة الطاقة المتقدمة", + "category": "أنظمة كهربائية", + "lc_rating": 85, + "quality_rating": 4.2, + "location": "جدة", + "contact": "sales@advancedpower.com", + "description": "مؤسسة متخصصة في توريد وتركيب الأنظمة الكهربائية والطاقة المتجددة." + }, + { + "id": 3, + "name": "شركة التبريد العالمية", + "category": "أنظمة ميكانيكية", + "lc_rating": 75, + "quality_rating": 4.0, + "location": "الدمام", + "contact": "info@globalcooling.com", + "description": "شركة متخصصة في أنظمة التكييف والتبريد المركزي للمشاريع الكبرى." + }, + { + "id": 4, + "name": "شركة الزجاج المتطورة", + "category": "مواد بناء", + "lc_rating": 80, + "quality_rating": 4.3, + "location": "الرياض", + "contact": "sales@advancedglass.com", + "description": "شركة متخصصة في إنتاج وتوريد الزجاج والواجهات الزجاجية للمباني." + }, + { + "id": 5, + "name": "مؤسسة التشطيبات الحديثة", + "category": "تشطيبات", + "lc_rating": 90, + "quality_rating": 4.7, + "location": "جدة", + "contact": "info@modernfinishing.com", + "description": "مؤسسة متخصصة في أعمال التشطيبات الداخلية والخارجية بجودة عالية." + }, + { + "id": 6, + "name": "شركة الأثاث المكتبي", + "category": "أثاث ومفروشات", + "lc_rating": 70, + "quality_rating": 4.0, + "location": "الرياض", + "contact": "sales@officefurniture.com", + "description": "شركة متخصصة في تصنيع وتوريد الأثاث المكتبي والتجهيزات المكتبية." + }, + { + "id": 7, + "name": "شركة الأنظمة الأمنية المتقدمة", + "category": "أنظمة أمنية", + "lc_rating": 65, + "quality_rating": 4.1, + "location": "الدمام", + "contact": "info@advancedsecurity.com", + "description": "شركة متخصصة في أنظمة الأمن والمراقبة والإنذار للمباني والمنشآت." + }, + { + "id": 8, + "name": "شركة المعدات الهندسية", + "category": "معدات وآليات", + "lc_rating": 85, + "quality_rating": 4.5, + "location": "جدة", + "contact": "sales@engineeringequipment.com", + "description": "شركة متخصصة في توريد وصيانة المعدات الهندسية والآليات للمشاريع." + }, + { + "id": 9, + "name": "مكتب الاستشارات الهندسية", + "category": "خدمات هندسية", + "lc_rating": 100, + "quality_rating": 4.8, + "location": "الرياض", + "contact": "info@engineeringconsultants.com", + "description": "مكتب استشاري متخصص في تقديم الخدمات الهندسية والاستشارية للمشاريع." + }, + { + "id": 10, + "name": "مصنع الحديد السعودي", + "category": "مواد بناء", + "lc_rating": 100, + "quality_rating": 4.6, + "location": "جدة", + "contact": "sales@saudisteel.com", + "description": "مصنع متخصص في إنتاج وتوريد منتجات الحديد والصلب للمشاريع الإنشائية." + } + ] + + # تطبيق الفلترة حسب الفئة + if selected_category != "جميع الفئات": + filtered_suppliers = [s for s in suppliers if s["category"] == selected_category] + else: + filtered_suppliers = suppliers + + # تطبيق فلترة البحث + if search_query: + filtered_suppliers = [s for s in filtered_suppliers if search_query.lower() in s["name"].lower() or search_query.lower() in s["description"].lower()] + + # عرض الموردين + for supplier in filtered_suppliers: + with st.container(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown(f""" +
+
{supplier['name']} ({supplier['category']})
+

الموقع: {supplier['location']} | التواصل: {supplier['contact']}

+

تصنيف المحتوى المحلي: {supplier['lc_rating']}% | تقييم الجودة: {supplier['quality_rating']}/5

+

{supplier['description']}

+
+ """, unsafe_allow_html=True) + + with col2: + st.button(f"عرض التفاصيل #{supplier['id']}", key=f"supplier_details_{supplier['id']}") + st.button(f"إضافة للمشروع #{supplier['id']}", key=f"add_supplier_{supplier['id']}") + + # زر إضافة مورد جديد + st.button("إضافة مورد جديد") + + def _render_lc_reports_tab(self): + """عرض تبويب تقارير المحتوى المحلي""" + + st.markdown("#### تقارير المحتوى المحلي") + + # اختيار نوع التقرير + report_type = st.selectbox( + "نوع التقرير", + [ + "تقرير المحتوى المحلي للمشروع الحالي", + "تقرير مقارنة المحتوى المحلي بين المشاريع", + "تقرير التطور التاريخي للمحتوى المحلي", + "تقرير الموردين ذوي المحتوى المحلي المرتفع", + "تقرير الامتثال لمتطلبات هيئة المحتوى المحلي" + ] + ) + + # عرض محاكاة للتقرير المختار + st.markdown(f"##### {report_type}") + + if report_type == "تقرير المحتوى المحلي للمشروع الحالي": + # محاكاة تقرير المشروع الحالي + project_data = pd.DataFrame({ + 'المكون': ['الخرسانة المسلحة', 'الأعمال الكهربائية', 'أعمال التكييف', 'الواجهات والنوافذ', + 'أعمال التشطيبات', 'الأثاث والتجهيزات', 'أنظمة الأمن والمراقبة', 'العمالة المباشرة'], + 'القيمة': [3000000, 1500000, 1200000, 800000, 1200000, 900000, 600000, 800000], + 'نسبة المحتوى المحلي': [85, 65, 55, 45, 80, 30, 40, 50] + }) + + # حساب قيمة المحتوى المحلي + project_data['قيمة المحتوى المحلي'] = project_data['القيمة'] * project_data['نسبة المحتوى المحلي'] / 100 + + # إضافة نسبة من إجمالي المشروع + total_value = project_data['القيمة'].sum() + project_data['نسبة من المشروع'] = project_data['القيمة'] / total_value * 100 + + # حساب النسبة الإجمالية للمحتوى المحلي + total_lc = project_data['قيمة المحتوى المحلي'].sum() / total_value * 100 + + # عرض الإجمالي + st.metric("نسبة المحتوى المحلي الإجمالية", f"{total_lc:.1f}%") + + # عرض تفاصيل المكونات + st.dataframe(project_data.style.format({ + 'القيمة': '{:,.0f} ريال', + 'قيمة المحتوى المحلي': '{:,.0f} ريال', + 'نسبة المحتوى المحلي': '{:.1f}%', + 'نسبة من المشروع': '{:.1f}%' + }), use_container_width=True) + + # مخطط توزيع المحتوى المحلي + col1, col2 = st.columns(2) + + with col1: + fig = px.pie( + project_data, + values='القيمة', + names='المكون', + title="توزيع قيمة المشروع" + ) + + st.plotly_chart(fig, use_container_width=True) + + with col2: + fig = px.pie( + project_data, + values='قيمة المحتوى المحلي', + names='المكون', + title="توزيع قيمة المحتوى المحلي" + ) + + st.plotly_chart(fig, use_container_width=True) + + # مخطط شريطي للمحتوى المحلي + fig = px.bar( + project_data, + x='المكون', + y='نسبة المحتوى المحلي', + title="نسبة المحتوى المحلي لكل مكون", + text_auto='.1f', + color='نسبة من المشروع' + ) + + fig.update_traces(texttemplate='%{text}%', textposition='outside') + + st.plotly_chart(fig, use_container_width=True) + + elif report_type == "تقرير مقارنة المحتوى المحلي بين المشاريع": + # محاكاة بيانات مقارنة المشاريع + projects_data = pd.DataFrame({ + 'المشروع': ['مبنى إداري الرياض', 'مجمع سكني جدة', 'مستشفى الدمام', 'مركز تجاري المدينة', 'فندق مكة'], + 'القيمة': [10000000, 15000000, 20000000, 12000000, 18000000], + 'نسبة المحتوى المحلي': [65, 55, 70, 60, 50], + 'سنة الإنجاز': [2022, 2022, 2023, 2023, 2024] + }) + + # عرض جدول المقارنة + st.dataframe(projects_data.style.format({ + 'القيمة': '{:,.0f} ريال', + 'نسبة المحتوى المحلي': '{:.1f}%' + }), use_container_width=True) + + # مخطط شريطي للمقارنة + fig = px.bar( + projects_data, + x='المشروع', + y='نسبة المحتوى المحلي', + title="مقارنة نسبة المحتوى المحلي بين المشاريع", + text_auto='.1f', + color='سنة الإنجاز' + ) + + fig.update_traces(texttemplate='%{text}%', textposition='outside') + + st.plotly_chart(fig, use_container_width=True) + + # مخطط فقاعي للمقارنة + fig = px.scatter( + projects_data, + x='القيمة', + y='نسبة المحتوى المحلي', + size='القيمة', + color='سنة الإنجاز', + text='المشروع', + title="العلاقة بين قيمة المشروع ونسبة المحتوى المحلي" + ) + + fig.update_traces(textposition='top center') + fig.update_layout(xaxis_title="قيمة المشروع (ريال)", yaxis_title="نسبة المحتوى المحلي (%)") + + st.plotly_chart(fig, use_container_width=True) + + elif report_type == "تقرير التطور التاريخي للمحتوى المحلي": + # محاكاة بيانات التطور التاريخي + historical_data = pd.DataFrame({ + 'السنة': [2019, 2020, 2021, 2022, 2023, 2024], + 'نسبة المحتوى المحلي': [45, 48, 52, 58, 62, 66], + 'المستهدف': [40, 45, 50, 55, 60, 65] + }) + + # عرض جدول التطور التاريخي + st.dataframe(historical_data.style.format({ + 'نسبة المحتوى المحلي': '{:.1f}%', + 'المستهدف': '{:.1f}%' + }), use_container_width=True) + + # مخطط خطي للتطور التاريخي + fig = px.line( + historical_data, + x='السنة', + y=['نسبة المحتوى المحلي', 'المستهدف'], + title="التطور التاريخي لنسبة المحتوى المحلي", + markers=True, + labels={'value': 'النسبة (%)', 'variable': ''} + ) + + fig.update_layout(legend_title_text='') + + st.plotly_chart(fig, use_container_width=True) + + # مخطط شريطي للمقارنة بين الفعلي والمستهدف + historical_data['الفرق'] = historical_data['نسبة المحتوى المحلي'] - historical_data['المستهدف'] + + fig = px.bar( + historical_data, + x='السنة', + y='الفرق', + title="الفرق بين نسبة المحتوى المحلي الفعلية والمستهدفة", + text_auto='.1f', + color='الفرق', + color_continuous_scale=['red', 'green'] + ) + + fig.update_traces(texttemplate='%{text}%', textposition='outside') + + st.plotly_chart(fig, use_container_width=True) + + elif report_type == "تقرير الموردين ذوي المحتوى المحلي المرتفع": + # محاكاة بيانات الموردين + suppliers_data = pd.DataFrame({ + 'المورد': ['شركة الإنشاءات السعودية', 'مؤسسة الطاقة المتقدمة', 'شركة التبريد العالمية', + 'شركة الزجاج المتطورة', 'مؤسسة التشطيبات الحديثة', 'مصنع الحديد السعودي', + 'شركة المعدات الهندسية', 'مكتب الاستشارات الهندسية'], + 'الفئة': ['مواد بناء', 'أنظمة كهربائية', 'أنظمة ميكانيكية', 'مواد بناء', + 'تشطيبات', 'مواد بناء', 'معدات وآليات', 'خدمات هندسية'], + 'نسبة المحتوى المحلي': [95, 85, 75, 80, 90, 100, 85, 100], + 'حجم التعامل': [3000000, 1500000, 1200000, 800000, 1200000, 2500000, 900000, 500000] + }) + + # عرض جدول الموردين + st.dataframe(suppliers_data.style.format({ + 'نسبة المحتوى المحلي': '{:.0f}%', + 'حجم التعامل': '{:,.0f} ريال' + }), use_container_width=True) + + # مخطط شريطي للموردين + fig = px.bar( + suppliers_data, + x='المورد', + y='نسبة المحتوى المحلي', + title="نسبة المحتوى المحلي للموردين", + text_auto='.0f', + color='الفئة' + ) + + fig.update_traces(texttemplate='%{text}%', textposition='outside') + fig.update_layout(xaxis_tickangle=-45) + + st.plotly_chart(fig, use_container_width=True) + + # مخطط فقاعي للموردين + fig = px.scatter( + suppliers_data, + x='نسبة المحتوى المحلي', + y='حجم التعامل', + size='حجم التعامل', + color='الفئة', + text='المورد', + title="العلاقة بين نسبة المحتوى المحلي وحجم التعامل مع الموردين" + ) + + fig.update_traces(textposition='top center') + fig.update_layout(xaxis_title="نسبة المحتوى المحلي (%)", yaxis_title="حجم التعامل (ريال)") + + st.plotly_chart(fig, use_container_width=True) + + elif report_type == "تقرير الامتثال لمتطلبات هيئة المحتوى المحلي": + # محاكاة بيانات الامتثال + compliance_data = pd.DataFrame({ + 'المتطلب': [ + 'نسبة المحتوى المحلي الإجمالية', + 'نسبة السعودة في القوى العاملة', + 'نسبة المنتجات المحلية', + 'نسبة الخدمات المحلية', + 'نسبة الموردين المحليين', + 'المساهمة في تطوير المحتوى المحلي' + ], + 'المستهدف': [40, 30, 50, 60, 70, 20], + 'المحقق': [38, 35, 45, 65, 75, 25], + 'حالة الامتثال': ['قريب', 'ممتثل', 'غير ممتثل', 'ممتثل', 'ممتثل', 'ممتثل'] + }) + + # إضافة ألوان لحالة الامتثال + colors = [] + for status in compliance_data['حالة الامتثال']: + if status == 'ممتثل': + colors.append('green') + elif status == 'قريب': + colors.append('orange') + else: + colors.append('red') + + compliance_data['اللون'] = colors + + # عرض جدول الامتثال + st.dataframe(compliance_data.style.format({ + 'المستهدف': '{:.0f}%', + 'المحقق': '{:.0f}%' + }), use_container_width=True) + + # مخطط شريطي للامتثال + fig = px.bar( + compliance_data, + x='المتطلب', + y=['المستهدف', 'المحقق'], + title="مقارنة المتطلبات المستهدفة والمحققة", + barmode='group', + labels={'value': 'النسبة (%)', 'variable': ''} + ) + + st.plotly_chart(fig, use_container_width=True) + + # مخطط دائري لحالة الامتثال + status_counts = compliance_data['حالة الامتثال'].value_counts().reset_index() + status_counts.columns = ['حالة الامتثال', 'العدد'] + + fig = px.pie( + status_counts, + values='العدد', + names='حالة الامتثال', + title="توزيع حالة الامتثال للمتطلبات", + color='حالة الامتثال', + color_discrete_map={ + 'ممتثل': 'green', + 'قريب': 'orange', + 'غير ممتثل': 'red' + } + ) + + st.plotly_chart(fig, use_container_width=True) + + # أزرار التصدير + col1, col2 = st.columns(2) + + with col1: + st.download_button( + "تصدير التقرير كملف Excel", + "بيانات التقرير", + file_name=f"{report_type}.xlsx", + mime="application/vnd.ms-excel" + ) + + with col2: + st.download_button( + "تصدير التقرير كملف PDF", + "بيانات التقرير", + file_name=f"{report_type}.pdf", + mime="application/pdf" + ) + + def _render_lc_optimization_tab(self): + """عرض تبويب تحسين المحتوى المحلي""" + + st.markdown("#### تحسين المحتوى المحلي") + + st.markdown(""" + تساعدك هذه الأداة في تحسين نسبة المحتوى المحلي في المشروع من خلال تقديم توصيات وبدائل للمكونات ذات المحتوى المحلي المنخفض. + """) + + # عرض المكونات ذات المحتوى المحلي المنخفض + st.markdown("##### المكونات ذات المحتوى المحلي المنخفض") + + # محاكاة بيانات المكونات ذات المحتوى المحلي المنخفض + low_lc_components = [ + { + "id": 1, + "name": "الأثاث والتجهيزات", + "category": "أثاث", + "value": 900000, + "local_content": 30, + "supplier": "شركة الأثاث المكتبي" + }, + { + "id": 2, + "name": "أنظمة الأمن والمراقبة", + "category": "أنظمة", + "value": 600000, + "local_content": 40, + "supplier": "شركة الأنظمة الأمنية المتقدمة" + }, + { + "id": 3, + "name": "الواجهات والنوافذ", + "category": "مواد", + "value": 800000, + "local_content": 45, + "supplier": "شركة الزجاج المتطورة" + } + ] + + # عرض جدول المكونات + low_lc_df = pd.DataFrame(low_lc_components) + + st.dataframe( + low_lc_df[["name", "category", "value", "local_content", "supplier"]].rename(columns={ + "name": "المكون", + "category": "الفئة", + "value": "القيمة", + "local_content": "المحتوى المحلي", + "supplier": "المورد" + }).style.format({ + "القيمة": "{:,.0f} ريال", + "المحتوى المحلي": "{:.0f}%" + }), + use_container_width=True + ) + + # اختيار مكون للتحسين + selected_component = st.selectbox( + "اختر المكون للتحسين", + options=[comp["name"] for comp in low_lc_components], + index=0 + ) + + # الحصول على المكون المختار + selected_comp_data = next((comp for comp in low_lc_components if comp["name"] == selected_component), None) + + # عرض بدائل المكون المختار + if selected_comp_data: + st.markdown(f"##### البدائل المقترحة لـ {selected_component}") + + # محاكاة بيانات البدائل + alternatives = [] + + if selected_component == "الأثاث والتجهيزات": + alternatives = [ + { + "id": 1, + "name": "شركة الأثاث الوطني", + "description": "شركة متخصصة في تصنيع الأثاث المكتبي محلياً", + "local_content": 80, + "cost_factor": 1.05, + "quality_rating": 4.2 + }, + { + "id": 2, + "name": "مصنع التجهيزات المكتبية", + "description": "مصنع متخصص في إنتاج الأثاث المكتبي بخامات محلية", + "local_content": 90, + "cost_factor": 1.10, + "quality_rating": 4.5 + }, + { + "id": 3, + "name": "توزيع المكونات على موردين محليين", + "description": "تقسيم توريد الأثاث على عدة موردين محليين", + "local_content": 75, + "cost_factor": 1.00, + "quality_rating": 4.0 + } + ] + elif selected_component == "أنظمة الأمن والمراقبة": + alternatives = [ + { + "id": 1, + "name": "شركة التقنية الأمنية السعودية", + "description": "شركة متخصصة في تركيب وتجميع أنظمة الأمن محلياً", + "local_content": 70, + "cost_factor": 1.08, + "quality_rating": 4.0 + }, + { + "id": 2, + "name": "مؤسسة تقنيات الحماية", + "description": "توريد وتركيب أنظمة أمنية معتمدة من هيئة المحتوى المحلي", + "local_content": 65, + "cost_factor": 0.95, + "quality_rating": 3.8 + }, + { + "id": 3, + "name": "تجميع الأنظمة محلياً", + "description": "استيراد المكونات وتجميعها وبرمجتها محلياً", + "local_content": 60, + "cost_factor": 0.90, + "quality_rating": 3.7 + } + ] + elif selected_component == "الواجهات والنوافذ": + alternatives = [ + { + "id": 1, + "name": "مصنع الزجاج السعودي", + "description": "مصنع متخصص في إنتاج الزجاج والواجهات الزجاجية محلياً", + "local_content": 85, + "cost_factor": 1.15, + "quality_rating": 4.3 + }, + { + "id": 2, + "name": "شركة الألمنيوم الوطنية", + "description": "شركة متخصصة في إنتاج الواجهات والنوافذ من الألمنيوم محلياً", + "local_content": 90, + "cost_factor": 1.20, + "quality_rating": 4.5 + }, + { + "id": 3, + "name": "تعديل التصميم لاستخدام مواد محلية", + "description": "تعديل تصميم الواجهات لاستخدام نسبة أكبر من المواد المتوفرة محلياً", + "local_content": 75, + "cost_factor": 1.00, + "quality_rating": 4.0 + } + ] + + # عرض البدائل + for alt in alternatives: + with st.container(): + col1, col2, col3 = st.columns([3, 1, 1]) + + with col1: + st.markdown(f""" +
+
{alt['name']}
+

{alt['description']}

+

المحتوى المحلي: {alt['local_content']}% | معامل التكلفة: {alt['cost_factor']:.2f} | تقييم الجودة: {alt['quality_rating']}/5

+
+ """, unsafe_allow_html=True) + + with col2: + st.button(f"تفاصيل #{alt['id']}", key=f"alt_details_{alt['id']}") + + with col3: + if st.button(f"اختيار #{alt['id']}", key=f"select_alt_{alt['id']}"): + st.success(f"تم اختيار {alt['name']} كبديل لـ {selected_component}.") + + # حساب تأثير البدائل على المحتوى المحلي الإجمالي + st.markdown("##### تأثير البدائل على المحتوى المحلي الإجمالي") + + # محاكاة البيانات الإجمالية + total_value = 10000000 + current_lc_value = 6000000 + current_lc_percent = current_lc_value / total_value * 100 + + # حساب التأثير لكل بديل + impact_data = [] + for alt in alternatives: + # القيمة الحالية للمحتوى المحلي في المكون + current_component_lc_value = selected_comp_data["value"] * selected_comp_data["local_content"] / 100 + + # القيمة المتوقعة للمحتوى المحلي مع البديل + new_component_value = selected_comp_data["value"] * alt["cost_factor"] + new_component_lc_value = new_component_value * alt["local_content"] / 100 + + # الفرق في قيمة المحتوى المحلي + lc_value_diff = new_component_lc_value - current_component_lc_value + + # القيمة الإجمالية الجديدة للمشروع + new_total_value = total_value - selected_comp_data["value"] + new_component_value + + # قيمة المحتوى المحلي الإجمالية الجديدة + new_total_lc_value = current_lc_value + lc_value_diff + + # نسبة المحتوى المحلي الإجمالية الجديدة + new_total_lc_percent = new_total_lc_value / new_total_value * 100 + + # إضافة البيانات + impact_data.append({ + "البديل": alt["name"], + "نسبة المحتوى المحلي الحالية": current_lc_percent, + "نسبة المحتوى المحلي المتوقعة": new_total_lc_percent, + "التغير": new_total_lc_percent - current_lc_percent, + "القيمة الإجمالية الجديدة": new_total_value, + "تقييم الجودة": alt["quality_rating"] + }) + + # عرض جدول التأثير + impact_df = pd.DataFrame(impact_data) + + st.dataframe( + impact_df.style.format({ + "نسبة المحتوى المحلي الحالية": "{:.1f}%", + "نسبة المحتوى المحلي المتوقعة": "{:.1f}%", + "التغير": "{:+.1f}%", + "القيمة الإجمالية الجديدة": "{:,.0f} ريال", + "تقييم الجودة": "{:.1f}/5" + }), + use_container_width=True + ) + + # مخطط مقارنة للبدائل + fig = px.bar( + impact_df, + x="البديل", + y=["نسبة المحتوى المحلي الحالية", "نسبة المحتوى المحلي المتوقعة"], + barmode="group", + title="مقارنة تأثير البدائل على نسبة المحتوى المحلي الإجمالية", + labels={"value": "نسبة المحتوى المحلي (%)", "variable": ""} + ) + + st.plotly_chart(fig, use_container_width=True) + + # استخدام Claude AI للتحليل المتقدم + if st.checkbox("استخدام Claude AI لتحليل البدائل", value=False, key="lc_optimization_use_claude"): + with st.spinner("جاري تحليل البدائل..."): + # محاكاة وقت المعالجة + time.sleep(2) + + try: + # إنشاء نص المدخلات للتحليل + prompt = f"""تحليل بدائل المحتوى المحلي لمكون {selected_component}: + + المكون الحالي: + - الاسم: {selected_component} + - الفئة: {selected_comp_data['category']} + - القيمة: {selected_comp_data['value']:,} ريال + - نسبة المحتوى المحلي: {selected_comp_data['local_content']}% + - المورد: {selected_comp_data['supplier']} + + البدائل المقترحة: + 1. {alternatives[0]['name']}: + - المحتوى المحلي: {alternatives[0]['local_content']}% + - معامل التكلفة: {alternatives[0]['cost_factor']:.2f} + - تقييم الجودة: {alternatives[0]['quality_rating']}/5 + - الوصف: {alternatives[0]['description']} + + 2. {alternatives[1]['name']}: + - المحتوى المحلي: {alternatives[1]['local_content']}% + - معامل التكلفة: {alternatives[1]['cost_factor']:.2f} + - تقييم الجودة: {alternatives[1]['quality_rating']}/5 + - الوصف: {alternatives[1]['description']} + + 3. {alternatives[2]['name']}: + - المحتوى المحلي: {alternatives[2]['local_content']}% + - معامل التكلفة: {alternatives[2]['cost_factor']:.2f} + - تقييم الجودة: {alternatives[2]['quality_rating']}/5 + - الوصف: {alternatives[2]['description']} + + المطلوب: + 1. تحليل مقارن شامل للبدائل من حيث المحتوى المحلي والتكلفة والجودة + 2. تحديد البديل الأفضل مع شرح أسباب اختياره + 3. تقديم توصيات إضافية لتحسين المحتوى المحلي لهذا المكون + 4. تحديد أي مخاطر محتملة في الانتقال للبديل المقترح + + يرجى تقديم تحليل مهني ومختصر يركز على الجوانب الأكثر أهمية. + """ + + # استدعاء Claude للتحليل + claude_analysis = self.claude_service.chat_completion( + [{"role": "user", "content": prompt}] + ) + + if "error" not in claude_analysis: + # عرض تحليل Claude + st.markdown("##### تحليل متقدم للبدائل") + st.info(claude_analysis["content"]) + else: + st.warning(f"تعذر إجراء التحليل المتقدم: {claude_analysis['error']}") + except Exception as e: + st.warning(f"تعذر إجراء التحليل المتقدم: {str(e)}") + + # زر تطبيق البديل المختار + if st.button("تطبيق البديل المختار على المشروع"): + st.success("تم تطبيق البديل المختار على المشروع وتحديث نسبة المحتوى المحلي.") + + def _render_faq_tab(self): + """عرض تبويب الأسئلة الشائعة""" + + st.markdown("### الأسئلة الشائعة") + + # البحث في الأسئلة الشائعة + search_query = st.text_input("البحث في الأسئلة الشائعة", key="faq_search") + + # فلترة الأسئلة حسب البحث + if search_query: + filtered_faqs = [ + faq for faq in self.faqs + if search_query.lower() in faq["question"].lower() or search_query.lower() in faq["answer"].lower() + ] + else: + filtered_faqs = self.faqs + + # عرض الأسئلة والأجوبة + for i, faq in enumerate(filtered_faqs): + with st.expander(faq["question"]): + st.markdown(faq["answer"]) + + # زر التواصل مع الدعم + st.markdown("##### لم تجد إجابة لسؤالك؟") + col1, col2 = st.columns(2) + + with col1: + if st.button("التواصل مع الدعم الفني", use_container_width=True): + st.info("سيتم التواصل معك قريباً من قبل فريق الدعم الفني.") + + with col2: + if st.button("طرح سؤال جديد", use_container_width=True): + st.text_area("اكتب سؤالك هنا") + st.button("إرسال") \ No newline at end of file diff --git a/modules/ai_assistant/assistant_app.py b/modules/ai_assistant/assistant_app.py new file mode 100644 index 0000000000000000000000000000000000000000..8306a78f6923fe730ef0c096c1e95c238e59ebe9 --- /dev/null +++ b/modules/ai_assistant/assistant_app.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +تطبيق المساعد الذكي التفاعلي +يتيح للمستخدمين التفاعل مع نماذج الذكاء الاصطناعي المتقدمة للحصول على مساعدة في تحليل العقود والمناقصات +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات المساعد الذكي +from modules.ai_assistant.ai_assistant import AIAssistant + + +class AssistantApp: + """تطبيق المساعد الذكي التفاعلي""" + + def __init__(self): + """تهيئة تطبيق المساعد الذكي""" + self.assistant = AIAssistant() + + def render(self): + """عرض واجهة المستخدم الرئيسية للتطبيق""" + self.assistant.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="المساعد الذكي | WAHBi AI", + page_icon="🤖", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = AssistantApp() + app.render() \ No newline at end of file diff --git a/modules/ai_finetuning/__init__.py b/modules/ai_finetuning/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..070419ec30c2df5c5ba01ff2fa55212770dd1391 --- /dev/null +++ b/modules/ai_finetuning/__init__.py @@ -0,0 +1 @@ +# ملف تهيئة حزمة ضبط نماذج الذكاء الاصطناعي \ No newline at end of file diff --git a/modules/ai_finetuning/finetuning_app.py b/modules/ai_finetuning/finetuning_app.py new file mode 100644 index 0000000000000000000000000000000000000000..a1dc1a7b8e4982246bd7567707b37e0b762a5b74 --- /dev/null +++ b/modules/ai_finetuning/finetuning_app.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة تطبيق تخصيص وضبط نماذج الذكاء الاصطناعي للمصطلحات التعاقدية المتخصصة +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات تخصيص وضبط نماذج الذكاء الاصطناعي +from modules.ai_finetuning.model_finetuning import ModelFinetuning + + +class FinetuningApp: + """وحدة تطبيق تخصيص وضبط نماذج الذكاء الاصطناعي""" + + def __init__(self): + """تهيئة وحدة تطبيق تخصيص وضبط نماذج الذكاء الاصطناعي""" + self.model_finetuning = ModelFinetuning() + + def render(self): + """عرض واجهة وحدة تطبيق تخصيص وضبط نماذج الذكاء الاصطناعي""" + st.markdown("

وحدة تخصيص وضبط نماذج الذكاء الاصطناعي

", unsafe_allow_html=True) + + st.markdown(""" +
+ تمكنك هذه الوحدة من تخصيص وضبط نماذج الذكاء الاصطناعي للتعرف بدقة على المصطلحات التعاقدية والهندسية المتخصصة باللغة العربية. + يمكنك إنشاء قاموس للمصطلحات، وإعداد أمثلة التدريب، وتدريب النماذج واختبارها. +
+ """, unsafe_allow_html=True) + + # عرض نموذج تخصيص وضبط نماذج الذكاء الاصطناعي + self.model_finetuning.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="تخصيص وضبط نماذج الذكاء الاصطناعي | WAHBi AI", + page_icon="🧠", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = FinetuningApp() + app.render() \ No newline at end of file diff --git a/modules/ai_finetuning/model_finetuning.py b/modules/ai_finetuning/model_finetuning.py new file mode 100644 index 0000000000000000000000000000000000000000..464b6c2455ca7b925a2920a3fab386449e407243 --- /dev/null +++ b/modules/ai_finetuning/model_finetuning.py @@ -0,0 +1,2081 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة تخصيص وضبط نماذج الذكاء الاصطناعي للمصطلحات التعاقدية المتخصصة +تتيح هذه الوحدة إمكانية تدريب نماذج الذكاء الاصطناعي على المصطلحات المتخصصة في مجال العقود والمناقصات +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import json +import time +import datetime +from typing import List, Dict, Any, Optional, Tuple +import openai +import matplotlib.pyplot as plt +import tempfile +import csv +import re +import random +from pathlib import Path + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +from utils.components.credits import render_credits +from utils.helpers import format_number, format_currency, styled_button + + +class ModelFinetuning: + """فئة تخصيص وضبط نماذج الذكاء الاصطناعي""" + + def __init__(self): + """تهيئة وحدة تخصيص وضبط نماذج الذكاء الاصطناعي""" + # تهيئة مجلدات حفظ البيانات + self.data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../data/finetuning")) + os.makedirs(self.data_dir, exist_ok=True) + + # تهيئة الملفات والمجلدات الفرعية + self.training_data_dir = os.path.join(self.data_dir, "training_data") + os.makedirs(self.training_data_dir, exist_ok=True) + + self.models_dir = os.path.join(self.data_dir, "models") + os.makedirs(self.models_dir, exist_ok=True) + + self.terminology_file = os.path.join(self.data_dir, "terminology.json") + + # تهيئة حالة الجلسة + if 'terminology_data' not in st.session_state: + if os.path.exists(self.terminology_file): + with open(self.terminology_file, 'r', encoding='utf-8') as f: + st.session_state.terminology_data = json.load(f) + else: + st.session_state.terminology_data = { + "terms": [], + "training_examples": [], + "models": [] + } + + if 'active_training_job' not in st.session_state: + st.session_state.active_training_job = None + + if 'training_results' not in st.session_state: + st.session_state.training_results = [] + + # ضبط API مفاتيح الذكاء الاصطناعي + self.api_key = os.environ.get("OPENAI_API_KEY") + self.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY") + + def render(self): + """عرض واجهة وحدة تخصيص وضبط نماذج الذكاء الاصطناعي""" + # عرض الشعار والعنوان الرئيسي + render_header("تخصيص وضبط نماذج الذكاء الاصطناعي للمصطلحات التعاقدية المتخصصة") + + # تبويبات الوحدة + tabs = st.tabs([ + "قاموس المصطلحات المتخصصة", + "إعداد بيانات التدريب", + "تدريب النموذج", + "اختبار النموذج", + "المساعد المتخصص" + ]) + + # تبويب قاموس المصطلحات المتخصصة + with tabs[0]: + self._render_terminology_dictionary() + + # تبويب إعداد بيانات التدريب + with tabs[1]: + self._render_training_data_setup() + + # تبويب تدريب النموذج + with tabs[2]: + self._render_model_training() + + # تبويب اختبار النموذج + with tabs[3]: + self._render_model_testing() + + # تبويب المساعد المتخصص + with tabs[4]: + self._render_specialized_assistant() + + # عرض حقوق النشر + render_credits() + + def _render_terminology_dictionary(self): + """عرض قاموس المصطلحات المتخصصة""" + st.markdown(""" +
+

📚 قاموس المصطلحات المتخصصة

+

أضف وحرر المصطلحات الفنية المتخصصة في مجال العقود والمناقصات باللغة العربية.

+

هذه المصطلحات ستستخدم لتدريب وضبط نماذج الذكاء الاصطناعي للتعرف عليها بدقة عالية.

+
+ """, unsafe_allow_html=True) + + # إضافة مصطلح جديد + st.markdown("### إضافة مصطلح جديد") + + col1, col2 = st.columns(2) + + with col1: + term = st.text_input("المصطلح", key="new_term") + category = st.selectbox( + "الفئة", + options=[ + "شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", + "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", + "محتوى محلي", "أخرى" + ], + key="new_term_category" + ) + + with col2: + english_term = st.text_input("المصطلح بالإنجليزية (اختياري)", key="new_term_english") + importance = st.slider("مستوى الأهمية", 1, 5, 3, key="new_term_importance") + + definition = st.text_area("التعريف", key="new_term_definition") + examples = st.text_area("أمثلة على استخدام المصطلح (فصل بين الأمثلة بسطر جديد)", key="new_term_examples") + + # زر إضافة المصطلح + if styled_button("إضافة المصطلح", key="add_term", type="primary", icon="➕"): + if not term or not definition: + st.error("يرجى تعبئة المصطلح والتعريف على الأقل.") + else: + # إنشاء كائن المصطلح + new_term = { + "term": term, + "definition": definition, + "category": category, + "english_term": english_term, + "importance": importance, + "examples": [ex.strip() for ex in examples.split("\n") if ex.strip()], + "added_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # إضافة المصطلح للقائمة + st.session_state.terminology_data["terms"].append(new_term) + + # حفظ البيانات + self._save_terminology_data() + + st.success(f"تمت إضافة المصطلح '{term}' بنجاح!") + st.rerun() + + # عرض المصطلحات الموجودة + st.markdown("### المصطلحات الموجودة") + + terms = st.session_state.terminology_data.get("terms", []) + + if not terms: + st.info("لا توجد مصطلحات مضافة. يرجى إضافة مصطلحات جديدة.") + else: + # تصفية المصطلحات + filter_col1, filter_col2 = st.columns(2) + + with filter_col1: + filter_category = st.selectbox( + "تصفية حسب الفئة", + options=["الكل"] + list(set(t.get("category") for t in terms)), + key="filter_term_category" + ) + + with filter_col2: + search_query = st.text_input("بحث", key="search_term") + + # تطبيق التصفية + filtered_terms = terms + if filter_category != "الكل": + filtered_terms = [t for t in filtered_terms if t.get("category") == filter_category] + + if search_query: + filtered_terms = [ + t for t in filtered_terms + if search_query.lower() in t.get("term", "").lower() or + search_query.lower() in t.get("definition", "").lower() or + search_query.lower() in t.get("english_term", "").lower() + ] + + # عرض المصطلحات المصفاة + if not filtered_terms: + st.warning("لا توجد مصطلحات تطابق معايير التصفية.") + else: + # إعداد بيانات للعرض + for i, term in enumerate(filtered_terms): + with st.expander(f"{term.get('term')} ({term.get('english_term', '')})", expanded=i==0 and len(filtered_terms)<5): + term_col1, term_col2 = st.columns([3, 1]) + + with term_col1: + st.markdown(f"**التعريف:** {term.get('definition')}") + st.markdown(f"**الفئة:** {term.get('category')}") + st.markdown(f"**المصطلح بالإنجليزية:** {term.get('english_term', '-')}") + + if "examples" in term and term["examples"]: + st.markdown("**أمثلة:**") + for ex in term["examples"]: + st.markdown(f"- {ex}") + + with term_col2: + st.markdown(f"**مستوى الأهمية:** {'⭐' * term.get('importance', 3)}") + st.markdown(f"**تاريخ الإضافة:** {term.get('added_at', '-')}") + + # أزرار التحرير والحذف + if styled_button("تحرير", key=f"edit_term_{i}", type="secondary", icon="✏️"): + st.session_state.term_to_edit = i + + if styled_button("حذف", key=f"delete_term_{i}", type="danger", icon="🗑️"): + st.session_state.term_to_delete = i + + # معالجة تحرير أو حذف المصطلح + if "term_to_edit" in st.session_state: + self._render_edit_term_form(st.session_state.term_to_edit, filtered_terms) + + if "term_to_delete" in st.session_state: + if st.warning(f"هل أنت متأكد من حذف المصطلح '{filtered_terms[st.session_state.term_to_delete].get('term')}'؟"): + if styled_button("تأكيد الحذف", key="confirm_delete", type="danger", icon="🗑️"): + # حذف المصطلح + term_index = terms.index(filtered_terms[st.session_state.term_to_delete]) + del st.session_state.terminology_data["terms"][term_index] + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة الحذف + del st.session_state.term_to_delete + + st.success("تم حذف المصطلح بنجاح!") + st.rerun() + + if styled_button("إلغاء", key="cancel_delete", type="secondary", icon="❌"): + del st.session_state.term_to_delete + st.rerun() + + # تصدير المصطلحات + st.markdown("### تصدير وتوريد المصطلحات") + + col1, col2 = st.columns(2) + + with col1: + if styled_button("تصدير المصطلحات إلى CSV", key="export_terms", type="primary", icon="📤"): + self._export_terms_to_csv() + + with col2: + uploaded_file = st.file_uploader("استيراد المصطلحات من ملف CSV", type=["csv"], key="import_terms_file") + + if uploaded_file is not None: + if styled_button("استيراد المصطلحات", key="import_terms", type="success", icon="📥"): + self._import_terms_from_csv(uploaded_file) + + def _render_edit_term_form(self, term_index, terms_list): + """عرض نموذج تحرير المصطلح""" + term = terms_list[term_index] + + st.markdown("### تحرير المصطلح") + + col1, col2 = st.columns(2) + + with col1: + edited_term = st.text_input("المصطلح", value=term.get("term", ""), key="edit_term_name") + edited_category = st.selectbox( + "الفئة", + options=[ + "شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", + "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", + "محتوى محلي", "أخرى" + ], + index=["شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", "محتوى محلي", "أخرى"].index(term.get("category", "أخرى")), + key="edit_term_category" + ) + + with col2: + edited_english_term = st.text_input("المصطلح بالإنجليزية (اختياري)", value=term.get("english_term", ""), key="edit_term_english") + edited_importance = st.slider("مستوى الأهمية", 1, 5, term.get("importance", 3), key="edit_term_importance") + + edited_definition = st.text_area("التعريف", value=term.get("definition", ""), key="edit_term_definition") + edited_examples = st.text_area("أمثلة على استخدام المصطلح (فصل بين الأمثلة بسطر جديد)", value="\n".join(term.get("examples", [])), key="edit_term_examples") + + col1, col2 = st.columns(2) + + with col1: + if styled_button("حفظ التغييرات", key="save_edited_term", type="primary", icon="💾"): + if not edited_term or not edited_definition: + st.error("يرجى تعبئة المصطلح والتعريف على الأقل.") + else: + # تحديث المصطلح + updated_term = { + "term": edited_term, + "definition": edited_definition, + "category": edited_category, + "english_term": edited_english_term, + "importance": edited_importance, + "examples": [ex.strip() for ex in edited_examples.split("\n") if ex.strip()], + "added_at": term.get("added_at", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + "updated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # الحصول على المؤشر الفعلي في القائمة الكاملة + all_terms = st.session_state.terminology_data["terms"] + actual_index = all_terms.index(term) + + # تحديث المصطلح + st.session_state.terminology_data["terms"][actual_index] = updated_term + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة التحرير + del st.session_state.term_to_edit + + st.success(f"تم تحديث المصطلح '{edited_term}' بنجاح!") + st.rerun() + + with col2: + if styled_button("إلغاء", key="cancel_edit_term", type="secondary", icon="❌"): + del st.session_state.term_to_edit + st.rerun() + + def _export_terms_to_csv(self): + """تصدير المصطلحات إلى ملف CSV""" + terms = st.session_state.terminology_data.get("terms", []) + + if not terms: + st.error("لا توجد مصطلحات للتصدير.") + return + + # إنشاء ملف CSV مؤقت + with tempfile.NamedTemporaryFile(mode='w+', suffix='.csv', newline='', encoding='utf-8', delete=False) as f: + writer = csv.writer(f) + + # كتابة الترويسة + writer.writerow([ + 'المصطلح', 'التعريف', 'الفئة', 'المصطلح بالإنجليزية', + 'مستوى الأهمية', 'الأمثلة', 'تاريخ الإضافة' + ]) + + # كتابة المصطلحات + for term in terms: + writer.writerow([ + term.get('term', ''), + term.get('definition', ''), + term.get('category', ''), + term.get('english_term', ''), + term.get('importance', 3), + '|'.join(term.get('examples', [])), + term.get('added_at', '') + ]) + + # الحصول على مسار الملف + csv_path = f.name + + # قراءة الملف وتقديمه للتنزيل + with open(csv_path, 'r', encoding='utf-8') as f: + csv_data = f.read() + + # تقديم الملف للتنزيل + st.download_button( + label="تنزيل ملف CSV", + data=csv_data, + file_name="terminology_dictionary.csv", + mime="text/csv" + ) + + # حذف الملف المؤقت + os.unlink(csv_path) + + def _import_terms_from_csv(self, uploaded_file): + """استيراد المصطلحات من ملف CSV""" + try: + # قراءة الملف + df = pd.read_csv(uploaded_file, encoding='utf-8') + + # التحقق من وجود الأعمدة المطلوبة + required_columns = ['المصطلح', 'التعريف'] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + st.error(f"الملف لا يحتوي على الأعمدة التالية: {', '.join(missing_columns)}") + return + + # إضافة المصطلحات + terms_added = 0 + terms_updated = 0 + + for _, row in df.iterrows(): + term = row['المصطلح'] + + # البحث عن المصطلح الموجود + existing_term = next((t for t in st.session_state.terminology_data["terms"] if t.get("term") == term), None) + + # تحضير كائن المصطلح + term_obj = { + "term": term, + "definition": row.get('التعريف', ''), + "category": row.get('الفئة', 'أخرى'), + "english_term": row.get('المصطلح بالإنجليزية', ''), + "importance": int(row.get('مستوى الأهمية', 3)), + "examples": row.get('الأمثلة', '').split('|') if 'الأمثلة' in row else [], + "added_at": row.get('تاريخ الإضافة', datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + } + + if existing_term: + # تحديث المصطلح الموجود + index = st.session_state.terminology_data["terms"].index(existing_term) + st.session_state.terminology_data["terms"][index] = term_obj + terms_updated += 1 + else: + # إضافة مصطلح جديد + st.session_state.terminology_data["terms"].append(term_obj) + terms_added += 1 + + # حفظ البيانات + self._save_terminology_data() + + st.success(f"تم استيراد المصطلحات بنجاح! (تمت إضافة {terms_added} مصطلح جديد، وتحديث {terms_updated} مصطلح موجود)") + st.rerun() + + except Exception as e: + st.error(f"حدث خطأ أثناء استيراد المصطلحات: {str(e)}") + + def _render_training_data_setup(self): + """عرض إعداد بيانات التدريب""" + st.markdown(""" +
+

🔬 إعداد بيانات التدريب

+

قم بإنشاء وتحرير أمثلة التدريب لضبط نماذج الذكاء الاصطناعي على المصطلحات المتخصصة.

+

يمكنك إنشاء أمثلة يدوياً أو استيرادها من ملف أو توليدها تلقائياً باستخدام نماذج الذكاء الاصطناعي الحالية.

+
+ """, unsafe_allow_html=True) + + # تبويبات إعداد البيانات + training_tabs = st.tabs(["أمثلة التدريب الحالية", "إنشاء أمثلة يدوياً", "توليد أمثلة تلقائياً", "استيراد وتصدير البيانات"]) + + # عرض أمثلة التدريب الحالية + with training_tabs[0]: + self._render_existing_training_examples() + + # إنشاء أمثلة يدوياً + with training_tabs[1]: + self._render_manual_example_creation() + + # توليد أمثلة تلقائياً + with training_tabs[2]: + self._render_automatic_example_generation() + + # استيراد وتصدير البيانات + with training_tabs[3]: + self._render_import_export_training_data() + + def _render_existing_training_examples(self): + """عرض أمثلة التدريب الحالية""" + st.markdown("### أمثلة التدريب الحالية") + + examples = st.session_state.terminology_data.get("training_examples", []) + + if not examples: + st.info("لا توجد أمثلة تدريب. يرجى إنشاء أمثلة جديدة.") + return + + # عرض إحصائيات البيانات + st.markdown("#### إحصائيات البيانات") + + total_examples = len(examples) + categories = {} + terms_used = set() + + for ex in examples: + cat = ex.get("category", "غير مصنف") + categories[cat] = categories.get(cat, 0) + 1 + + for term in ex.get("terms", []): + terms_used.add(term) + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي الأمثلة", total_examples) + + with col2: + st.metric("عدد المصطلحات المستخدمة", len(terms_used)) + + with col3: + st.metric("عدد الفئات", len(categories)) + + # عرض توزيع الفئات + st.markdown("#### توزيع الأمثلة حسب الفئة") + + categories_df = pd.DataFrame({ + "الفئة": list(categories.keys()), + "عدد الأمثلة": list(categories.values()) + }) + + fig, ax = plt.subplots(figsize=(10, 6)) + ax.bar(categories_df["الفئة"], categories_df["عدد الأمثلة"]) + ax.set_title("توزيع أمثلة التدريب حسب الفئة") + ax.set_xlabel("الفئة") + ax.set_ylabel("عدد الأمثلة") + + # تدوير أسماء الفئات لتسهيل القراءة + plt.xticks(rotation=45, ha='right') + plt.tight_layout() + + st.pyplot(fig) + + # تصفية الأمثلة + st.markdown("#### تصفية الأمثلة") + + filter_col1, filter_col2 = st.columns(2) + + with filter_col1: + filter_category = st.selectbox( + "تصفية حسب الفئة", + options=["الكل"] + list(categories.keys()), + key="filter_example_category" + ) + + with filter_col2: + search_query = st.text_input("بحث في النص", key="search_example") + + # تطبيق التصفية + filtered_examples = examples + if filter_category != "الكل": + filtered_examples = [ex for ex in filtered_examples if ex.get("category") == filter_category] + + if search_query: + filtered_examples = [ + ex for ex in filtered_examples + if search_query.lower() in ex.get("input", "").lower() or + search_query.lower() in ex.get("output", "").lower() + ] + + # عرض الأمثلة المصفاة + if not filtered_examples: + st.warning("لا توجد أمثلة تطابق معايير التصفية.") + else: + # عرض عدد محدود من الأمثلة في كل صفحة + examples_per_page = 10 + total_pages = (len(filtered_examples) - 1) // examples_per_page + 1 + + # التنقل بين الصفحات + col1, col2, col3 = st.columns([1, 3, 1]) + + with col2: + page = st.slider("الصفحة", 1, max(1, total_pages), 1, key="examples_page") + + start_idx = (page - 1) * examples_per_page + end_idx = min(start_idx + examples_per_page, len(filtered_examples)) + + page_examples = filtered_examples[start_idx:end_idx] + + # عرض الأمثلة + for i, example in enumerate(page_examples): + example_idx = start_idx + i + with st.expander(f"مثال #{example_idx+1} - {example.get('category', 'غير مصنف')}", expanded=i==0 and len(page_examples)<5): + ex_col1, ex_col2 = st.columns([3, 1]) + + with ex_col1: + st.markdown("**النص المدخل:**") + st.markdown(f"```\n{example.get('input', '')}\n```") + + st.markdown("**النص المتوقع:**") + st.markdown(f"```\n{example.get('output', '')}\n```") + + with ex_col2: + st.markdown("**الفئة:** " + example.get('category', 'غير مصنف')) + st.markdown("**المصطلحات المستخدمة:**") + for term in example.get("terms", []): + st.markdown(f"- {term}") + + # تاريخ الإنشاء + if "created_at" in example: + st.markdown(f"**تاريخ الإنشاء:** {example['created_at']}") + + # أزرار التحرير والحذف + if styled_button("تحرير", key=f"edit_example_{example_idx}", type="secondary", icon="✏️"): + st.session_state.example_to_edit = example_idx + + if styled_button("حذف", key=f"delete_example_{example_idx}", type="danger", icon="🗑️"): + st.session_state.example_to_delete = example_idx + + # معالجة تحرير أو حذف مثال + if "example_to_edit" in st.session_state: + self._render_edit_example_form(st.session_state.example_to_edit, filtered_examples) + + if "example_to_delete" in st.session_state: + if st.warning(f"هل أنت متأكد من حذف المثال #{st.session_state.example_to_delete+1}؟"): + if styled_button("تأكيد الحذف", key="confirm_delete_example", type="danger", icon="🗑️"): + # حذف المثال + example_index = examples.index(filtered_examples[st.session_state.example_to_delete]) + del st.session_state.terminology_data["training_examples"][example_index] + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة الحذف + del st.session_state.example_to_delete + + st.success("تم حذف المثال بنجاح!") + st.rerun() + + if styled_button("إلغاء", key="cancel_delete_example", type="secondary", icon="❌"): + del st.session_state.example_to_delete + st.rerun() + + def _render_edit_example_form(self, example_index, examples_list): + """عرض نموذج تحرير مثال التدريب""" + example = examples_list[example_index] + + st.markdown("### تحرير مثال التدريب") + + # اختيار المصطلحات المستخدمة + all_terms = [term.get("term") for term in st.session_state.terminology_data.get("terms", [])] + selected_terms = st.multiselect( + "المصطلحات المستخدمة في المثال", + options=all_terms, + default=example.get("terms", []), + key="edit_example_terms" + ) + + # إدخال النص المدخل والمتوقع + input_text = st.text_area("النص المدخل", value=example.get("input", ""), key="edit_example_input", height=150) + output_text = st.text_area("النص المتوقع", value=example.get("output", ""), key="edit_example_output", height=150) + + # اختيار الفئة + category = st.selectbox( + "الفئة", + options=["شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", "محتوى محلي", "أخرى"], + index=["شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", "محتوى محلي", "أخرى"].index(example.get("category", "أخرى")), + key="edit_example_category" + ) + + col1, col2 = st.columns(2) + + with col1: + if styled_button("حفظ التغييرات", key="save_edited_example", type="primary", icon="💾"): + if not input_text or not output_text: + st.error("يرجى تعبئة النص المدخل والنص المتوقع.") + else: + # تحديث المثال + updated_example = { + "input": input_text, + "output": output_text, + "category": category, + "terms": selected_terms, + "created_at": example.get("created_at", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + "updated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # الحصول على المؤشر الفعلي في القائمة الكاملة + all_examples = st.session_state.terminology_data["training_examples"] + actual_index = all_examples.index(example) + + # تحديث المثال + st.session_state.terminology_data["training_examples"][actual_index] = updated_example + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة التحرير + del st.session_state.example_to_edit + + st.success("تم تحديث مثال التدريب بنجاح!") + st.rerun() + + with col2: + if styled_button("إلغاء", key="cancel_edit_example", type="secondary", icon="❌"): + del st.session_state.example_to_edit + st.rerun() + + def _render_manual_example_creation(self): + """عرض نموذج إنشاء أمثلة يدوياً""" + st.markdown("### إنشاء مثال تدريب جديد") + + # اختيار المصطلحات المستخدمة + all_terms = [term.get("term") for term in st.session_state.terminology_data.get("terms", [])] + selected_terms = st.multiselect( + "المصطلحات المستخدمة في المثال", + options=all_terms, + key="new_example_terms" + ) + + # اختيار الفئة + category = st.selectbox( + "الفئة", + options=["شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", "محتوى محلي", "أخرى"], + key="new_example_category" + ) + + # إدخال النص المدخل والمتوقع + st.markdown("**النص المدخل** (نص السؤال أو الطلب)") + input_text = st.text_area("", key="new_example_input", height=150, placeholder="مثال: قم بشرح معنى مصطلح 'محتوى محلي' وكيفية حسابه في المشاريع الحكومية.") + + st.markdown("**النص المتوقع** (الإجابة المثالية التي يجب أن يقدمها النموذج)") + output_text = st.text_area("", key="new_example_output", height=150, placeholder="مثال: المحتوى المحلي (Local Content) هو نسبة القيمة المحلية المضافة في المنتجات والخدمات المقدمة في المشروع...") + + # عرض تعريفات المصطلحات المختارة للمساعدة + if selected_terms: + with st.expander("تعريفات المصطلحات المختارة", expanded=True): + for term_name in selected_terms: + term = next((t for t in st.session_state.terminology_data.get("terms", []) if t.get("term") == term_name), None) + if term: + st.markdown(f"**{term_name}**: {term.get('definition', '')}") + + # زر إضافة المثال + if styled_button("إضافة مثال التدريب", key="add_example", type="primary", icon="➕"): + if not input_text or not output_text: + st.error("يرجى تعبئة النص المدخل والنص المتوقع.") + else: + # إنشاء كائن المثال + new_example = { + "input": input_text, + "output": output_text, + "category": category, + "terms": selected_terms, + "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # إضافة المثال للقائمة + if "training_examples" not in st.session_state.terminology_data: + st.session_state.terminology_data["training_examples"] = [] + + st.session_state.terminology_data["training_examples"].append(new_example) + + # حفظ البيانات + self._save_terminology_data() + + st.success("تم إضافة مثال التدريب بنجاح!") + st.rerun() + + def _render_automatic_example_generation(self): + """عرض واجهة توليد أمثلة تلقائياً""" + st.markdown("### توليد أمثلة تدريب تلقائياً") + + # التحقق من وجود مفاتيح API + if not self.api_key and not self.anthropic_api_key: + st.warning("لم يتم العثور على مفاتيح API للذكاء الاصطناعي. يرجى إضافة OPENAI_API_KEY أو ANTHROPIC_API_KEY إلى المتغيرات البيئية.") + return + + # اختيار نموذج الذكاء الاصطناعي + ai_models = [] + + if self.api_key: + ai_models.extend(["gpt-4o", "gpt-3.5-turbo"]) + + if self.anthropic_api_key: + ai_models.extend(["claude-3-7-sonnet-20250219"]) + + selected_model = st.selectbox( + "اختر نموذج الذكاء الاصطناعي", + options=ai_models, + key="auto_gen_model" + ) + + # اختيار المصطلحات لتوليد أمثلة حولها + all_terms = [term.get("term") for term in st.session_state.terminology_data.get("terms", [])] + selected_terms = st.multiselect( + "اختر المصطلحات لتوليد أمثلة حولها", + options=all_terms, + key="auto_gen_terms" + ) + + # اختيار عدد الأمثلة المراد توليدها + num_examples = st.slider("عدد الأمثلة لكل مصطلح", 1, 5, 2, key="auto_gen_count") + + # اختيار الفئات المرغوبة + selected_categories = st.multiselect( + "اختر الفئات المرغوبة للأمثلة", + options=["شروط تعاقدية", "مواصفات فنية", "مستندات مناقصة", "بنود مالية", "جداول كميات", "ضمانات", "مصطلحات قانونية", "محتوى محلي", "أخرى"], + default=["شروط تعاقدية", "مستندات مناقصة", "مصطلحات قانونية"], + key="auto_gen_categories" + ) + + # زر توليد الأمثلة + if styled_button("توليد الأمثلة", key="generate_examples", type="primary", icon="✨"): + if not selected_terms: + st.error("يرجى اختيار مصطلح واحد على الأقل.") + elif not selected_categories: + st.error("يرجى اختيار فئة واحدة على الأقل.") + else: + # عرض شريط التقدم + progress_bar = st.progress(0) + status_text = st.empty() + + # تجهيز المصطلحات وتعريفاتها + terms_with_definitions = {} + for term_name in selected_terms: + term = next((t for t in st.session_state.terminology_data.get("terms", []) if t.get("term") == term_name), None) + if term: + terms_with_definitions[term_name] = term.get('definition', '') + + # توليد الأمثلة + generated_examples = [] + total_iterations = len(selected_terms) * len(selected_categories) + current_iteration = 0 + + for term_name, definition in terms_with_definitions.items(): + for category in selected_categories: + current_iteration += 1 + progress = current_iteration / total_iterations + progress_bar.progress(progress) + status_text.text(f"جاري توليد أمثلة للمصطلح '{term_name}' في الفئة '{category}'...") + + # توليد أمثلة لهذا المصطلح والفئة + examples = self._generate_examples_with_ai( + term_name, + definition, + category, + num_examples, + selected_model + ) + + generated_examples.extend(examples) + + # إضافة الأمثلة المولدة إلى البيانات + if "training_examples" not in st.session_state.terminology_data: + st.session_state.terminology_data["training_examples"] = [] + + st.session_state.terminology_data["training_examples"].extend(generated_examples) + + # حفظ البيانات + self._save_terminology_data() + + # إكمال شريط التقدم + progress_bar.progress(1.0) + status_text.text(f"تم توليد {len(generated_examples)} مثال بنجاح!") + + st.success(f"تم توليد {len(generated_examples)} مثال بنجاح!") + st.rerun() + + def _generate_examples_with_ai(self, term_name, definition, category, num_examples, model): + """توليد أمثلة باستخدام الذكاء الاصطناعي""" + # تحضير الرسالة + prompt = f""" + أنت خبير في توليد أمثلة تدريب لضبط نماذج الذكاء الاصطناعي على المصطلحات التعاقدية والهندسية المتخصصة باللغة العربية. + + أريد منك توليد {num_examples} مثال تدريب للمصطلح التالي: + + المصطلح: {term_name} + التعريف: {definition} + الفئة: {category} + + لكل مثال، قم بتوليد: + 1. نص المدخل (سؤال أو طلب حول المصطلح) + 2. نص المخرج المتوقع (الإجابة المثالية التي يجب أن يقدمها النموذج) + + تأكد من: + - جعل الأمثلة متنوعة وواقعية + - تضمين سياقات مختلفة لاستخدام المصطلح + - استخدام أسلوب مناسب لوثائق المناقصات والعقود + - تضمين تفاصيل تقنية دقيقة عند الحاجة + + قم بإرجاع النتائج بتنسيق JSON كما يلي: + + ```json + [ + { + "input": "نص المدخل للمثال الأول", + "output": "نص المخرج المتوقع للمثال الأول" + }, + { + "input": "نص المدخل للمثال الثاني", + "output": "نص المخرج المتوقع للمثال الثاني" + }, + ... + ] + ``` + + أرجع البيانات بتنسيق JSON فقط. + """ + + try: + # استدعاء API المناسب حسب النموذج المختار + if "gpt" in model and self.api_key: + # استخدام OpenAI API + openai.api_key = self.api_key + + response = openai.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": "أنت مساعد محترف متخصص في توليد بيانات تدريب لضبط نماذج الذكاء الاصطناعي."}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + response_format={"type": "json_object"} + ) + + # استخراج النتيجة + result_text = response.choices[0].message.content + + # تنظيف النص واستخراج JSON + json_match = re.search(r'```json\s*(.*?)\s*```', result_text, re.DOTALL) + if json_match: + result_json = json_match.group(1) + else: + result_json = result_text + + # تحليل JSON + examples_data = json.loads(result_json) + + # إذا كان الناتج كائن JSON بخاصية examples + if isinstance(examples_data, dict) and "examples" in examples_data: + examples_data = examples_data["examples"] + + elif "claude" in model and self.anthropic_api_key: + # استخدام Anthropic API + from anthropic import Anthropic + + anthropic_client = Anthropic(api_key=self.anthropic_api_key) + + response = anthropic_client.messages.create( + model=model, + max_tokens=4000, + messages=[ + {"role": "user", "content": prompt} + ], + temperature=0.7 + ) + + # استخراج النتيجة + result_text = response.content[0].text + + # تنظيف النص واستخراج JSON + json_match = re.search(r'```json\s*(.*?)\s*```', result_text, re.DOTALL) + if json_match: + result_json = json_match.group(1) + else: + result_json = result_text + + # تحليل JSON + examples_data = json.loads(result_json) + + # إذا كان الناتج كائن JSON بخاصية examples + if isinstance(examples_data, dict) and "examples" in examples_data: + examples_data = examples_data["examples"] + + else: + # في حالة عدم توفر النموذج المطلوب + return [] + + # تحويل البيانات إلى الصيغة المطلوبة للأمثلة + formatted_examples = [] + + for example in examples_data: + formatted_examples.append({ + "input": example["input"], + "output": example["output"], + "category": category, + "terms": [term_name], + "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "generated_by": model + }) + + return formatted_examples + + except Exception as e: + st.error(f"حدث خطأ أثناء توليد الأمثلة: {str(e)}") + return [] + + def _render_import_export_training_data(self): + """عرض واجهة استيراد وتصدير بيانات التدريب""" + st.markdown("### استيراد وتصدير بيانات التدريب") + + col1, col2 = st.columns(2) + + with col1: + st.markdown("#### تصدير بيانات التدريب") + + export_format = st.selectbox( + "صيغة التصدير", + options=["JSON", "JSONL", "CSV"], + key="export_format" + ) + + if styled_button("تصدير البيانات", key="export_data", type="primary", icon="📤"): + self._export_training_data(export_format) + + with col2: + st.markdown("#### استيراد بيانات التدريب") + + import_format = st.selectbox( + "صيغة الاستيراد", + options=["JSON", "JSONL", "CSV"], + key="import_format" + ) + + uploaded_file = st.file_uploader("استيراد بيانات التدريب", type=["json", "jsonl", "csv"], key="import_data_file") + + if uploaded_file is not None: + if styled_button("استيراد البيانات", key="import_data", type="success", icon="📥"): + self._import_training_data(uploaded_file, import_format) + + def _export_training_data(self, format): + """تصدير بيانات التدريب إلى ملف""" + examples = st.session_state.terminology_data.get("training_examples", []) + + if not examples: + st.error("لا توجد بيانات تدريب للتصدير.") + return + + try: + if format == "JSON": + # تصدير إلى ملف JSON + with tempfile.NamedTemporaryFile(mode='w+', suffix='.json', encoding='utf-8', delete=False) as f: + json.dump(examples, f, ensure_ascii=False, indent=2) + json_path = f.name + + # قراءة الملف وتقديمه للتنزيل + with open(json_path, 'r', encoding='utf-8') as f: + json_data = f.read() + + st.download_button( + label="تنزيل ملف JSON", + data=json_data, + file_name="training_data.json", + mime="application/json" + ) + + # حذف الملف المؤقت + os.unlink(json_path) + + elif format == "JSONL": + # تصدير إلى ملف JSONL + jsonl_content = "" + for example in examples: + jsonl_content += json.dumps(example, ensure_ascii=False) + "\n" + + st.download_button( + label="تنزيل ملف JSONL", + data=jsonl_content, + file_name="training_data.jsonl", + mime="application/jsonl" + ) + + elif format == "CSV": + # تصدير إلى ملف CSV + with tempfile.NamedTemporaryFile(mode='w+', suffix='.csv', newline='', encoding='utf-8', delete=False) as f: + writer = csv.writer(f) + + # كتابة الترويسة + writer.writerow([ + 'النص المدخل', 'النص المتوقع', 'الفئة', 'المصطلحات', 'تاريخ الإنشاء', 'تم التوليد بواسطة' + ]) + + # كتابة البيانات + for example in examples: + writer.writerow([ + example.get('input', ''), + example.get('output', ''), + example.get('category', ''), + '|'.join(example.get('terms', [])), + example.get('created_at', ''), + example.get('generated_by', 'يدوي') + ]) + + # الحصول على مسار الملف + csv_path = f.name + + # قراءة الملف وتقديمه للتنزيل + with open(csv_path, 'r', encoding='utf-8') as f: + csv_data = f.read() + + st.download_button( + label="تنزيل ملف CSV", + data=csv_data, + file_name="training_data.csv", + mime="text/csv" + ) + + # حذف الملف المؤقت + os.unlink(csv_path) + + except Exception as e: + st.error(f"حدث خطأ أثناء تصدير البيانات: {str(e)}") + + def _import_training_data(self, uploaded_file, format): + """استيراد بيانات التدريب من ملف""" + try: + examples = [] + + if format == "JSON": + # استيراد من ملف JSON + content = uploaded_file.read().decode('utf-8') + examples = json.loads(content) + + elif format == "JSONL": + # استيراد من ملف JSONL + content = uploaded_file.read().decode('utf-8') + + for line in content.strip().split('\n'): + if line.strip(): + examples.append(json.loads(line)) + + elif format == "CSV": + # استيراد من ملف CSV + df = pd.read_csv(uploaded_file, encoding='utf-8') + + for _, row in df.iterrows(): + example = { + "input": row.get('النص المدخل', ''), + "output": row.get('النص المتوقع', ''), + "category": row.get('الفئة', 'أخرى'), + "terms": row.get('المصطلحات', '').split('|') if pd.notna(row.get('المصطلحات', '')) else [], + "created_at": row.get('تاريخ الإنشاء', datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + "generated_by": row.get('تم التوليد بواسطة', 'مستورد') + } + + examples.append(example) + + # إضافة الأمثلة المستوردة + if "training_examples" not in st.session_state.terminology_data: + st.session_state.terminology_data["training_examples"] = [] + + # فلترة الأمثلة الصحيحة + valid_examples = [] + for ex in examples: + if "input" in ex and "output" in ex: + valid_examples.append(ex) + + # إضافة الأمثلة وحفظ البيانات + if valid_examples: + st.session_state.terminology_data["training_examples"].extend(valid_examples) + self._save_terminology_data() + + st.success(f"تم استيراد {len(valid_examples)} مثال بنجاح!") + st.rerun() + else: + st.error("لم يتم العثور على أمثلة صالحة في الملف.") + + except Exception as e: + st.error(f"حدث خطأ أثناء استيراد البيانات: {str(e)}") + + def _render_model_training(self): + """عرض واجهة تدريب النموذج""" + st.markdown(""" +
+

🧠 تدريب النموذج

+

قم بتدريب نموذج الذكاء الاصطناعي على المصطلحات المتخصصة باستخدام أمثلة التدريب.

+

يمكنك اختيار النموذج الأساسي والإعدادات المناسبة لعملية التدريب.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود بيانات تدريب كافية + examples = st.session_state.terminology_data.get("training_examples", []) + if len(examples) < 10: + st.warning(f"عدد أمثلة التدريب الحالية ({len(examples)}) غير كافٍ للتدريب. يُنصح بوجود 10 أمثلة على الأقل.") + + # تبويبات تدريب النموذج + training_tabs = st.tabs(["إعداد التدريب", "نماذج سابقة", "وظائف التدريب النشطة"]) + + # تبويب إعداد التدريب + with training_tabs[0]: + self._render_training_setup() + + # تبويب النماذج السابقة + with training_tabs[1]: + self._render_previous_models() + + # تبويب وظائف التدريب النشطة + with training_tabs[2]: + self._render_active_training_jobs() + + def _render_training_setup(self): + """عرض إعدادات تدريب النموذج""" + st.markdown("### إعداد عملية التدريب") + + # التحقق من وجود مفاتيح API + if not self.api_key and not self.anthropic_api_key: + st.warning("لم يتم العثور على مفاتيح API للذكاء الاصطناعي. يرجى إضافة OPENAI_API_KEY أو ANTHROPIC_API_KEY إلى المتغيرات البيئية.") + return + + # اختيار نموذج الذكاء الاصطناعي الأساسي + provider_options = [] + if self.api_key: + provider_options.append("OpenAI") + if self.anthropic_api_key: + provider_options.append("Anthropic") + + provider = st.selectbox( + "مزود الذكاء الاصطناعي", + options=provider_options, + key="training_provider" + ) + + # الإعدادات حسب المزود + if provider == "OpenAI": + # نماذج OpenAI المتاحة للضبط + base_model = st.selectbox( + "النموذج الأساسي", + options=["gpt-3.5-turbo-0125", "gpt-4o-mini"], + key="openai_base_model" + ) + + # إعدادات التدريب + col1, col2 = st.columns(2) + + with col1: + n_epochs = st.slider("عدد الحقب (Epochs)", 1, 4, 2, key="openai_epochs") + batch_size = st.selectbox("حجم الدفعة (Batch Size)", options=[1, 2, 4, 8], index=1, key="openai_batch_size") + + with col2: + learning_rate_multiplier = st.slider("مضاعف معدل التعلم", 0.1, 2.0, 1.0, 0.1, key="openai_lr") + suffix = st.text_input("لاحقة اسم النموذج", value="arabic-contracts-expert", key="openai_suffix") + + # زر بدء التدريب + if styled_button("بدء التدريب", key="start_openai_training", type="primary", icon="🚀"): + # التحقق من وجود بيانات كافية + examples = st.session_state.terminology_data.get("training_examples", []) + if len(examples) < 10: + st.error("عدد أمثلة التدريب الحالية قليل جداً. يُفضل وجود على الأقل 10 أمثلة للتدريب.") + else: + # التأكيد على بدء التدريب + confirm = st.warning(f"سيتم بدء عملية تدريب نموذج {base_model} باستخدام {len(examples)} مثال. هل أنت متأكد؟") + if styled_button("تأكيد بدء التدريب", key="confirm_openai_training", type="success", icon="✅"): + # توجيه بيانات التدريب لصيغة OpenAI + formatted_data = self._format_training_data_for_openai(examples) + + # بدء التدريب + self._start_openai_training( + base_model=base_model, + training_data=formatted_data, + n_epochs=n_epochs, + batch_size=batch_size, + learning_rate_multiplier=learning_rate_multiplier, + suffix=suffix + ) + + elif provider == "Anthropic": + st.info("ضبط نماذج Anthropic غير متاح حالياً في واجهة البرمجة العامة. يمكنك استخدام أمثلة التدريب مع المساعد المتخصص.") + + def _format_training_data_for_openai(self, examples): + """تنسيق بيانات التدريب لواجهة برمجة OpenAI""" + formatted_examples = [] + + for example in examples: + formatted_examples.append({ + "messages": [ + {"role": "user", "content": example.get("input", "")}, + {"role": "assistant", "content": example.get("output", "")} + ] + }) + + return formatted_examples + + def _start_openai_training(self, base_model, training_data, n_epochs, batch_size, learning_rate_multiplier, suffix): + """بدء عملية تدريب نموذج OpenAI""" + try: + # تهيئة واجهة برمجة OpenAI + openai.api_key = self.api_key + + # إنشاء ملف تدريب + training_file_path = os.path.join(self.training_data_dir, f"training_data_{int(time.time())}.jsonl") + + with open(training_file_path, 'w', encoding='utf-8') as f: + for example in training_data: + f.write(json.dumps(example, ensure_ascii=False) + "\n") + + # رفع ملف التدريب إلى OpenAI + with open(training_file_path, 'rb') as f: + response = openai.files.create( + file=f, + purpose="fine-tune" + ) + + file_id = response.id + + # بدء وظيفة التدريب + response = openai.fine_tuning.jobs.create( + training_file=file_id, + model=base_model, + hyperparameters={ + "n_epochs": n_epochs, + "batch_size": batch_size, + "learning_rate_multiplier": learning_rate_multiplier + }, + suffix=suffix + ) + + job_id = response.id + + # تخزين معلومات وظيفة التدريب + training_job = { + "job_id": job_id, + "provider": "OpenAI", + "base_model": base_model, + "n_epochs": n_epochs, + "batch_size": batch_size, + "learning_rate_multiplier": learning_rate_multiplier, + "suffix": suffix, + "status": "running", + "file_id": file_id, + "file_path": training_file_path, + "examples_count": len(training_data), + "started_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "finished_at": None, + "fine_tuned_model": None + } + + # إضافة الوظيفة إلى حالة الجلسة + st.session_state.active_training_job = training_job + + # إضافة الوظيفة إلى قائمة وظائف التدريب + if "training_jobs" not in st.session_state.terminology_data: + st.session_state.terminology_data["training_jobs"] = [] + + st.session_state.terminology_data["training_jobs"].append(training_job) + + # حفظ البيانات + self._save_terminology_data() + + st.success(f"تم بدء وظيفة التدريب بنجاح! معرف الوظيفة: {job_id}") + st.info("يمكنك متابعة حالة التدريب من تبويب 'وظائف التدريب النشطة'.") + + except Exception as e: + st.error(f"حدث خطأ أثناء بدء عملية التدريب: {str(e)}") + + def _render_previous_models(self): + """عرض النماذج المدربة سابقاً""" + st.markdown("### النماذج المدربة سابقاً") + + # الحصول على النماذج المدربة + models = st.session_state.terminology_data.get("models", []) + + if not models: + st.info("لا توجد نماذج مدربة سابقاً.") + return + + # عرض النماذج + for i, model in enumerate(models): + with st.expander(f"{model.get('model_id')} - {model.get('base_model')}", expanded=i==0): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown(f"**معرف النموذج:** {model.get('model_id')}") + st.markdown(f"**النموذج الأساسي:** {model.get('base_model')}") + st.markdown(f"**عدد أمثلة التدريب:** {model.get('examples_count')}") + st.markdown(f"**تاريخ الإنشاء:** {model.get('created_at')}") + + # عرض مؤشرات الأداء إن وجدت + if "metrics" in model: + st.markdown("#### مؤشرات الأداء") + + metrics = model.get("metrics", {}) + for metric_name, metric_value in metrics.items(): + st.markdown(f"**{metric_name}:** {metric_value}") + + with col2: + # أزرار الاستخدام والحذف + if styled_button("استخدام النموذج", key=f"use_model_{i}", type="primary", icon="✅"): + st.session_state.selected_model = model.get('model_id') + st.success(f"تم اختيار النموذج {model.get('model_id')} للاستخدام.") + + if styled_button("حذف النموذج", key=f"delete_model_{i}", type="danger", icon="🗑️"): + st.session_state.model_to_delete = i + + # عرض الوصف والملاحظات + st.markdown(f"**الوصف:** {model.get('description', 'لا يوجد وصف.')}") + + # عرض النماذج المستخدمة في التدريب + if "examples_preview" in model and model["examples_preview"]: + with st.expander("عينة من أمثلة التدريب"): + for j, example in enumerate(model["examples_preview"]): + st.markdown(f"**مثال #{j+1}**") + st.markdown(f"**المدخل:** {example.get('input')}") + st.markdown(f"**المخرج:** {example.get('output')}") + st.markdown("---") + + # معالجة حذف النموذج + if "model_to_delete" in st.session_state: + if st.warning(f"هل أنت متأكد من حذف النموذج '{models[st.session_state.model_to_delete].get('model_id')}'؟"): + if styled_button("تأكيد الحذف", key="confirm_delete_model", type="danger", icon="🗑️"): + # حذف النموذج + del st.session_state.terminology_data["models"][st.session_state.model_to_delete] + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة الحذف + del st.session_state.model_to_delete + + st.success("تم حذف النموذج بنجاح!") + st.rerun() + + if styled_button("إلغاء", key="cancel_delete_model", type="secondary", icon="❌"): + del st.session_state.model_to_delete + st.rerun() + + def _render_active_training_jobs(self): + """عرض وظائف التدريب النشطة""" + st.markdown("### وظائف التدريب النشطة") + + # الحصول على وظائف التدريب + jobs = st.session_state.terminology_data.get("training_jobs", []) + + # فرز الوظائف حسب الحالة + active_jobs = [job for job in jobs if job.get("status") in ["running", "validating_files", "queued"]] + completed_jobs = [job for job in jobs if job.get("status") == "succeeded"] + failed_jobs = [job for job in jobs if job.get("status") in ["failed", "cancelled"]] + + # زر تحديث حالة الوظائف + if styled_button("تحديث حالة الوظائف", key="refresh_jobs", type="primary", icon="🔄"): + self._refresh_training_jobs_status() + + # عرض الوظائف النشطة + if active_jobs: + st.markdown("#### الوظائف النشطة") + + for i, job in enumerate(active_jobs): + with st.expander(f"{job.get('job_id')} - {job.get('base_model')}", expanded=True): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown(f"**معرف الوظيفة:** {job.get('job_id')}") + st.markdown(f"**النموذج الأساسي:** {job.get('base_model')}") + st.markdown(f"**الحالة:** {job.get('status')}") + st.markdown(f"**تاريخ البدء:** {job.get('started_at')}") + + # عرض تقدم التدريب إن وجد + if "progress" in job: + progress = job.get("progress", 0) + st.progress(progress) + st.markdown(f"**التقدم:** {progress*100:.1f}%") + + with col2: + # زر إلغاء الوظيفة + if styled_button("إلغاء الوظيفة", key=f"cancel_job_{i}", type="danger", icon="⛔"): + st.session_state.job_to_cancel = i + + # عرض معلومات إضافية + st.markdown(f"**عدد الحقب:** {job.get('n_epochs')}") + st.markdown(f"**حجم الدفعة:** {job.get('batch_size')}") + st.markdown(f"**مضاعف معدل التعلم:** {job.get('learning_rate_multiplier')}") + else: + st.info("لا توجد وظائف تدريب نشطة حالياً.") + + # عرض الوظائف المكتملة + if completed_jobs: + st.markdown("#### الوظائف المكتملة") + + for i, job in enumerate(completed_jobs): + with st.expander(f"{job.get('job_id')} - {job.get('base_model')}", expanded=False): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown(f"**معرف الوظيفة:** {job.get('job_id')}") + st.markdown(f"**النموذج الأساسي:** {job.get('base_model')}") + st.markdown(f"**تاريخ البدء:** {job.get('started_at')}") + st.markdown(f"**تاريخ الانتهاء:** {job.get('finished_at')}") + st.markdown(f"**النموذج المدرب:** {job.get('fine_tuned_model')}") + + with col2: + # زر استخدام النموذج المدرب + if styled_button("استخدام النموذج", key=f"use_trained_model_{i}", type="primary", icon="✅"): + st.session_state.selected_model = job.get('fine_tuned_model') + st.success(f"تم اختيار النموذج {job.get('fine_tuned_model')} للاستخدام.") + + # زر حذف الوظيفة + if styled_button("حذف الوظيفة", key=f"delete_completed_job_{i}", type="danger", icon="🗑️"): + st.session_state.completed_job_to_delete = len(active_jobs) + i + + # عرض الوظائف الفاشلة + if failed_jobs: + st.markdown("#### الوظائف الفاشلة") + + for i, job in enumerate(failed_jobs): + with st.expander(f"{job.get('job_id')} - {job.get('base_model')}", expanded=False): + col1, col2 = st.columns([3, 1]) + + with col1: + st.markdown(f"**معرف الوظيفة:** {job.get('job_id')}") + st.markdown(f"**النموذج الأساسي:** {job.get('base_model')}") + st.markdown(f"**الحالة:** {job.get('status')}") + st.markdown(f"**تاريخ البدء:** {job.get('started_at')}") + + # عرض سبب الفشل إن وجد + if "error" in job: + st.error(f"سبب الفشل: {job.get('error')}") + + with col2: + # زر حذف الوظيفة + if styled_button("حذف الوظيفة", key=f"delete_failed_job_{i}", type="danger", icon="🗑️"): + st.session_state.failed_job_to_delete = len(active_jobs) + len(completed_jobs) + i + + # معالجة إلغاء الوظيفة + if "job_to_cancel" in st.session_state: + if st.warning(f"هل أنت متأكد من إلغاء وظيفة التدريب '{active_jobs[st.session_state.job_to_cancel].get('job_id')}'؟"): + if styled_button("تأكيد الإلغاء", key="confirm_cancel_job", type="danger", icon="🗑️"): + # إلغاء الوظيفة + self._cancel_training_job(active_jobs[st.session_state.job_to_cancel]) + + # إعادة ضبط حالة الإلغاء + del st.session_state.job_to_cancel + + st.success("تم إلغاء وظيفة التدريب بنجاح!") + st.rerun() + + if styled_button("إلغاء", key="cancel_job_cancellation", type="secondary", icon="❌"): + del st.session_state.job_to_cancel + st.rerun() + + # معالجة حذف الوظائف المكتملة + if "completed_job_to_delete" in st.session_state: + idx = st.session_state.completed_job_to_delete + if 0 <= idx < len(jobs): + if st.warning(f"هل أنت متأكد من حذف وظيفة التدريب '{jobs[idx].get('job_id')}'؟"): + if styled_button("تأكيد الحذف", key="confirm_delete_completed_job", type="danger", icon="🗑️"): + # حذف الوظيفة + del st.session_state.terminology_data["training_jobs"][idx] + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة الحذف + del st.session_state.completed_job_to_delete + + st.success("تم حذف وظيفة التدريب بنجاح!") + st.rerun() + + if styled_button("إلغاء", key="cancel_completed_job_deletion", type="secondary", icon="❌"): + del st.session_state.completed_job_to_delete + st.rerun() + + # معالجة حذف الوظائف الفاشلة + if "failed_job_to_delete" in st.session_state: + idx = st.session_state.failed_job_to_delete + if 0 <= idx < len(jobs): + if st.warning(f"هل أنت متأكد من حذف وظيفة التدريب '{jobs[idx].get('job_id')}'؟"): + if styled_button("تأكيد الحذف", key="confirm_delete_failed_job", type="danger", icon="🗑️"): + # حذف الوظيفة + del st.session_state.terminology_data["training_jobs"][idx] + + # حفظ البيانات + self._save_terminology_data() + + # إعادة ضبط حالة الحذف + del st.session_state.failed_job_to_delete + + st.success("تم حذف وظيفة التدريب بنجاح!") + st.rerun() + + if styled_button("إلغاء", key="cancel_failed_job_deletion", type="secondary", icon="❌"): + del st.session_state.failed_job_to_delete + st.rerun() + + def _refresh_training_jobs_status(self): + """تحديث حالة وظائف التدريب""" + jobs = st.session_state.terminology_data.get("training_jobs", []) + + # فلترة الوظائف النشطة + active_jobs = [job for job in jobs if job.get("status") in ["running", "validating_files", "queued"]] + + if not active_jobs: + st.info("لا توجد وظائف تدريب نشطة للتحديث.") + return + + try: + # تحديث حالة كل وظيفة نشطة + for job in active_jobs: + if job.get("provider") == "OpenAI" and self.api_key: + # تحديث حالة وظيفة OpenAI + job_id = job.get("job_id") + + # استعلام عن حالة الوظيفة + openai.api_key = self.api_key + response = openai.fine_tuning.jobs.retrieve(job_id) + + # تحديث حالة الوظيفة + job["status"] = response.status + + # تحديث التقدم إن وجد + if hasattr(response, "progress") and response.progress is not None: + job["progress"] = response.progress + + # إذا اكتملت الوظيفة، تحديث معلومات النموذج المدرب + if response.status == "succeeded": + job["finished_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + job["fine_tuned_model"] = response.fine_tuned_model + + # إضافة النموذج المدرب إلى قائمة النماذج + self._add_trained_model(job, response) + + # إذا فشلت الوظيفة، تسجيل سبب الفشل + elif response.status == "failed" and hasattr(response, "error"): + job["error"] = response.error + + # حفظ البيانات + self._save_terminology_data() + + st.success("تم تحديث حالة وظائف التدريب بنجاح!") + + except Exception as e: + st.error(f"حدث خطأ أثناء تحديث حالة وظائف التدريب: {str(e)}") + + def _cancel_training_job(self, job): + """إلغاء وظيفة تدريب""" + try: + if job.get("provider") == "OpenAI" and self.api_key: + # إلغاء وظيفة OpenAI + job_id = job.get("job_id") + + # استدعاء واجهة برمجة OpenAI + openai.api_key = self.api_key + openai.fine_tuning.jobs.cancel(job_id) + + # تحديث حالة الوظيفة + job["status"] = "cancelled" + job["finished_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # حفظ البيانات + self._save_terminology_data() + + return True + + return False + + except Exception as e: + st.error(f"حدث خطأ أثناء إلغاء وظيفة التدريب: {str(e)}") + return False + + def _add_trained_model(self, job, response): + """إضافة النموذج المدرب إلى قائمة النماذج""" + # إنشاء كائن النموذج + model = { + "model_id": response.fine_tuned_model, + "base_model": job.get("base_model"), + "provider": job.get("provider"), + "training_job_id": job.get("job_id"), + "description": f"النموذج المدرب على المصطلحات المتخصصة في {job.get('suffix')}", + "examples_count": job.get("examples_count"), + "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "metrics": {} + } + + # إضافة مؤشرات الأداء إن وجدت + if hasattr(response, "result_files") and response.result_files: + # تنزيل ملف النتائج وقراءة مؤشرات الأداء + pass + + # إضافة عينة من أمثلة التدريب + examples = st.session_state.terminology_data.get("training_examples", []) + if examples: + # أخذ 5 أمثلة كعينة + sample_examples = random.sample(examples, min(5, len(examples))) + model["examples_preview"] = sample_examples + + # إضافة النموذج إلى قائمة النماذج + if "models" not in st.session_state.terminology_data: + st.session_state.terminology_data["models"] = [] + + st.session_state.terminology_data["models"].append(model) + + def _render_model_testing(self): + """عرض واجهة اختبار النموذج""" + st.markdown(""" +
+

🧪 اختبار النموذج

+

اختبر نموذج الذكاء الاصطناعي المدرب على المصطلحات المتخصصة.

+

يمكنك تجريب أسئلة مختلفة ومقارنة النتائج مع النماذج الأخرى.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود مفاتيح API + if not self.api_key and not self.anthropic_api_key: + st.warning("لم يتم العثور على مفاتيح API للذكاء الاصطناعي. يرجى إضافة OPENAI_API_KEY أو ANTHROPIC_API_KEY إلى المتغيرات البيئية.") + return + + # الحصول على قائمة النماذج المتاحة + available_models = [] + + # OpenAI + if self.api_key: + available_models.extend(["gpt-4o", "gpt-3.5-turbo"]) + + # إضافة النماذج المدربة إن وجدت + for model in st.session_state.terminology_data.get("models", []): + if model.get("provider") == "OpenAI": + available_models.append(model.get("model_id")) + + # Anthropic + if self.anthropic_api_key: + available_models.extend(["claude-3-7-sonnet-20250219"]) + + # اختيار النموذج + selected_model = st.selectbox( + "اختر النموذج", + options=available_models, + key="test_model" + ) + + # إدخال النص للاختبار + test_input = st.text_area( + "أدخل نص الاختبار", + value="ما هو مفهوم المحتوى المحلي في المشاريع الحكومية وكيف يتم حسابه؟", + height=150, + key="test_input" + ) + + # خيارات متقدمة + with st.expander("خيارات متقدمة"): + temperature = st.slider("درجة الإبداعية (Temperature)", 0.0, 1.0, 0.7, 0.1, key="test_temperature") + max_tokens = st.slider("الحد الأقصى للرموز (Max Tokens)", 100, 2000, 500, 100, key="test_max_tokens") + + # تحميل المصطلحات والتعريفات + terms_with_definitions = {} + for term in st.session_state.terminology_data.get("terms", []): + terms_with_definitions[term.get("term")] = term.get("definition") + + # زر إجراء الاختبار + if styled_button("إجراء الاختبار", key="run_test", type="primary", icon="🧪"): + if not test_input: + st.error("يرجى إدخال نص للاختبار.") + else: + # عرض شريط التقدم + with st.spinner("جاري معالجة النص..."): + # إجراء الاختبار + response = self._test_model( + model=selected_model, + input_text=test_input, + temperature=temperature, + max_tokens=max_tokens, + terms_with_definitions=terms_with_definitions + ) + + # عرض النتيجة + st.markdown("### نتيجة الاختبار") + st.markdown(response) + + # تحليل الاستجابة لاكتشاف المصطلحات المستخدمة + used_terms = [] + for term in terms_with_definitions: + if term in response: + used_terms.append(term) + + if used_terms: + st.markdown("### المصطلحات المكتشفة في الاستجابة") + for term in used_terms: + st.markdown(f"- **{term}**: {terms_with_definitions[term]}") + + def _test_model(self, model, input_text, temperature, max_tokens, terms_with_definitions): + """اختبار النموذج""" + try: + # تجهيز المحتوى النظامي + system_prompt = "أنت مساعد متخصص في عقود المقاولات والمناقصات باللغة العربية. قم بالإجابة بدقة على الأسئلة والطلبات مع مراعاة المصطلحات الفنية المتخصصة." + + # إضافة المصطلحات إلى المحتوى النظامي + if terms_with_definitions: + system_prompt += "\n\nفيما يلي قائمة بالمصطلحات المتخصصة وتعريفاتها:\n\n" + for term, definition in terms_with_definitions.items(): + system_prompt += f"- {term}: {definition}\n" + + # OpenAI + if "gpt" in model or any(model_data.get("model_id") == model for model_data in st.session_state.terminology_data.get("models", [])): + # استخدام OpenAI API + openai.api_key = self.api_key + + response = openai.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": input_text} + ], + temperature=temperature, + max_tokens=max_tokens + ) + + return response.choices[0].message.content + + # Anthropic + elif "claude" in model and self.anthropic_api_key: + # استخدام Anthropic API + from anthropic import Anthropic + + anthropic_client = Anthropic(api_key=self.anthropic_api_key) + + response = anthropic_client.messages.create( + model=model, + max_tokens=max_tokens, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": input_text} + ], + temperature=temperature + ) + + return response.content[0].text + + else: + return "النموذج المختار غير مدعوم حالياً." + + except Exception as e: + return f"حدث خطأ أثناء اختبار النموذج: {str(e)}" + + def _render_specialized_assistant(self): + """عرض واجهة المساعد المتخصص""" + st.markdown(""" +
+

🤖 المساعد المتخصص

+

استخدم المساعد الذكي المتخصص في المصطلحات التعاقدية الهندسية.

+

يمكنك طرح أسئلة حول المصطلحات وتفسيراتها واستخداماتها.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود مفاتيح API + if not self.api_key and not self.anthropic_api_key: + st.warning("لم يتم العثور على مفاتيح API للذكاء الاصطناعي. يرجى إضافة OPENAI_API_KEY أو ANTHROPIC_API_KEY إلى المتغيرات البيئية.") + return + + # الحصول على قائمة النماذج المتاحة + available_models = [] + + # OpenAI + if self.api_key: + available_models.extend(["gpt-4o", "gpt-3.5-turbo"]) + + # إضافة النماذج المدربة إن وجدت + for model in st.session_state.terminology_data.get("models", []): + if model.get("provider") == "OpenAI": + available_models.append(model.get("model_id")) + + # Anthropic + if self.anthropic_api_key: + available_models.extend(["claude-3-7-sonnet-20250219"]) + + # تهيئة حالة المحادثة + if "chat_history" not in st.session_state: + st.session_state.chat_history = [] + + if "assistant_model" not in st.session_state: + st.session_state.assistant_model = available_models[0] if available_models else "" + + # اختيار النموذج + selected_model = st.selectbox( + "اختر نموذج المساعد", + options=available_models, + index=available_models.index(st.session_state.assistant_model) if st.session_state.assistant_model in available_models else 0, + key="assistant_model_selector" + ) + + # تحديث النموذج المختار + if selected_model != st.session_state.assistant_model: + st.session_state.assistant_model = selected_model + st.rerun() + + # عرض المحادثة + st.markdown("### المحادثة") + + for message in st.session_state.chat_history: + if message["role"] == "user": + st.markdown(f""" +
+ أنت: {message["content"]} +
+ """, unsafe_allow_html=True) + else: + st.markdown(f""" +
+ المساعد: {message["content"]} +
+ """, unsafe_allow_html=True) + + # إدخال رسالة جديدة + user_input = st.text_area("اكتب رسالتك هنا", key="assistant_input", height=100) + + # أزرار التحكم + col1, col2, col3 = st.columns([1, 1, 1]) + + with col1: + if styled_button("إرسال", key="send_message", type="primary", icon="✉️"): + if not user_input: + st.error("يرجى كتابة رسالة للإرسال.") + else: + # إضافة رسالة المستخدم إلى المحادثة + st.session_state.chat_history.append({ + "role": "user", + "content": user_input + }) + + # الحصول على الرد من النموذج + with st.spinner("المساعد يفكر..."): + # تحميل المصطلحات والتعريفات + terms_with_definitions = {} + for term in st.session_state.terminology_data.get("terms", []): + terms_with_definitions[term.get("term")] = term.get("definition") + + # الحصول على رد المساعد + response = self._get_assistant_response( + model=st.session_state.assistant_model, + chat_history=st.session_state.chat_history, + terms_with_definitions=terms_with_definitions + ) + + # إضافة رد المساعد إلى المحادثة + st.session_state.chat_history.append({ + "role": "assistant", + "content": response + }) + + # إعادة تشغيل لتحديث واجهة المستخدم + st.rerun() + + with col2: + if styled_button("مسح المحادثة", key="clear_chat", type="danger", icon="🗑️"): + st.session_state.chat_history = [] + st.rerun() + + with col3: + if styled_button("اقتراح أسئلة", key="suggest_questions", type="secondary", icon="💡"): + # عرض أسئلة مقترحة + st.markdown("### أسئلة مقترحة") + + # الحصول على المصطلحات المتاحة + terms = [term.get("term") for term in st.session_state.terminology_data.get("terms", [])] + + # إنشاء أسئلة مقترحة + suggested_questions = [ + f"ما هو تعريف مصطلح {term}؟" for term in random.sample(terms, min(3, len(terms))) + ] + + suggested_questions.extend([ + "كيف يتم حساب المحتوى المحلي في مشاريع البنية التحتية؟", + "ما هي أهم البنود التي يجب الانتباه لها في العقود الهندسية؟", + "ما الفرق بين الضمان الابتدائي والضمان النهائي؟", + "كيف يمكن تقييم المخاطر في مشاريع البناء؟" + ]) + + # عرض الأسئلة المقترحة + for i, question in enumerate(suggested_questions): + if styled_button(question, key=f"suggested_q_{i}", type="info", icon="❓"): + # إضافة السؤال المقترح إلى المحادثة + st.session_state.chat_history.append({ + "role": "user", + "content": question + }) + + # الحصول على الرد من النموذج + with st.spinner("المساعد يفكر..."): + # تحميل المصطلحات والتعريفات + terms_with_definitions = {} + for term in st.session_state.terminology_data.get("terms", []): + terms_with_definitions[term.get("term")] = term.get("definition") + + # الحصول على رد المساعد + response = self._get_assistant_response( + model=st.session_state.assistant_model, + chat_history=st.session_state.chat_history, + terms_with_definitions=terms_with_definitions + ) + + # إضافة رد المساعد إلى المحادثة + st.session_state.chat_history.append({ + "role": "assistant", + "content": response + }) + + # إعادة تشغيل لتحديث واجهة المستخدم + st.rerun() + + def _get_assistant_response(self, model, chat_history, terms_with_definitions): + """الحصول على رد المساعد المتخصص""" + try: + # تجهيز المحتوى النظامي + system_prompt = """أنت مساعد متخصص في عقود المقاولات والمناقصات باللغة العربية. + مهمتك هي تقديم معلومات دقيقة ومفصلة حول المصطلحات التعاقدية والهندسية المتخصصة. + قدم شرحاً واضحاً ومفهوماً للمصطلحات، مع أمثلة عملية عند الإمكان. + استخدم لغة مهنية ودقيقة، مع مراعاة السياق الهندسي والقانوني للمصطلحات.""" + + # إضافة المصطلحات إلى المحتوى النظامي + if terms_with_definitions: + system_prompt += "\n\nفيما يلي قائمة بالمصطلحات المتخصصة وتعريفاتها التي يجب عليك استخدامها في إجاباتك:\n\n" + for term, definition in terms_with_definitions.items(): + system_prompt += f"- {term}: {definition}\n" + + # تحويل محادثة streamlit إلى صيغة مناسبة للـ API + messages = [{"role": "system", "content": system_prompt}] + + for msg in chat_history: + messages.append({"role": msg["role"], "content": msg["content"]}) + + # OpenAI + if "gpt" in model or any(model_data.get("model_id") == model for model_data in st.session_state.terminology_data.get("models", [])): + # استخدام OpenAI API + openai.api_key = self.api_key + + response = openai.chat.completions.create( + model=model, + messages=messages, + temperature=0.7 + ) + + return response.choices[0].message.content + + # Anthropic + elif "claude" in model and self.anthropic_api_key: + # استخدام Anthropic API + from anthropic import Anthropic + + # تعديل الرسائل لتتناسب مع صيغة Anthropic + anthropic_messages = [] + for msg in messages[1:]: # تخطي رسالة النظام + anthropic_messages.append({"role": msg["role"], "content": msg["content"]}) + + anthropic_client = Anthropic(api_key=self.anthropic_api_key) + + response = anthropic_client.messages.create( + model=model, + max_tokens=2000, + system=system_prompt, + messages=anthropic_messages, + temperature=0.7 + ) + + return response.content[0].text + + else: + return "النموذج المختار غير مدعوم حالياً." + + except Exception as e: + return f"عذراً، حدث خطأ أثناء معالجة طلبك: {str(e)}" + + def _save_terminology_data(self): + """حفظ بيانات المصطلحات""" + try: + # التأكد من وجود المجلد + os.makedirs(os.path.dirname(self.terminology_file), exist_ok=True) + + # حفظ البيانات + with open(self.terminology_file, 'w', encoding='utf-8') as f: + json.dump(st.session_state.terminology_data, f, ensure_ascii=False, indent=2) + + except Exception as e: + st.error(f"حدث خطأ أثناء حفظ البيانات: {str(e)}") + + +# تشغيل النموذج بشكل مستقل +def main(): + """تشغيل وحدة تخصيص وضبط نماذج الذكاء الاصطناعي بشكل مستقل""" + # تهيئة الواجهة + st.set_page_config( + page_title="تخصيص وضبط نماذج الذكاء الاصطناعي | WAHBi AI", + page_icon="🧠", + layout="wide", + initial_sidebar_state="expanded", + menu_items={ + 'Get Help': 'mailto:support@wahbi-ai.com', + 'Report a bug': 'mailto:support@wahbi-ai.com', + 'About': 'وحدة تخصيص وضبط نماذج الذكاء الاصطناعي للمصطلحات التعاقدية المتخصصة - جزء من نظام WAHBi AI لتحليل المناقصات' + } + ) + + # تهيئة وحدة الضبط + model_finetuning = ModelFinetuning() + + # عرض واجهة الوحدة + model_finetuning.render() + +# تشغيل النموذج عند استدعاء الملف مباشرة +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/document_analysis/document_analysis_app.py b/modules/document_analysis/document_analysis_app.py new file mode 100644 index 0000000000000000000000000000000000000000..5712d48d76796357fcbaa732a82472ef8a911c13 --- /dev/null +++ b/modules/document_analysis/document_analysis_app.py @@ -0,0 +1,1114 @@ +# -*- coding: utf-8 -*- +""" +وحدة تطبيق تحليل المستندات + +هذا الملف يحتوي على الفئة الرئيسية لتطبيق تحليل المستندات. +""" + +# استيراد المكتبات القياسية +import os +import sys +import logging +import base64 +import json +import time +from io import BytesIO +from pathlib import Path +from urllib.parse import urlparse +from tempfile import NamedTemporaryFile + +# استيراد مكتبة Streamlit +import streamlit as st + +# استيراد المكتبات الإضافية +import requests +from PIL import Image + +try: + # استيراد مكتبات Docling و MLX VLM + from docling_core.types.doc import ImageRefMode + from docling_core.types.doc.document import DocTagsDocument, DoclingDocument + from mlx_vlm import load, generate + from mlx_vlm.prompt_utils import apply_chat_template + from mlx_vlm.utils import load_config, stream_generate + docling_available = True +except ImportError: + docling_available = False + logging.warning("لم يتم العثور على مكتبات Docling و MLX VLM. بعض الوظائف قد لا تعمل.") + +try: + # استيراد مكتبة pdf2image للتعامل مع ملفات PDF + from pdf2image import convert_from_path + pdf_conversion_available = True +except ImportError: + pdf_conversion_available = False + logging.warning("لم يتم العثور على مكتبة pdf2image. لن يمكن تحويل ملفات PDF إلى صور.") + +# إعداد المسار للوحدات النمطية +current_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.dirname(os.path.dirname(current_dir)) +if parent_dir not in sys.path: + sys.path.append(parent_dir) + +# استيراد الخدمات باستخدام المسار النسبي +try: + # الطريقة 1: استيراد نسبي مباشر + from .services.text_extractor import TextExtractor + from .services.item_extractor import ItemExtractor + from .services.document_parser import DocumentParser +except ImportError: + try: + # الطريقة 2: استيراد مطلق + from modules.document_analysis.services.text_extractor import TextExtractor + from modules.document_analysis.services.item_extractor import ItemExtractor + from modules.document_analysis.services.document_parser import DocumentParser + except ImportError: + # الطريقة 3: تعريف الفئات مباشرة كحل مؤقت + logging.warning("لا يمكن استيراد خدمات تحليل المستندات. استخدام التعريفات المؤقتة.") + + class TextExtractor: + def __init__(self, config=None): + self.config = config or {} + + def extract_from_pdf(self, file_path): + return "نص مستخرج مؤقت من PDF" + + def extract_from_docx(self, file_path): + return "نص مستخرج مؤقت من DOCX" + + def extract_from_image(self, file_path): + return "نص مستخرج مؤقت من صورة" + + def extract(self, file_path): + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext == '.pdf': + return self.extract_from_pdf(file_path) + elif ext in ('.doc', '.docx'): + return self.extract_from_docx(file_path) + elif ext in ('.jpg', '.jpeg', '.png'): + return self.extract_from_image(file_path) + else: + return "نوع ملف غير مدعوم" + + class ItemExtractor: + def __init__(self, config=None): + self.config = config or {} + + def extract_tables(self, document): + return [{"عنوان": "جدول مؤقت", "بيانات": []}] + + def extract(self, file_path): + return [ + {"بند": "بند مؤقت 1", "قيمة": 1000}, + {"بند": "بند مؤقت 2", "قيمة": 2000}, + {"بند": "بند مؤقت 3", "قيمة": 3000} + ] + + class DocumentParser: + def __init__(self, config=None): + self.config = config or {} + + def parse_document(self, file_path): + return {"نوع": "مستند مؤقت", "محتوى": "محتوى مؤقت"} + + def parse(self, file_path): + return { + "نوع المستند": "مستند مؤقت", + "عدد الصفحات": 5, + "تاريخ التحليل": "2025-03-24", + "درجة الثقة": "80%", + "ملاحظات": "تحليل مؤقت للمستند" + } + + +class DoclingAnalyzer: + """ + فئة لتحليل المستندات باستخدام نماذج Docling و MLX VLM + """ + def __init__(self): + self.model = None + self.processor = None + self.config = None + self.docling_available = False + + try: + # تحميل النموذج + import os + from mlx_vlm import load, generate + from mlx_vlm.utils import load_config + + model_path = "ds4sd/SmolDocling-256M-preview-mlx-bf16" + self.model, self.processor = load(model_path) + self.config = load_config(model_path) + self.docling_available = True + except Exception as e: + print(f"خطأ في تحميل نموذج Docling: {str(e)}") + self.docling_available = False + + def is_available(self): + """التحقق من توفر نماذج Docling""" + return self.docling_available and self.model is not None + + def analyze_image(self, image_path=None, image_url=None, image_bytes=None, prompt="Convert this page to docling."): + """ + تحليل صورة باستخدام نموذج Docling + + المعلمات: + image_path (str): مسار الصورة المحلية (اختياري) + image_url (str): رابط الصورة (اختياري) + image_bytes (bytes): بيانات الصورة (اختياري) + prompt (str): التوجيه للنموذج + + العوائد: + dict: نتائج التحليل متضمنة النص والعلامات والمستند + """ + if not self.is_available(): + return { + "error": "Docling غير متوفر. يرجى تثبيت المكتبات المطلوبة." + } + + try: + from io import BytesIO + from pathlib import Path + from urllib.parse import urlparse + import requests + from PIL import Image + from docling_core.types.doc import ImageRefMode + from docling_core.types.doc.document import DocTagsDocument, DoclingDocument + from mlx_vlm.prompt_utils import apply_chat_template + from mlx_vlm.utils import stream_generate, load_image + + # تحميل الصورة + pil_image = None + image_source = None + + if image_url: + try: + response = requests.get(image_url, stream=True, timeout=10) + response.raise_for_status() + pil_image = Image.open(BytesIO(response.content)) + image_source = image_url + except Exception as e: + return {"error": f"فشل في تحميل الصورة من الرابط: {str(e)}"} + elif image_path: + try: + # التأكد من وجود الملف + if not Path(image_path).exists(): + return {"error": f"ملف الصورة غير موجود: {image_path}"} + pil_image = Image.open(image_path) + image_source = image_path + except Exception as e: + return {"error": f"فشل في فتح ملف الصورة: {str(e)}"} + elif image_bytes: + try: + pil_image = Image.open(BytesIO(image_bytes)) + # حفظ الصورة مؤقتا للتحليل + temp_path = "/tmp/temp_image.jpg" + pil_image.save(temp_path) + image_source = temp_path + except Exception as e: + return {"error": f"فشل في معالجة بيانات الصورة: {str(e)}"} + else: + return {"error": "يجب توفير مصدر للصورة (مسار، رابط، أو بيانات)"} + + # تطبيق قالب المحادثة + formatted_prompt = apply_chat_template(self.processor, self.config, prompt, num_images=1) + + # إنشاء النتيجة + output = "" + + # تمرير مسار الصورة أو عنوان URL الفعلي + try: + for token in stream_generate( + self.model, self.processor, formatted_prompt, [image_source], + max_tokens=4096, verbose=False + ): + output += token.text + if "" in token.text: + break + except Exception as e: + return {"error": f"فشل في تحليل الصورة: {str(e)}"} + + # إنشاء مستند Docling + try: + doctags_doc = DocTagsDocument.from_doctags_and_image_pairs([output], [pil_image]) + doc = DoclingDocument(name="AnalyzedDocument") + doc.load_from_doctags(doctags_doc) + + # إرجاع النتائج + return { + "doctags": output, + "markdown": doc.export_to_markdown(), + "document": doc, + "image": pil_image + } + except Exception as e: + return {"error": f"فشل في إنشاء مستند Docling: {str(e)}"} + + except Exception as e: + return {"error": f"حدث خطأ غير متوقع: {str(e)}"} + + def export_to_html(self, doc, output_path="./output.html", show_in_browser=False): + """ + تصدير المستند إلى HTML + + المعلمات: + doc (DoclingDocument): مستند Docling + output_path (str): مسار ملف الإخراج + show_in_browser (bool): عرض الملف في المتصفح + + العوائد: + str: مسار ملف HTML المولد + """ + if not self.is_available(): + return None + + try: + from pathlib import Path + from docling_core.types.doc import ImageRefMode + + # إنشاء مسار الإخراج + out_path = Path(output_path) + # التأكد من وجود المجلد + out_path.parent.mkdir(exist_ok=True, parents=True) + + doc.save_as_html(out_path, image_mode=ImageRefMode.EMBEDDED) + + # فتح في المتصفح إذا تم طلب ذلك + if show_in_browser: + import webbrowser + webbrowser.open(f"file:///{str(out_path.resolve())}") + + return str(out_path) + except Exception as e: + print(f"خطأ في تصدير المستند إلى HTML: {str(e)}") + return None + + +class ClaudeAnalyzer: + """ + فئة لتحليل المستندات باستخدام Claude.ai API + """ + def __init__(self): + """تهيئة محلل Claude""" + self.api_url = "https://api.anthropic.com/v1/messages" + + def get_api_key(self): + """الحصول على مفتاح API من متغيرات البيئة""" + api_key = os.environ.get("anthropic") + if not api_key: + raise ValueError("مفتاح API لـ Claude غير موجود في متغيرات البيئة") + return api_key + + def analyze_document(self, file_path, model_name="claude-3-7-sonnet", prompt=None): + """ + تحليل مستند باستخدام Claude AI + + المعلمات: + file_path: مسار الملف المراد تحليله + model_name: اسم نموذج Claude المراد استخدامه + prompt: التوجيه المخصص للتحليل (اختياري) + + العوائد: + dict: نتائج التحليل + """ + try: + # الحصول على مفتاح API + api_key = self.get_api_key() + + # تحديد التوجيه المناسب إذا لم يتم توفيره + if prompt is None: + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext == '.pdf': + prompt = "قم بتحليل هذه الصورة المستخرجة من مستند PDF واستخراج المعلومات الرئيسية مثل العناوين، الفقرات، الجداول، والنقاط المهمة." + elif ext in ('.doc', '.docx'): + prompt = "قم بتحليل هذه الصورة المستخرجة من مستند Word واستخراج المعلومات الرئيسية والخلاصة." + elif ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp'): + prompt = "قم بوصف وتحليل محتوى هذه الصورة بالتفصيل، مع ذكر العناصر المهمة والنصوص والبيانات الموجودة فيها." + else: + prompt = "قم بتحليل محتوى هذا الملف واستخراج المعلومات المفيدة منه." + + # التحقق من نوع الملف وتحويله إذا لزم الأمر + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + processed_file_path = file_path + temp_files = [] # قائمة للملفات المؤقتة لحذفها لاحقاً + + # للملفات غير المدعومة مباشرة (مثل PDF) + if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp'): + # إذا كان الملف PDF، حاول تحويله إلى صورة + if ext == '.pdf': + if not pdf_conversion_available: + return {"error": "لا يمكن تحويل ملف PDF إلى صورة. يرجى تثبيت مكتبة pdf2image."} + + try: + # تحويل الصفحة الأولى فقط + images = convert_from_path(file_path, first_page=1, last_page=1) + if images: + # حفظ الصورة بشكل مؤقت + temp_image_path = "/tmp/temp_pdf_image.jpg" + images[0].save(temp_image_path, 'JPEG') + processed_file_path = temp_image_path # استخدام مسار الصورة الجديد + temp_files.append(temp_image_path) + else: + return {"error": "فشل في تحويل ملف PDF إلى صورة"} + except Exception as e: + return {"error": f"فشل في تحويل ملف PDF إلى صورة: {str(e)}"} + else: + return {"error": f"نوع الملف {ext} غير مدعوم. Claude API يدعم فقط الصور (JPEG, PNG, GIF, WebP) أو PDF (يتم تحويله تلقائياً)."} + + # ضغط الصورة إذا كان حجمها كبيراً + try: + img = Image.open(processed_file_path) + + # تحقق من حجم الصورة وضغطها إذا كانت كبيرة + img_width, img_height = img.size + if img_width > 1500 or img_height > 1500: + # تحويل الصورة إلى حجم أصغر (1500×1500 بكسل كحد أقصى) + img.thumbnail((1500, 1500)) + + # حفظ الصورة المضغوطة في ملف مؤقت + compressed_image_path = "/tmp/compressed_image.jpg" + img.save(compressed_image_path, format="JPEG", quality=85) + + # إضافة الملف المؤقت إلى القائمة + if processed_file_path not in temp_files: + temp_files.append(compressed_image_path) + + processed_file_path = compressed_image_path + except Exception as e: + logging.warning(f"فشل في ضغط الصورة: {str(e)}. سيتم استخدام الصورة الأصلية.") + + # قراءة محتوى الملف المعالج + with open(processed_file_path, 'rb') as f: + file_content = f.read() + + # التحقق من حجم الملف (يجب أن يكون أقل من 20 ميجابايت) + file_size_mb = len(file_content) / (1024 * 1024) + if file_size_mb > 20: + # محاولة ضغط الصورة أكثر إذا كان حجمها أكبر من 20 ميجابايت + try: + img = Image.open(processed_file_path) + + # ضغط أكبر - حجم أصغر وجودة أقل + compressed_image_path = "/tmp/extra_compressed_image.jpg" + img.thumbnail((1000, 1000)) + img.save(compressed_image_path, format="JPEG", quality=70) + + # إضافة الملف المؤقت إلى القائمة + temp_files.append(compressed_image_path) + processed_file_path = compressed_image_path + + # قراءة الملف المضغوط + with open(processed_file_path, 'rb') as f: + file_content = f.read() + + # التحقق من الحجم مرة أخرى + file_size_mb = len(file_content) / (1024 * 1024) + if file_size_mb > 20: + # لا يزال الحجم كبيراً + for temp_file in temp_files: + try: + os.unlink(temp_file) + except: + pass + return {"error": f"حجم الملف كبير جدًا ({file_size_mb:.2f} ميجابايت) حتى بعد الضغط. يجب أن يكون أقل من 20 ميجابايت."} + except Exception as e: + for temp_file in temp_files: + try: + os.unlink(temp_file) + except: + pass + return {"error": f"الملف كبير جدًا ({file_size_mb:.2f} ميجابايت) ولا يمكن ضغطه. يجب أن يكون أقل من 20 ميجابايت."} + + # تحديد نوع الملف المعالج (بعد التحويل إذا تم) + file_type = self._get_file_type(processed_file_path) + + # تحويل المحتوى إلى Base64 + file_base64 = base64.b64encode(file_content).decode('utf-8') + + # إعداد البيانات للطلب + headers = { + "Content-Type": "application/json", + "x-api-key": api_key, + "anthropic-version": "2023-06-01" + } + + # التحقق من اسم النموذج وتصحيحه إذا لزم الأمر + valid_models = { + "claude-3-7-sonnet": "claude-3-7-sonnet-20250219", + "claude-3-5-haiku": "claude-3-5-haiku-20240307" + } + + if model_name in valid_models: + model_name = valid_models[model_name] + + # طباعة معلومات التصحيح + logging.debug(f"إرسال طلب إلى Claude API: {model_name}, نوع الملف: {file_type}") + + # تحضير payload للـ API + payload = { + "model": model_name, + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": file_type, + "data": file_base64 + } + } + ] + } + ] + } + + # إرسال الطلب إلى API مع محاولات إعادة + for attempt in range(3): # ثلاث محاولات كحد أقصى + try: + response = requests.post( + self.api_url, + headers=headers, + json=payload, + timeout=120 # زيادة مهلة الانتظار إلى دقيقتين + ) + + # إذا نجح الطلب، نخرج من الحلقة + if response.status_code == 200: + break + + # إذا كان الخطأ 502، ننتظر ونحاول مرة أخرى + if response.status_code == 502: + wait_time = (attempt + 1) * 5 # انتظار 5، 10، 15 ثانية + logging.warning(f"تم استلام خطأ 502. الانتظار {wait_time} ثانية قبل إعادة المحاولة.") + time.sleep(wait_time) + else: + # إذا كان الخطأ ليس 502، نخرج من الحلقة + break + + except requests.exceptions.RequestException as e: + logging.warning(f"فشل الطلب في المحاولة {attempt+1}: {str(e)}") + if attempt == 2: # آخر محاولة + # حذف الملفات المؤقتة + for temp_file in temp_files: + try: + os.unlink(temp_file) + except: + pass + return {"error": f"فشل الاتصال بعد عدة محاولات: {str(e)}"} + time.sleep((attempt + 1) * 5) # انتظار قبل إعادة المحاولة + + # حذف الملفات المؤقتة + for temp_file in temp_files: + try: + os.unlink(temp_file) + except: + pass + + # التحقق من نجاح الطلب + if response.status_code != 200: + error_message = f"فشل طلب API: {response.status_code}" + try: + error_details = response.json() + error_message += f"\nتفاصيل: {error_details}" + except: + error_message += f"\nتفاصيل: {response.text}" + + return { + "error": error_message + } + + # معالجة الاستجابة + result = response.json() + + return { + "success": True, + "content": result["content"][0]["text"], + "model": result["model"], + "usage": result.get("usage", {}) + } + + except Exception as e: + # حذف الملفات المؤقتة في حالة حدوث خطأ + for temp_file in temp_files: + try: + os.unlink(temp_file) + except: + pass + + logging.error(f"خطأ أثناء تحليل المستند: {str(e)}") + import traceback + stack_trace = traceback.format_exc() + return {"error": f"فشل في تحليل المستند: {str(e)}\n{stack_trace}"} + + def _get_file_type(self, file_path): + """تحديد نوع الملف من امتداده""" + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + # Claude API يدعم فقط أنواع الصور التالية + if ext in ('.jpg', '.jpeg'): + return "image/jpeg" + elif ext == '.png': + return "image/png" + elif ext == '.gif': + return "image/gif" + elif ext == '.webp': + return "image/webp" + else: + # للملفات الأخرى، نعيد نوع صورة افتراضي + # هذا سيستخدم فقط إذا تم تحويل الملف إلى صورة أولاً + return "image/jpeg" + + def get_available_models(self): + """ + الحصول على قائمة بالنماذج المتاحة + + العوائد: + dict: قائمة بالنماذج مع وصفها + """ + return { + "claude-3-7-sonnet": "Claude 3.7 Sonnet - نموذج ذكي للمهام المتقدمة", + "claude-3-5-haiku": "Claude 3.5 Haiku - أسرع نموذج للمهام اليومية" + } + + def get_model_full_name(self, short_name): + """ + تحويل الاسم المختصر للنموذج إلى الاسم الكامل + + المعلمات: + short_name: الاسم المختصر للنموذج + + العوائد: + str: الاسم الكامل للنموذج + """ + valid_models = { + "claude-3-7-sonnet": "claude-3-7-sonnet-20250219", + "claude-3-5-haiku": "claude-3-5-haiku-20240307" + } + + return valid_models.get(short_name, short_name) + + +class DocumentAnalysisApp: + def __init__(self): + # إنشاء كائنات الخدمات + self.text_extractor = TextExtractor() + self.item_extractor = ItemExtractor() + self.document_parser = DocumentParser() + + # إنشاء محلل Docling + self.docling_analyzer = DoclingAnalyzer() + + # إنشاء محلل Claude + self.claude_analyzer = ClaudeAnalyzer() + + def render(self): + """العرض الرئيسي للتطبيق""" + st.title("تحليل المستندات") + st.write("اختر ملفًا لتحليله واستخرج البيانات المطلوبة.") + + # إنشاء علامات تبويب للأنواع المختلفة من التحليل + tabs = st.tabs(["تحليل عام", "تحليل Docling", "تحليل Claude AI"]) + + with tabs[0]: + self._render_general_analysis() + + with tabs[1]: + self._render_docling_analysis() + + with tabs[2]: + self._render_claude_analysis() + + def _render_general_analysis(self): + """عرض واجهة التحليل العام""" + uploaded_file = st.file_uploader("ارفع ملف PDF أو DOCX", type=["pdf", "docx"], key="general_uploader") + + if uploaded_file: + with st.spinner("جاري تحليل المستند..."): + file_path = f"/tmp/{uploaded_file.name}" + with open(file_path, "wb") as f: + f.write(uploaded_file.read()) + + # تحديد نوع الملف من امتداده + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + # استخراج النص حسب نوع الملف + if ext == '.pdf': + extracted_text = self.text_extractor.extract_from_pdf(file_path) + elif ext in ('.doc', '.docx'): + extracted_text = self.text_extractor.extract_from_docx(file_path) + else: + extracted_text = "نوع ملف غير مدعوم للنص" + + # عرض النص المستخرج + st.subheader("النص المستخرج:") + st.text_area("النص", extracted_text, height=300) + + # استخراج البنود + extracted_items = self.item_extractor.extract(file_path) + if extracted_items: + st.subheader("البنود المستخرجة:") + st.dataframe(extracted_items) + + # تحليل المستند + parsed_data = self.document_parser.parse(file_path) + st.subheader("تحليل المستند:") + st.json(parsed_data) + + def _render_docling_analysis(self): + """عرض واجهة تحليل Docling""" + import streamlit as st + from tempfile import NamedTemporaryFile + + if not self.docling_analyzer.is_available(): + st.warning("مكتبات Docling و MLX VLM غير متوفرة. يرجى تثبيت الحزم المطلوبة.") + st.code(""" + # يرجى تثبيت الحزم التالية: + pip install docling-core mlx-vlm pillow>=10.3.0 transformers>=4.49.0 tqdm>=4.66.2 + """) + return + + st.subheader("تحليل الصور والمستندات باستخدام Docling") + + # اختيار مصدر الصورة + source_option = st.radio("اختر مصدر الصورة:", ["رفع صورة", "رابط صورة"]) + + image_path = None + image_url = None + image_data = None + + if source_option == "رفع صورة": + uploaded_image = st.file_uploader("ارفع صورة", type=["jpg", "jpeg", "png"], key="docling_uploader") + if uploaded_image: + # حفظ الصورة المرفوعة إلى ملف مؤقت + image_data = uploaded_image.read() + + # عرض الصورة المرفوعة + st.image(image_data, caption="الصورة المرفوعة", width=400) + + # إنشاء ملف مؤقت لحفظ الصورة + with NamedTemporaryFile(delete=False, suffix=f".{uploaded_image.name.split('.')[-1]}") as temp_file: + temp_file.write(image_data) + image_path = temp_file.name + else: + image_url = st.text_input("أدخل رابط الصورة:") + if image_url: + try: + # عرض الصورة من الرابط + st.image(image_url, caption="الصورة من الرابط", width=400) + except Exception as e: + st.error(f"خطأ في تحميل الصورة: {str(e)}") + + # توجيه للنموذج + prompt = st.text_input("توجيه للنموذج:", value="Convert this page to docling.") + + # زر التحليل + if st.button("تحليل الصورة"): + if image_path or image_url: + with st.spinner("جاري تحليل الصورة..."): + # تحليل الصورة + results = self.docling_analyzer.analyze_image( + image_path=image_path, + image_url=image_url, + image_bytes=None, # نستخدم الملف المؤقت بدلاً من البيانات المباشرة + prompt=prompt + ) + + if "error" in results: + st.error(results["error"]) + else: + # عرض النتائج + with st.expander("علامات DocTags", expanded=True): + st.code(results["doctags"], language="xml") + + with st.expander("Markdown", expanded=True): + st.code(results["markdown"], language="markdown") + + # تصدير إلى HTML + if st.button("تصدير إلى HTML"): + html_path = self.docling_analyzer.export_to_html( + results["document"], + show_in_browser=True + ) + if html_path: + st.success(f"تم تصدير المستند إلى: {html_path}") + else: + st.error("فشل تصدير المستند إلى HTML") + + # حذف الملف المؤقت بعد الانتهاء + if image_path and os.path.exists(image_path) and image_data: + try: + os.unlink(image_path) + except: + pass + else: + st.warning("يرجى اختيار صورة للتحليل أولاً.") + + def _render_claude_analysis(self): + """عرض واجهة تحليل Claude AI مع توسعة البيانات المعروضة""" + import time + + st.subheader("تحليل المستندات باستخدام Claude AI") + + col1, col2 = st.columns([2, 1]) + + with col1: + # إضافة اختيار النموذج + claude_models = { + "claude-3-7-sonnet": "Claude 3.7 Sonnet - نموذج ذكي للمهام المتقدمة", + "claude-3-5-haiku": "Claude 3.5 Haiku - أسرع نموذج للمهام اليومية" + } + + selected_model = st.radio( + "اختر نموذج Claude", + options=list(claude_models.keys()), + format_func=lambda x: claude_models[x], + horizontal=True + ) + + with col2: + # إضافة شرح بسيط للنموذج + if selected_model == "claude-3-7-sonnet": + st.info("نموذج Claude 3.7 Sonnet هو أحدث نموذج ذكي يقدم تحليلاً متعمقاً للمستندات مع دقة عالية") + else: + st.info("نموذج Claude 3.5 Haiku أسرع في التحليل ومناسب للمهام البسيطة والاستخدام اليومي") + + # تخصيص التوجيه مع اقتراحات للتوجيهات المخصصة + st.subheader("تخصيص التحليل") + + prompt_templates = { + "تحليل عام": "قم بتحليل هذا المستند واستخراج جميع المعلومات المهمة.", + "استخراج البيانات الأساسية": "استخرج كافة البيانات الأساسية من هذا المستند بما في ذلك الأسماء والتواريخ والأرقام والمبالغ المالية.", + "تلخيص المستند": "قم بتلخيص هذا المستند بشكل مفصل مع التركيز على النقاط الرئيسية.", + "تحليل العقود": "حلل هذا العقد واستخرج الأطراف والالتزامات والشروط والتواريخ المهمة.", + "تحليل فواتير": "استخرج كافة المعلومات من هذه الفاتورة بما في ذلك المورد والعميل وتفاصيل المنتجات والأسعار والمبالغ الإجمالية." + } + + prompt_type = st.selectbox( + "اختر نوع التوجيه", + options=list(prompt_templates.keys()), + index=0 + ) + + default_prompt = prompt_templates[prompt_type] + + custom_prompt = st.text_area( + "تخصيص التوجيه للتحليل", + value=default_prompt, + height=100 + ) + + # خيارات متقدمة + with st.expander("خيارات متقدمة"): + extraction_format = st.selectbox( + "تنسيق استخراج البيانات", + ["عام", "جداول", "قائمة", "هيكل منظم"], + index=0 + ) + + detail_level = st.slider( + "مستوى التفاصيل", + min_value=1, + max_value=5, + value=3, + help="1: ملخص موجز، 5: تحليل تفصيلي كامل" + ) + + # تحديث التوجيه بناء على الخيارات المتقدمة + if extraction_format != "عام" or detail_level != 3: + custom_prompt += f"\n\nاستخدم تنسيق {extraction_format} مع مستوى تفاصيل {detail_level}/5." + + # رفع الملف + uploaded_file = st.file_uploader( + "ارفع ملفًا للتحليل", + type=["pdf", "jpg", "jpeg", "png"], + key="claude_uploader", + help="يدعم ملفات PDF والصور. سيتم تحويل PDF إلى صور لمعالجتها." + ) + + # التحقق من وجود مفتاح API + api_available = True + try: + self.claude_analyzer.get_api_key() + except ValueError: + api_available = False + st.warning("مفتاح API لـ Claude غير متوفر. يرجى التأكد من تعيين متغير البيئة 'anthropic'.") + + # زر التحليل + analyze_col1, analyze_col2 = st.columns([1, 3]) + + with analyze_col1: + analyze_button = st.button( + "تحليل المستند", + key="analyze_claude_btn", + use_container_width=True, + disabled=not (uploaded_file and api_available) + ) + + with analyze_col2: + if not uploaded_file: + st.info("يرجى رفع ملف للتحليل") + + # إجراء التحليل + if uploaded_file and api_available and analyze_button: + # عرض شريط التقدم + progress_bar = st.progress(0, text="جاري تجهيز الملف...") + + with st.spinner(f"جاري التحليل باستخدام {claude_models[selected_model].split('-')[0]}..."): + # حفظ الملف المرفوع إلى ملف مؤقت + temp_path = f"/tmp/{uploaded_file.name}" + with open(temp_path, "wb") as f: + f.write(uploaded_file.getbuffer()) + + # تحديث شريط التقدم + progress_bar.progress(25, text="جاري معالجة الملف...") + + try: + # تحليل المستند + progress_bar.progress(40, text="جاري إرسال الطلب إلى Claude AI...") + + results = self.claude_analyzer.analyze_document( + temp_path, + model_name=selected_model, + prompt=custom_prompt + ) + + progress_bar.progress(90, text="جاري معالجة النتائج...") + + if "error" in results: + st.error(results["error"]) + else: + progress_bar.progress(100, text="اكتمل التحليل!") + + # عرض النتائج بشكل منظم + st.success(f"تم التحليل بنجاح باستخدام {results.get('model', selected_model)}!") + + # إضافة علامات تبويب فرعية للنتائج + result_tabs = st.tabs(["التحليل الكامل", "بيانات مستخرجة", "معلومات إضافية"]) + + with result_tabs[0]: + # عرض النتائج الكاملة + st.markdown("## نتائج التحليل") + st.markdown(results["content"]) + + with result_tabs[1]: + # محاولة استخراج بيانات منظمة من النتائج + st.markdown("## البيانات المستخرجة") + + # تقسيم النتائج إلى أقسام + content_parts = results["content"].split("\n\n") + + # استخراج العناوين والبيانات الهامة + headings = [] + key_values = {} + + for part in content_parts: + # تحديد العناوين + if part.startswith("#") or part.startswith("##") or part.startswith("###"): + headings.append(part.strip()) + continue + + # محاولة استخراج أزواج المفتاح/القيمة + if ":" in part and len(part.split(":")) == 2: + key, value = part.split(":") + key_values[key.strip()] = value.strip() + + # عرض العناوين + if headings: + st.markdown("### العناوين الرئيسية") + for heading in headings[:5]: # عرض أهم 5 عناوين + st.markdown(f"- {heading}") + + if len(headings) > 5: + with st.expander(f"عرض {len(headings) - 5} عناوين إضافية"): + for heading in headings[5:]: + st.markdown(f"- {heading}") + + # عرض البيانات الهامة + if key_values: + st.markdown("### بيانات هامة") + + # تحويل البيانات إلى DataFrame + import pandas as pd + df = pd.DataFrame([key_values.values()], columns=key_values.keys()) + st.dataframe(df.T) + + # البحث عن الجداول في النص + if "| ------ |" in results["content"] or "\n|" in results["content"]: + st.markdown("### جداول مستخرجة") + # استخراج الجداول من النص Markdown + table_parts = [] + in_table = False + current_table = [] + + for line in results["content"].split("\n"): + if line.startswith("|") and "-|-" in line.replace(" ", ""): + in_table = True + current_table.append(line) + elif in_table and line.startswith("|"): + current_table.append(line) + elif in_table and not line.startswith("|") and line.strip(): + in_table = False + table_parts.append("\n".join(current_table)) + current_table = [] + + # إضافة الجدول الأخير إذا كان هناك + if current_table: + table_parts.append("\n".join(current_table)) + + # عرض الجداول + for i, table in enumerate(table_parts): + st.markdown(f"#### جدول {i+1}") + st.markdown(table) + + # إذا لم يتم العثور على أي بيانات منظمة + if not headings and not key_values and not ("| ------ |" in results["content"] or "\n|" in results["content"]): + st.info("لم يتم العثور على بيانات منظمة في النتائج. يمكنك تعديل التوجيه لطلب تنسيق أكثر هيكلية.") + + with result_tabs[2]: + # عرض معلومات إضافية + st.markdown("## معلومات عن التحليل") + + # عرض معلومات الاستخدام + col1, col2 = st.columns(2) + + with col1: + st.markdown("### معلومات النموذج") + st.markdown(f"**النموذج المستخدم**: {results.get('model', selected_model)}") + st.markdown(f"**تاريخ التحليل**: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + with col2: + st.markdown("### إحصائيات الاستخدام") + + if "usage" in results: + usage = results["usage"] + st.markdown(f"**توكنز المدخلات**: {usage.get('input_tokens', 'غير متوفر')}") + st.markdown(f"**توكنز الإخراج**: {usage.get('output_tokens', 'غير متوفر')}") + st.markdown(f"**إجمالي التوكنز**: {usage.get('input_tokens', 0) + usage.get('output_tokens', 0)}") + else: + st.info("معلومات الاستخدام غير متوفرة") + + # إضافة خيارات التصدير + st.markdown("### تصدير النتائج") + + export_col1, export_col2 = st.columns(2) + + with export_col1: + # تصدير كنص + st.download_button( + label="تحميل النتائج كملف نصي", + data=results["content"], + file_name=f"claude_analysis_{uploaded_file.name.split('.')[0]}.txt", + mime="text/plain" + ) + + with export_col2: + # تصدير كـ Markdown + st.download_button( + label="تحميل النتائج كملف Markdown", + data=results["content"], + file_name=f"claude_analysis_{uploaded_file.name.split('.')[0]}.md", + mime="text/markdown" + ) + finally: + # حذف الملف المؤقت + try: + os.unlink(temp_path) + except: + pass + + def analyze_document(self, file_path): + """ + تحليل مستند وإرجاع نتائج التحليل + + المعلمات: + file_path (str): مسار المستند المراد تحليله + + العوائد: + dict: نتائج تحليل المستند + """ + # تحديد نوع المستند من امتداد الملف + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + # تحليل المستند حسب نوعه + if ext == '.pdf': + text = self.text_extractor.extract_from_pdf(file_path) + elif ext in ('.doc', '.docx'): + text = self.text_extractor.extract_from_docx(file_path) + elif ext in ('.jpg', '.jpeg', '.png'): + # استخدام محلل Docling للصور إذا كان متاحًا + if self.docling_analyzer.is_available(): + docling_results = self.docling_analyzer.analyze_image(image_path=file_path) + if "error" not in docling_results: + return { + "نص": docling_results["markdown"], + "doctags": docling_results["doctags"], + "معلومات": { + "نوع المستند": "صورة", + "تحليل": "تم تحليله باستخدام Docling" + } + } + + # استخدام المحلل العادي إذا كان Docling غير متاح + text = self.text_extractor.extract_from_image(file_path) + else: + raise ValueError(f"نوع المستند غير مدعوم: {ext}") + + # تحليل المستند + document = self.document_parser.parse_document(file_path) + + # استخراج العناصر المنظمة + tables = self.item_extractor.extract_tables(document) + + # إرجاع نتائج التحليل + return { + "نص": text, + "جداول": tables, + "معلومات": document + } + + def analyze_with_claude(self, file_path, model_name="claude-3-7-sonnet", prompt=None): + """ + تحليل مستند باستخدام Claude AI + + المعلمات: + file_path (str): مسار المستند المراد تحليله + model_name (str): اسم نموذج Claude المراد استخدامه + prompt (str): التوجيه المخصص للتحليل (اختياري) + + العوائد: + dict: نتائج التحليل + """ + # محاولة تحليل المستند باستخدام Claude + try: + # التحقق من وجود المفتاح + self.claude_analyzer.get_api_key() + + # تحليل المستند باستخدام Claude + return self.claude_analyzer.analyze_document( + file_path, + model_name=model_name, + prompt=prompt + ) + except Exception as e: + logging.error(f"خطأ في تحليل المستند باستخدام Claude: {str(e)}") + return {"error": f"فشل في تحليل المستند باستخدام Claude: {str(e)}"} + + +# تشغيل التطبيق +if __name__ == "__main__": + app = DocumentAnalysisApp() + app.render() \ No newline at end of file diff --git a/modules/document_analysis/services/__init__.py b/modules/document_analysis/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..42eb7368d3ae75ba2c3fae7fb957915352f2f178 --- /dev/null +++ b/modules/document_analysis/services/__init__.py @@ -0,0 +1,22 @@ +""" +حزمة خدمات تحليل المستندات + +توفر هذه الحزمة الأدوات والخدمات اللازمة لتحليل المستندات بمختلف أنواعها +واستخراج النصوص والبيانات المنظمة منها. +""" + +# استيراد الفئات الرئيسية +from .text_extractor import TextExtractor +from .item_extractor import ItemExtractor +from .document_parser import DocumentParser + +# تحديد الفئات التي يمكن استيرادها عند استخدام from services import * +__all__ = [ + 'TextExtractor', + 'ItemExtractor', + 'DocumentParser', +] + +# معلومات الإصدار +__version__ = '0.1.0' +__author__ = 'فريق تطوير تحليل المستندات' \ No newline at end of file diff --git a/modules/document_analysis/services/document_parser.py b/modules/document_analysis/services/document_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..7239cda8f37c3765bf7bdb762706d980163eac35 --- /dev/null +++ b/modules/document_analysis/services/document_parser.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" +خدمة تحليل المستندات + +هذا الملف يحتوي على الفئة المسؤولة عن تحليل المستندات واستخراج المعلومات الهيكلية منها. +""" + +import os +import logging +import datetime + +class DocumentParser: + """فئة تحليل المستندات واستخراج المعلومات منها""" + + def __init__(self, config=None): + """ + تهيئة محلل المستندات + + المعلمات: + config (dict): إعدادات محلل المستندات + """ + self.config = config or {} + self.logger = logging.getLogger(__name__) + + def parse(self, file_path): + """ + تحليل المستند واستخراج المعلومات منه + + المعلمات: + file_path (str): مسار الملف + + العوائد: + dict: معلومات المستند المستخرجة + """ + self.logger.info(f"جاري تحليل المستند: {file_path}") + + try: + # في البيئة الحقيقية، استخدم تحليل متقدم للمستند + # محاكاة التحليل للعرض + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 + + # تحديد نوع الملف + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + # تحديد نوع المستند + document_type = self._get_document_type(ext) + + # محاكاة معلومات المستند + current_date = datetime.datetime.now().strftime("%Y-%m-%d") + + result = { + "اسم الملف": file_name, + "حجم الملف": f"{file_size / 1024:.2f} كيلوبايت", + "نوع الملف": document_type, + "تاريخ التحليل": current_date, + "تقدير عدد الصفحات": self._estimate_pages(file_size), + "نتائج التحليل": { + "نوع المستند": self._classify_document(file_name), + "درجة الثقة": "85%", + "الأقسام الرئيسية": self._get_main_sections(), + "الكلمات الرئيسية": self._get_main_keywords(), + "الشروط الهامة": self._get_important_terms() + } + } + + return result + except Exception as e: + self.logger.error(f"خطأ في تحليل المستند: {str(e)}") + return {"خطأ": f"حدث خطأ أثناء تحليل المستند: {str(e)}"} + + def parse_document(self, file_path): + """ + تحليل المستند واستخراج المعلومات الأساسية منه + + المعلمات: + file_path (str): مسار الملف + + العوائد: + dict: معلومات المستند الأساسية + """ + self.logger.info(f"جاري تحليل المستند الأساسي: {file_path}") + + # في البيئة الحقيقية، استخدم تحليل متقدم للمستند + # محاكاة التحليل للعرض + file_name = os.path.basename(file_path) + + return { + "نوع": self._classify_document(file_name), + "محتوى": "محتوى المستند...", + "هيكل": { + "عنوان": "عنوان المستند", + "أقسام": ["قسم 1", "قسم 2", "قسم 3"] + } + } + + def _get_document_type(self, ext): + """ + تحديد نوع المستند من امتداد الملف + + المعلمات: + ext (str): امتداد الملف + + العوائد: + str: نوع المستند + """ + document_types = { + '.pdf': 'مستند PDF', + '.doc': 'مستند Word', + '.docx': 'مستند Word', + '.jpg': 'صورة JPEG', + '.jpeg': 'صورة JPEG', + '.png': 'صورة PNG', + '.xlsx': 'جدول Excel', + '.xls': 'جدول Excel', + '.txt': 'ملف نصي' + } + + return document_types.get(ext, 'نوع ملف غير معروف') + + def _estimate_pages(self, file_size): + """ + تقدير عدد صفحات المستند بناءً على حجمه + + المعلمات: + file_size (int): حجم الملف بالبايت + + العوائد: + int: تقدير عدد الصفحات + """ + # تقدير بسيط: كل 50 كيلوبايت تقريباً صفحة واحدة + # هذا تقدير بسيط جداً ويختلف حسب نوع المستند ومحتواه + return max(1, int(file_size / (50 * 1024))) + + def _classify_document(self, file_name): + """ + تصنيف نوع المستند بناءً على اسمه + + المعلمات: + file_name (str): اسم الملف + + العوائد: + str: تصنيف المستند + """ + file_name_lower = file_name.lower() + + if 'عقد' in file_name_lower or 'contract' in file_name_lower: + return "عقد" + elif 'مناقصة' in file_name_lower or 'tender' in file_name_lower: + return "مستند مناقصة" + elif 'تقرير' in file_name_lower or 'report' in file_name_lower: + return "تقرير" + elif 'فاتورة' in file_name_lower or 'invoice' in file_name_lower: + return "فاتورة" + elif 'عرض' in file_name_lower or 'proposal' in file_name_lower: + return "عرض سعر" + elif 'مواصفات' in file_name_lower or 'spec' in file_name_lower: + return "مواصفات فنية" + elif 'كراسة' in file_name_lower or 'شروط' in file_name_lower: + return "كراسة شروط" + else: + return "مستند عام" + + def _get_main_sections(self): + """ + الحصول على قائمة الأقسام الرئيسية التقديرية للمستند + + العوائد: + list: قائمة الأقسام الرئيسية + """ + # محاكاة قائمة الأقسام + return [ + "مقدمة", + "نطاق العمل", + "المواصفات الفنية", + "جدول الكميات", + "الشروط والأحكام", + "الجدول الزمني", + "المتطلبات الخاصة" + ] + + def _get_main_keywords(self): + """ + الحصول على قائمة الكلمات الرئيسية التقديرية للمستند + + العوائد: + list: قائمة الكلمات الرئيسية + """ + # محاكاة قائمة الكلمات الرئيسية + return [ + "مناقصة", + "بناء", + "تشييد", + "تسليم مفتاح", + "مواصفات فنية", + "جدول كميات", + "ضمان", + "غرامة تأخير", + "دفعة مقدمة", + "محتوى محلي" + ] + + def _get_important_terms(self): + """ + الحصول على قائمة الشروط الهامة التقديرية للمستند + + العوائد: + list: قائمة الشروط الهامة + """ + # محاكاة قائمة الشروط الهامة + return [ + "مدة تنفيذ المشروع: 18 شهر", + "غرامة التأخير: 0.5% أسبوعياً بحد أقصى 10%", + "الدفعة المقدمة: 10%", + "الضمان النهائي: 5% لمدة سنة", + "شروط الدفع: دفعات شهرية حسب نسبة الإنجاز", + "المحتوى المحلي: 70% كحد أدنى" + ] \ No newline at end of file diff --git a/modules/document_analysis/services/item_extractor.py b/modules/document_analysis/services/item_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..d9b18ea6467356b5254cd2583fe097157867e0fe --- /dev/null +++ b/modules/document_analysis/services/item_extractor.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" +خدمة استخراج البنود من المستندات + +هذا الملف يحتوي على الفئة المسؤولة عن استخراج البنود والجداول من المستندات. +""" + +import os +import logging + +class ItemExtractor: + """فئة استخراج البنود من المستندات""" + + def __init__(self, config=None): + """ + تهيئة مستخرج البنود + + المعلمات: + config (dict): إعدادات مستخرج البنود + """ + self.config = config or {} + self.logger = logging.getLogger(__name__) + + def extract(self, file_path): + """ + استخراج البنود من ملف + + المعلمات: + file_path (str): مسار الملف + + العوائد: + list: قائمة البنود المستخرجة + """ + self.logger.info(f"جاري استخراج البنود من الملف: {file_path}") + + try: + # في البيئة الحقيقية، استخدم تحليل متقدم للمستند + # محاكاة الاستخراج للعرض + file_name = os.path.basename(file_path) + + # تحديد نوع الملف + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext == '.pdf': + return self._extract_items_from_pdf(file_path) + elif ext in ('.doc', '.docx'): + return self._extract_items_from_docx(file_path) + else: + return [{"بند": "نوع الملف غير مدعوم", "قيمة": 0}] + except Exception as e: + self.logger.error(f"خطأ في استخراج البنود: {str(e)}") + return [{"بند": "حدث خطأ أثناء الاستخراج", "قيمة": 0, "خطأ": str(e)}] + + def _extract_items_from_pdf(self, file_path): + """ + استخراج البنود من ملف PDF + + المعلمات: + file_path (str): مسار ملف PDF + + العوائد: + list: قائمة البنود المستخرجة + """ + # في البيئة الحقيقية، استخدم تحليل متقدم للمستند + # محاكاة الاستخراج للعرض + return [ + {"بند": "أعمال الخرسانة", "وحدة": "م3", "كمية": 250, "سعر الوحدة": 1200, "الإجمالي": 300000}, + {"بند": "أعمال التشطيبات", "وحدة": "م2", "كمية": 1500, "سعر الوحدة": 350, "الإجمالي": 525000}, + {"بند": "أعمال الكهرباء", "وحدة": "نقطة", "كمية": 120, "سعر الوحدة": 200, "الإجمالي": 24000}, + {"بند": "أعمال السباكة", "وحدة": "نقطة", "كمية": 75, "سعر الوحدة": 300, "الإجمالي": 22500}, + {"بند": "أعمال الألمنيوم", "وحدة": "م2", "كمية": 85, "سعر الوحدة": 1500, "الإجمالي": 127500} + ] + + def _extract_items_from_docx(self, file_path): + """ + استخراج البنود من ملف Word + + المعلمات: + file_path (str): مسار ملف Word + + العوائد: + list: قائمة البنود المستخرجة + """ + # في البيئة الحقيقية، استخدم تحليل متقدم للمستند + # محاكاة الاستخراج للعرض + return [ + {"بند": "استشارات هندسية", "وحدة": "ساعة", "كمية": 120, "سعر الوحدة": 500, "الإجمالي": 60000}, + {"بند": "تصميم معماري", "وحدة": "م2", "كمية": 1800, "سعر الوحدة": 100, "الإجمالي": 180000}, + {"بند": "تصميم إنشائي", "وحدة": "م2", "كمية": 1800, "سعر الوحدة": 80, "الإجمالي": 144000}, + {"بند": "تصميم كهربائي", "وحدة": "م2", "كمية": 1800, "سعر الوحدة": 40, "الإجمالي": 72000}, + {"بند": "تصميم ميكانيكي", "وحدة": "م2", "كمية": 1800, "سعر الوحدة": 40, "الإجمالي": 72000} + ] + + def extract_tables(self, document): + """ + استخراج الجداول من مستند + + المعلمات: + document (dict): المستند المحلل + + العوائد: + list: قائمة الجداول المستخرجة + """ + self.logger.info("جاري استخراج الجداول من المستند") + + try: + # في البيئة الحقيقية، استخدم تحليل متقدم للمستند + # محاكاة الاستخراج للعرض + return [ + { + "عنوان": "جدول البنود والتكاليف", + "بيانات": [ + {"بند": "أعمال الخرسانة", "وحدة": "م3", "كمية": 250, "سعر الوحدة": 1200, "الإجمالي": 300000}, + {"بند": "أعمال التشطيبات", "وحدة": "م2", "كمية": 1500, "سعر الوحدة": 350, "الإجمالي": 525000}, + {"بند": "أعمال الكهرباء", "وحدة": "نقطة", "كمية": 120, "سعر الوحدة": 200, "الإجمالي": 24000}, + {"بند": "أعمال السباكة", "وحدة": "نقطة", "كمية": 75, "سعر الوحدة": 300, "الإجمالي": 22500}, + {"بند": "أعمال الألمنيوم", "وحدة": "م2", "كمية": 85, "سعر الوحدة": 1500, "الإجمالي": 127500} + ] + }, + { + "عنوان": "جدول المعلومات العامة", + "بيانات": [ + {"اسم المشروع": "مبنى سكني", "المالك": "شركة الإسكان", "الموقع": "الرياض", "المساحة": "2500 م2"}, + {"اسم المشروع": "مبنى تجاري", "المالك": "شركة التطوير", "الموقع": "جدة", "المساحة": "3500 م2"} + ] + } + ] + except Exception as e: + self.logger.error(f"خطأ في استخراج الجداول: {str(e)}") + return [{"عنوان": "حدث خطأ أثناء الاستخراج", "بيانات": []}] \ No newline at end of file diff --git a/modules/document_analysis/services/text_extractor.py b/modules/document_analysis/services/text_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..078fd4c490b1b2170ab1b9767bed978d885eeb27 --- /dev/null +++ b/modules/document_analysis/services/text_extractor.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +خدمة استخراج النص من المستندات + +هذا الملف يحتوي على الفئة المسؤولة عن استخراج النص من أنواع مختلفة من المستندات. +""" + +import os +import logging + +class TextExtractor: + """فئة استخراج النص من المستندات""" + + def __init__(self, config=None): + """ + تهيئة مستخرج النص + + المعلمات: + config (dict): إعدادات مستخرج النص + """ + self.config = config or {} + self.logger = logging.getLogger(__name__) + + def extract(self, file_path): + """ + استخراج النص من ملف بناءً على نوع الملف + + المعلمات: + file_path (str): مسار الملف + + العوائد: + str: النص المستخرج + """ + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext == '.pdf': + return self.extract_from_pdf(file_path) + elif ext in ('.doc', '.docx'): + return self.extract_from_docx(file_path) + elif ext in ('.jpg', '.jpeg', '.png'): + return self.extract_from_image(file_path) + else: + self.logger.warning(f"نوع ملف غير مدعوم: {ext}") + return f"نوع ملف غير مدعوم: {ext}" + + def extract_from_pdf(self, file_path): + """ + استخراج النص من ملف PDF + + المعلمات: + file_path (str): مسار ملف PDF + + العوائد: + str: النص المستخرج + """ + self.logger.info(f"جاري استخراج النص من ملف PDF: {file_path}") + + try: + # في البيئة الحقيقية، استخدم مكتبة مناسبة مثل PyPDF2 أو pdfplumber + # محاكاة الاستخراج للعرض + return f"هذا نص مستخرج من ملف PDF: {os.path.basename(file_path)}\n\nيتم استخراج النص من الملف باستخدام مكتبة مناسبة في البيئة الحقيقية." + except Exception as e: + self.logger.error(f"خطأ في استخراج النص من PDF: {str(e)}") + return f"حدث خطأ أثناء استخراج النص: {str(e)}" + + def extract_from_docx(self, file_path): + """ + استخراج النص من ملف Word + + المعلمات: + file_path (str): مسار ملف Word + + العوائد: + str: النص المستخرج + """ + self.logger.info(f"جاري استخراج النص من ملف Word: {file_path}") + + try: + # في البيئة الحقيقية، استخدم مكتبة مناسبة مثل python-docx + # محاكاة الاستخراج للعرض + return f"هذا نص مستخرج من ملف Word: {os.path.basename(file_path)}\n\nيتم استخراج النص من الملف باستخدام مكتبة مناسبة في البيئة الحقيقية." + except Exception as e: + self.logger.error(f"خطأ في استخراج النص من Word: {str(e)}") + return f"حدث خطأ أثناء استخراج النص: {str(e)}" + + def extract_from_image(self, file_path): + """ + استخراج النص من ملف صورة باستخدام OCR + + المعلمات: + file_path (str): مسار ملف الصورة + + العوائد: + str: النص المستخرج + """ + self.logger.info(f"جاري استخراج النص من ملف صورة: {file_path}") + + try: + # في البيئة الحقيقية، استخدم مكتبة مناسبة مثل pytesseract + # محاكاة الاستخراج للعرض + return f"هذا نص مستخرج من ملف صورة: {os.path.basename(file_path)}\n\nيتم استخراج النص من الصورة باستخدام تقنية OCR في البيئة الحقيقية." + except Exception as e: + self.logger.error(f"خطأ في استخراج النص من الصورة: {str(e)}") + return f"حدث خطأ أثناء استخراج النص: {str(e)}" \ No newline at end of file diff --git a/modules/document_comparison/__init__.py b/modules/document_comparison/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f79b7f4986453a0c9a965ca015ff0b39d5b63f6f --- /dev/null +++ b/modules/document_comparison/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +وحدة مقارنة المستندات المتقدمة +""" \ No newline at end of file diff --git a/modules/document_comparison/comparison_app.py b/modules/document_comparison/comparison_app.py new file mode 100644 index 0000000000000000000000000000000000000000..6b51f3c3975cda417d15fa9c3a514ad0c0cd272b --- /dev/null +++ b/modules/document_comparison/comparison_app.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +تطبيق مقارنة المستندات المتقدمة +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات مقارنة المستندات +from modules.document_comparison.document_comparator import DocumentComparator + + +class DocumentComparisonApp: + """تطبيق مقارنة المستندات المتقدمة""" + + def __init__(self): + """تهيئة تطبيق مقارنة المستندات""" + self.comparator = DocumentComparator() + + def render(self): + """عرض واجهة المستخدم الرئيسية للتطبيق""" + self.comparator.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="مقارنة المستندات المتقدمة | WAHBi AI", + page_icon="📄", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = DocumentComparisonApp() + app.render() \ No newline at end of file diff --git a/modules/document_comparison/document_comparator.py b/modules/document_comparison/document_comparator.py new file mode 100644 index 0000000000000000000000000000000000000000..229fceca3ec16033b80e1e7ff64d54bf0500d4d4 --- /dev/null +++ b/modules/document_comparison/document_comparator.py @@ -0,0 +1,1503 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة مقارنة المستندات المتقدمة لتحليل الفروقات بين نسخ المستندات +""" + +import os +import sys +import json +import re +import difflib +import Levenshtein +from datetime import datetime +import numpy as np +import pandas as pd +import streamlit as st +import plotly.express as px +import plotly.graph_objects as go +from collections import Counter +from nltk.tokenize import sent_tokenize, word_tokenize +from rouge_score import rouge_scorer +from PyPDF2 import PdfReader +import io + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد المكونات المساعدة +from utils.helpers import create_directory_if_not_exists, format_time, get_user_info + + +class DocumentComparator: + """فئة مقارنة المستندات المتقدمة""" + + def __init__(self): + """تهيئة مقارن المستندات""" + self.comparison_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'document_comparison') + create_directory_if_not_exists(self.comparison_dir) + + # تهيئة NLTK وتنزيل حزمة punkt إذا لم تكن موجودة + self._initialize_nltk() + + # إعداد مقيم ROUGE لمقارنة النصوص + self.rouge_scorer = rouge_scorer.RougeScorer(['rouge1', 'rouge2', 'rougeL'], use_stemmer=False) + + def _initialize_nltk(self): + """تهيئة مكتبة NLTK وتنزيل الحزم المطلوبة""" + try: + # استيراد nltk + import nltk + + # قائمة بالحزم المطلوبة + required_packages = ['punkt', 'stopwords', 'wordnet'] + for package in required_packages: + try: + # محاولة استخدام الحزمة أولاً، وإذا فشلت يتم تنزيلها + nltk.data.find(f'tokenizers/{package}') + except LookupError: + print(f"تنزيل حزمة NLTK: {package}") + nltk.download(package, quiet=True) + + # محاولة استخدام sent_tokenize للتحقق من وجود حزمة punkt + from nltk.tokenize import sent_tokenize + sent_tokenize("This is a test sentence.") + except LookupError: + # تنزيل حزمة punkt تلقائيًا إذا لم تكن موجودة + import nltk + nltk.download('punkt', quiet=True) + # طباعة رسالة تأكيد التنزيل + st.info("تم تنزيل حزمة NLTK punkt بنجاح للاستخدام في مقارنة المستندات.") + + def _preprocess_text(self, text): + """معالجة النص قبل التحليل""" + # إزالة الأرقام والرموز الخاصة والمسافات الزائدة + text = re.sub(r'\s+', ' ', text) + text = text.strip() + return text + + def _segment_text(self, text): + """تقسيم النص إلى فقرات وجمل""" + # تقسيم النص إلى فقرات + paragraphs = [p.strip() for p in text.split('\n') if p.strip()] + + # تقسيم كل فقرة إلى جمل + sentences = [] + for paragraph in paragraphs: + paragraph_sentences = sent_tokenize(paragraph) + sentences.extend(paragraph_sentences) + + return paragraphs, sentences + + def _calculate_similarity(self, text1, text2): + """حساب نسبة التشابه بين نصين""" + # حساب نسبة التشابه باستخدام مقياس Levenshtein + ratio = Levenshtein.ratio(text1, text2) + + # حساب درجات ROUGE + rouge_scores = self.rouge_scorer.score(text1, text2) + + # حساب متوسط نقاط Rouge + rouge1_f1 = rouge_scores['rouge1'].fmeasure + rouge2_f1 = rouge_scores['rouge2'].fmeasure + rougeL_f1 = rouge_scores['rougeL'].fmeasure + avg_rouge = (rouge1_f1 + rouge2_f1 + rougeL_f1) / 3 + + # دمج النقاط للحصول على نتيجة نهائية + combined_score = (ratio + avg_rouge) / 2 + + return { + 'levenshtein_ratio': ratio, + 'rouge1_f1': rouge1_f1, + 'rouge2_f1': rouge2_f1, + 'rougeL_f1': rougeL_f1, + 'avg_rouge': avg_rouge, + 'combined_score': combined_score + } + + def _extract_text_from_pdf(self, pdf_file): + """استخراج النص من ملف PDF""" + text = "" + try: + # قراءة ملف PDF + pdf_reader = PdfReader(pdf_file) + + # استخراج النص من كل صفحة + for page in pdf_reader.pages: + text += page.extract_text() + "\n" + except Exception as e: + st.error(f"خطأ في قراءة ملف PDF: {e}") + + return text + + def get_document_diff(self, text1, text2, title1="المستند الأول", title2="المستند الثاني"): + """حساب الفروقات بين نصين""" + if not text1 or not text2: + return { + "title1": title1, + "title2": title2, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "similarity": 0, + "similarity_score": 0, + "text_diffs": [], + "summary": "أحد المستندات فارغ، لا يمكن إجراء المقارنة." + } + + # معالجة النصوص + preprocessed_text1 = self._preprocess_text(text1) + preprocessed_text2 = self._preprocess_text(text2) + + # حساب نسبة التشابه الإجمالية + similarity_metrics = self._calculate_similarity(preprocessed_text1, preprocessed_text2) + similarity_score = similarity_metrics['combined_score'] + similarity_percentage = int(similarity_score * 100) + + # تقسيم النصوص إلى فقرات وجمل + paragraphs1, sentences1 = self._segment_text(text1) + paragraphs2, sentences2 = self._segment_text(text2) + + # تحديد الفروقات بين الجمل باستخدام difflib + differ = difflib.Differ() + sentence_diffs = [] + + # مصفوفة التشابه بين الجمل + similarity_matrix = np.zeros((len(sentences1), len(sentences2))) + for i, s1 in enumerate(sentences1): + for j, s2 in enumerate(sentences2): + similarity_matrix[i, j] = Levenshtein.ratio(s1, s2) + + # تحديد أفضل مطابقة لكل جملة + matched_sentences2 = set() # تتبع الجمل المطابقة في المستند الثاني + + for i, s1 in enumerate(sentences1): + if len(s1.split()) < 3: # تجاهل الجمل القصيرة جداً + continue + + best_match_idx = -1 + best_match_score = 0.7 # عتبة التشابه + + for j, s2 in enumerate(sentences2): + if j in matched_sentences2: + continue # تجاهل الجمل التي تم مطابقتها بالفعل + + if len(s2.split()) < 3: # تجاهل الجمل القصيرة جداً + continue + + score = similarity_matrix[i, j] + if score > best_match_score and score > 0.7: + best_match_score = score + best_match_idx = j + + if best_match_idx != -1: + # وجدنا تطابق، تحديد الفروقات باستخدام difflib + s2 = sentences2[best_match_idx] + diff = list(differ.compare(s1.split(), s2.split())) + + # تحويل مخرجات difflib إلى تنسيق أسهل للاستخدام + formatted_diff = [] + for token in diff: + if token.startswith(' '): # متطابق + formatted_diff.append({'text': token[2:], 'status': 'same'}) + elif token.startswith('- '): # حذف + formatted_diff.append({'text': token[2:], 'status': 'removed'}) + elif token.startswith('+ '): # إضافة + formatted_diff.append({'text': token[2:], 'status': 'added'}) + + sentence_diffs.append({ + 'doc1_idx': i, + 'doc2_idx': best_match_idx, + 'doc1_text': s1, + 'doc2_text': s2, + 'similarity': best_match_score, + 'diff': formatted_diff + }) + + matched_sentences2.add(best_match_idx) + else: + # لم نجد تطابق، هذه الجملة غير موجودة في المستند الثاني + sentence_diffs.append({ + 'doc1_idx': i, + 'doc2_idx': -1, + 'doc1_text': s1, + 'doc2_text': "", + 'similarity': 0, + 'diff': [{'text': word, 'status': 'removed'} for word in s1.split()] + }) + + # تحديد الجمل الجديدة في المستند الثاني + for j, s2 in enumerate(sentences2): + if j not in matched_sentences2 and len(s2.split()) >= 3: + sentence_diffs.append({ + 'doc1_idx': -1, + 'doc2_idx': j, + 'doc1_text': "", + 'doc2_text': s2, + 'similarity': 0, + 'diff': [{'text': word, 'status': 'added'} for word in s2.split()] + }) + + # ترتيب الفروقات حسب الموقع في المستند الأول + sentence_diffs.sort(key=lambda x: (x['doc1_idx'] if x['doc1_idx'] != -1 else float('inf'), x['doc2_idx'] if x['doc2_idx'] != -1 else float('inf'))) + + # تحديد الفقرات المضافة والمحذوفة + paragraph_diffs = [] + matched_paragraphs2 = set() + + for i, p1 in enumerate(paragraphs1): + if len(p1.split()) < 5: # تجاهل الفقرات القصيرة جداً + continue + + best_match_idx = -1 + best_match_score = 0.6 # عتبة التشابه + + for j, p2 in enumerate(paragraphs2): + if j in matched_paragraphs2: + continue + + if len(p2.split()) < 5: + continue + + score = Levenshtein.ratio(p1, p2) + if score > best_match_score: + best_match_score = score + best_match_idx = j + + if best_match_idx != -1: + # وجدنا تطابق + p2 = paragraphs2[best_match_idx] + paragraph_diffs.append({ + 'doc1_idx': i, + 'doc2_idx': best_match_idx, + 'doc1_text': p1, + 'doc2_text': p2, + 'similarity': best_match_score, + 'status': 'modified' if best_match_score < 0.9 else 'same' + }) + + matched_paragraphs2.add(best_match_idx) + else: + # لم نجد تطابق، هذه الفقرة غير موجودة في المستند الثاني + paragraph_diffs.append({ + 'doc1_idx': i, + 'doc2_idx': -1, + 'doc1_text': p1, + 'doc2_text': "", + 'similarity': 0, + 'status': 'removed' + }) + + # تحديد الفقرات الجديدة في المستند الثاني + for j, p2 in enumerate(paragraphs2): + if j not in matched_paragraphs2 and len(p2.split()) >= 5: + paragraph_diffs.append({ + 'doc1_idx': -1, + 'doc2_idx': j, + 'doc1_text': "", + 'doc2_text': p2, + 'similarity': 0, + 'status': 'added' + }) + + # ترتيب الفروقات حسب الموقع + paragraph_diffs.sort(key=lambda x: (x['doc1_idx'] if x['doc1_idx'] != -1 else float('inf'), x['doc2_idx'] if x['doc2_idx'] != -1 else float('inf'))) + + # تحليل الفروقات للحصول على إحصائيات + total_paragraphs = len(paragraphs1) + len(paragraphs2) + removed_paragraphs = sum(1 for p in paragraph_diffs if p['status'] == 'removed') + added_paragraphs = sum(1 for p in paragraph_diffs if p['status'] == 'added') + modified_paragraphs = sum(1 for p in paragraph_diffs if p['status'] == 'modified') + + # تحليل الكلمات المضافة، المحذوفة والمتغيرة + added_words = [] + removed_words = [] + modified_contexts = [] + + for diff in sentence_diffs: + for token in diff['diff']: + if token['status'] == 'added': + added_words.append(token['text']) + elif token['status'] == 'removed': + removed_words.append(token['text']) + + # جمع السياقات المتغيرة للتحليل + if diff['doc1_idx'] != -1 and diff['doc2_idx'] != -1 and diff['similarity'] < 0.9: + modified_contexts.append({ + 'doc1_text': diff['doc1_text'], + 'doc2_text': diff['doc2_text'], + 'similarity': diff['similarity'] + }) + + # إنشاء التقرير النهائي + comparison_report = { + "title1": title1, + "title2": title2, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "similarity": similarity_percentage, + "similarity_metrics": similarity_metrics, + "sentence_diffs": sentence_diffs, + "paragraph_diffs": paragraph_diffs, + "statistics": { + "doc1_paragraphs": len(paragraphs1), + "doc2_paragraphs": len(paragraphs2), + "doc1_sentences": len(sentences1), + "doc2_sentences": len(sentences2), + "removed_paragraphs": removed_paragraphs, + "added_paragraphs": added_paragraphs, + "modified_paragraphs": modified_paragraphs, + "removed_words_count": len(removed_words), + "added_words_count": len(added_words), + "top_removed_words": Counter(removed_words).most_common(10), + "top_added_words": Counter(added_words).most_common(10) + }, + "modified_contexts": modified_contexts[:10], # أهم 10 سياقات متغيرة + "summary": self._generate_comparison_summary( + similarity_percentage, + len(paragraphs1), + len(paragraphs2), + removed_paragraphs, + added_paragraphs, + modified_paragraphs, + len(removed_words), + len(added_words) + ) + } + + # حفظ تقرير المقارنة + self._save_comparison_report(comparison_report, title1, title2) + + return comparison_report + + def _generate_comparison_summary(self, similarity, p1_count, p2_count, removed_p, added_p, modified_p, removed_w, added_w): + """إنشاء ملخص للمقارنة بين المستندين""" + if similarity >= 90: + similarity_description = "متطابقة بشكل كبير" + elif similarity >= 70: + similarity_description = "متشابهة" + elif similarity >= 50: + similarity_description = "متشابهة جزئياً" + else: + similarity_description = "مختلفة" + + summary = f"المستندان {similarity_description} بنسبة {similarity}%. " + + # وصف التغييرات في الفقرات + if removed_p > 0 or added_p > 0 or modified_p > 0: + changes = [] + if removed_p > 0: + changes.append(f"تم حذف {removed_p} فقرة") + if added_p > 0: + changes.append(f"تم إضافة {added_p} فقرة") + if modified_p > 0: + changes.append(f"تم تعديل {modified_p} فقرة") + + summary += "التغييرات تشمل: " + "، ".join(changes) + ". " + + # وصف التغييرات في الكلمات + if removed_w > 0 or added_w > 0: + word_changes = [] + if removed_w > 0: + word_changes.append(f"تم حذف {removed_w} كلمة") + if added_w > 0: + word_changes.append(f"تم إضافة {added_w} كلمة") + + summary += "على مستوى الكلمات: " + "، ".join(word_changes) + "." + + return summary + + def _save_comparison_report(self, report, title1, title2): + """حفظ تقرير المقارنة""" + # إنشاء اسم ملف فريد + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + filename = f"compare_{title1.replace(' ', '_')}_{title2.replace(' ', '_')}_{timestamp}.json" + file_path = os.path.join(self.comparison_dir, filename) + + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(report, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"خطأ في حفظ تقرير المقارنة: {e}") + + def load_comparison_report(self, filename): + """تحميل تقرير مقارنة محفوظ""" + file_path = os.path.join(self.comparison_dir, filename) + + if not os.path.exists(file_path): + return None + + try: + with open(file_path, 'r', encoding='utf-8') as f: + report = json.load(f) + return report + except Exception as e: + print(f"خطأ في تحميل تقرير المقارنة: {e}") + return None + + def get_comparison_reports(self): + """الحصول على قائمة تقارير المقارنة المحفوظة""" + reports = [] + + for filename in os.listdir(self.comparison_dir): + if filename.startswith("compare_") and filename.endswith(".json"): + file_path = os.path.join(self.comparison_dir, filename) + try: + with open(file_path, 'r', encoding='utf-8') as f: + report = json.load(f) + reports.append({ + "filename": filename, + "title1": report.get("title1", "مستند 1"), + "title2": report.get("title2", "مستند 2"), + "timestamp": report.get("timestamp", ""), + "similarity": report.get("similarity", 0) + }) + except Exception as e: + print(f"خطأ في قراءة تقرير المقارنة {filename}: {e}") + + # ترتيب التقارير حسب التاريخ (الأحدث أولاً) + reports.sort(key=lambda x: x["timestamp"], reverse=True) + + return reports + + def extract_key_differences(self, comparison_report): + """استخراج الاختلافات الرئيسية من تقرير المقارنة""" + if not comparison_report or "paragraph_diffs" not in comparison_report: + return [] + + key_differences = [] + + # استخراج الفقرات المضافة + added_paragraphs = [p for p in comparison_report["paragraph_diffs"] if p["status"] == "added"] + if added_paragraphs: + key_differences.append({ + "type": "added_paragraphs", + "label": "فقرات مضافة", + "count": len(added_paragraphs), + "items": [p["doc2_text"] for p in added_paragraphs] + }) + + # استخراج الفقرات المحذوفة + removed_paragraphs = [p for p in comparison_report["paragraph_diffs"] if p["status"] == "removed"] + if removed_paragraphs: + key_differences.append({ + "type": "removed_paragraphs", + "label": "فقرات محذوفة", + "count": len(removed_paragraphs), + "items": [p["doc1_text"] for p in removed_paragraphs] + }) + + # استخراج الفقرات المعدلة + modified_paragraphs = [p for p in comparison_report["paragraph_diffs"] if p["status"] == "modified"] + if modified_paragraphs: + modified_items = [] + for p in modified_paragraphs: + modified_items.append({ + "doc1_text": p["doc1_text"], + "doc2_text": p["doc2_text"], + "similarity": p["similarity"] + }) + + key_differences.append({ + "type": "modified_paragraphs", + "label": "فقرات معدلة", + "count": len(modified_paragraphs), + "items": modified_items + }) + + # استخراج الكلمات الرئيسية المضافة والمحذوفة + if "statistics" in comparison_report: + stats = comparison_report["statistics"] + + if "top_added_words" in stats and stats["top_added_words"]: + key_differences.append({ + "type": "added_words", + "label": "الكلمات المضافة الأكثر تكراراً", + "count": stats["added_words_count"], + "items": stats["top_added_words"] + }) + + if "top_removed_words" in stats and stats["top_removed_words"]: + key_differences.append({ + "type": "removed_words", + "label": "الكلمات المحذوفة الأكثر تكراراً", + "count": stats["removed_words_count"], + "items": stats["top_removed_words"] + }) + + return key_differences + + def analyze_legal_changes(self, comparison_report): + """تحليل التغييرات القانونية في المستندات""" + if not comparison_report: + return [] + + # قائمة المصطلحات القانونية الهامة للبحث عنها + legal_terms = { + "payment": ["دفع", "سداد", "مستحقات", "مقابل", "رسوم", "تكلفة", "مبلغ", "أتعاب"], + "deadlines": ["ميعاد", "موعد", "تاريخ", "أجل", "مدة", "فترة", "مهلة"], + "liability": ["مسؤولية", "التزام", "تحمل", "تعويض", "ضمان", "كفالة"], + "termination": ["إنهاء", "فسخ", "إلغاء", "إيقاف", "إنهاء العلاقة"], + "dispute": ["نزاع", "خلاف", "منازعة", "اعتراض", "تحكيم", "قضاء", "محكمة"], + "penalties": ["غرامة", "عقوبة", "شرط جزائي", "جزاء", "تعويض"], + "conditions": ["شرط", "بند", "حالة", "اشتراط", "متطلب"], + "rights": ["حق", "صلاحية", "امتياز", "منفعة", "ملكية", "تصرف"], + "obligations": ["التزام", "واجب", "تعهد", "إلزام", "لازم"] + } + + # البحث عن التغييرات المتعلقة بالمصطلحات القانونية + legal_changes = [] + + if "sentence_diffs" in comparison_report: + for category, terms in legal_terms.items(): + category_changes = [] + + for diff in comparison_report["sentence_diffs"]: + # فحص فقط الجمل المعدلة (المتطابقة جزئياً) + if diff["doc1_idx"] != -1 and diff["doc2_idx"] != -1 and diff["similarity"] < 0.9: + # فحص ما إذا كانت الجملة تحتوي على أي من المصطلحات القانونية + contains_term = False + for term in terms: + if term in diff["doc1_text"].lower() or term in diff["doc2_text"].lower(): + contains_term = True + break + + if contains_term: + category_changes.append({ + "doc1_text": diff["doc1_text"], + "doc2_text": diff["doc2_text"], + "similarity": diff["similarity"] + }) + + if category_changes: + legal_category_name = { + "payment": "الدفع والمستحقات المالية", + "deadlines": "المواعيد والفترات الزمنية", + "liability": "المسؤولية والالتزامات", + "termination": "إنهاء العقد أو فسخه", + "dispute": "النزاعات والخلافات", + "penalties": "الغرامات والعقوبات", + "conditions": "الشروط والبنود", + "rights": "الحقوق والصلاحيات", + "obligations": "الالتزامات والواجبات" + } + + legal_changes.append({ + "category": category, + "label": legal_category_name.get(category, category), + "count": len(category_changes), + "changes": category_changes + }) + + # ترتيب التغييرات حسب الأهمية (عدد التغييرات) + legal_changes.sort(key=lambda x: x["count"], reverse=True) + + return legal_changes + + def analyze_price_changes(self, text1, text2): + """تحليل التغييرات في الأسعار بين نسختي المستند""" + # البحث عن الأرقام متبوعة بعملة أو تعبيرات تدل على المبالغ + price_pattern = r'(\d{1,3}(?:,\d{3})*(?:\.\d+)?)\s*(?:ريال|دولار|يورو|جنيه|درهم|دينار|SAR|USD|EUR|SR|$|€|£)' + amount_pattern = r'مبلغ[\s\w]*?(\d{1,3}(?:,\d{3})*(?:\.\d+)?)' + + # استخراج الأسعار من كل نص + prices1 = re.findall(price_pattern, text1) + prices1.extend(re.findall(amount_pattern, text1)) + prices1 = [p.replace(',', '') for p in prices1] + prices1 = [float(p) for p in prices1 if p] + + prices2 = re.findall(price_pattern, text2) + prices2.extend(re.findall(amount_pattern, text2)) + prices2 = [p.replace(',', '') for p in prices2] + prices2 = [float(p) for p in prices2 if p] + + # تحليل التغييرات + price_diff = { + "doc1_prices_count": len(prices1), + "doc2_prices_count": len(prices2), + "doc1_total": sum(prices1) if prices1 else 0, + "doc2_total": sum(prices2) if prices2 else 0, + "doc1_average": sum(prices1) / len(prices1) if prices1 else 0, + "doc2_average": sum(prices2) / len(prices2) if prices2 else 0, + "doc1_min": min(prices1) if prices1 else 0, + "doc2_min": min(prices2) if prices2 else 0, + "doc1_max": max(prices1) if prices1 else 0, + "doc2_max": max(prices2) if prices2 else 0 + } + + # حساب التغيير في إجمالي الأسعار + if price_diff["doc1_total"] > 0: + price_diff["total_change_percentage"] = ((price_diff["doc2_total"] - price_diff["doc1_total"]) / price_diff["doc1_total"]) * 100 + else: + price_diff["total_change_percentage"] = 0 + + return price_diff + + def analyze_date_changes(self, text1, text2): + """تحليل التغييرات في التواريخ بين نسختي المستند""" + # البحث عن التواريخ بالصيغ المختلفة + date_patterns = [ + r'\d{1,2}/\d{1,2}/\d{2,4}', # DD/MM/YYYY or MM/DD/YYYY + r'\d{1,2}-\d{1,2}-\d{2,4}', # DD-MM-YYYY or MM-DD-YYYY + r'\d{2,4}/\d{1,2}/\d{1,2}', # YYYY/MM/DD + r'\d{2,4}-\d{1,2}-\d{1,2}', # YYYY-MM-DD + r'\d{1,2}\s+(?:يناير|فبراير|مارس|أبريل|مايو|يونيو|يوليو|أغسطس|سبتمبر|أكتوبر|نوفمبر|ديسمبر)\s+\d{2,4}' # DD شهر YYYY + ] + + dates1 = [] + dates2 = [] + + for pattern in date_patterns: + dates1.extend(re.findall(pattern, text1)) + dates2.extend(re.findall(pattern, text2)) + + # إنشاء تقرير التغييرات في التواريخ + date_changes = { + "doc1_dates_count": len(dates1), + "doc2_dates_count": len(dates2), + "doc1_dates": dates1[:10], # أول 10 تواريخ فقط + "doc2_dates": dates2[:10], + "common_dates": list(set(dates1).intersection(set(dates2))), + "removed_dates": list(set(dates1) - set(dates2)), + "added_dates": list(set(dates2) - set(dates1)) + } + + return date_changes + + def render_document_comparison(self, text1, text2, title1="المستند الأول", title2="المستند الثاني"): + """عرض مقارنة المستندات بالواجهة التفاعلية""" + st.markdown("

مقارنة المستندات المتقدمة

", unsafe_allow_html=True) + + if not text1 or not text2: + st.warning("يرجى توفير نصوص المستندين للمقارنة") + return + + with st.spinner("جاري تحليل ومقارنة المستندين..."): + # إجراء المقارنة + comparison_report = self.get_document_diff(text1, text2, title1, title2) + + # تحليل التغييرات القانونية + legal_changes = self.analyze_legal_changes(comparison_report) + + # تحليل التغييرات في الأسعار والتواريخ + price_changes = self.analyze_price_changes(text1, text2) + date_changes = self.analyze_date_changes(text1, text2) + + # عرض ملخص المقارنة + st.markdown("

ملخص المقارنة

", unsafe_allow_html=True) + + col1, col2, col3 = st.columns([1, 1, 1]) + + with col1: + similarity = comparison_report["similarity"] + color = "#00b894" if similarity >= 80 else "#fdcb6e" if similarity >= 50 else "#d63031" + + st.markdown(f""" +
+
نسبة التشابه الإجمالية
+
{similarity}%
+
تم تحليل {comparison_report["statistics"]["doc1_paragraphs"]} فقرة في {title1} و {comparison_report["statistics"]["doc2_paragraphs"]} فقرة في {title2}
+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(f""" +
+
ملخص التغييرات
+
+
+ فقرات محذوفة: + {comparison_report["statistics"]["removed_paragraphs"]} +
+
+ فقرات مضافة: + {comparison_report["statistics"]["added_paragraphs"]} +
+
+ فقرات معدلة: + {comparison_report["statistics"]["modified_paragraphs"]} +
+
+
+ """, unsafe_allow_html=True) + + with col3: + st.markdown(f""" +
+
تغييرات الكلمات
+
+
+ كلمات محذوفة: + {comparison_report["statistics"]["removed_words_count"]} +
+
+ كلمات مضافة: + {comparison_report["statistics"]["added_words_count"]} +
+
+
+ """, unsafe_allow_html=True) + + # عرض ملخص نصي + st.markdown(f""" +
+ {comparison_report["summary"]} +
+ """, unsafe_allow_html=True) + + # عرض تحليل التغييرات القانونية + st.markdown("

تحليل التغييرات القانونية

", unsafe_allow_html=True) + + if legal_changes: + tabs = st.tabs([change["label"] for change in legal_changes]) + + for i, tab in enumerate(tabs): + with tab: + st.markdown(f"**عدد التغييرات: {legal_changes[i]['count']}**") + + for j, change in enumerate(legal_changes[i]["changes"]): + col1, col2 = st.columns(2) + with col1: + st.markdown(f"**{title1}:**") + st.markdown(f"
{change['doc1_text']}
", unsafe_allow_html=True) + with col2: + st.markdown(f"**{title2}:**") + st.markdown(f"
{change['doc2_text']}
", unsafe_allow_html=True) + + if j < len(legal_changes[i]["changes"]) - 1: + st.markdown("---") + else: + st.info("لم يتم اكتشاف تغييرات قانونية هامة بين المستندين.") + + # عرض الرسوم البيانية للتغييرات + st.markdown("

رسوم بيانية للتغييرات

", unsafe_allow_html=True) + + col1, col2 = st.columns(2) + + with col1: + # رسم بياني لتوزيع أنواع التغييرات في الفقرات + stats = comparison_report["statistics"] + fig = px.pie( + names=["فقرات متطابقة", "فقرات معدلة", "فقرات محذوفة", "فقرات مضافة"], + values=[ + stats["doc1_paragraphs"] - stats["removed_paragraphs"] - stats["modified_paragraphs"], + stats["modified_paragraphs"], + stats["removed_paragraphs"], + stats["added_paragraphs"] + ], + title="توزيع التغييرات في الفقرات", + color_discrete_sequence=["#00b894", "#fdcb6e", "#d63031", "#0984e3"] + ) + + fig.update_layout( + font=dict(family="Arial, sans-serif", size=14), + height=350 + ) + + st.plotly_chart(fig, use_container_width=True) + + with col2: + # رسم بياني للكلمات المضافة والمحذوفة الأكثر تكراراً + words_data = [] + + for word, count in comparison_report["statistics"]["top_removed_words"]: + if len(word) > 1: # تجاهل الأحرف المفردة + words_data.append({"word": word, "count": count, "type": "محذوفة"}) + + for word, count in comparison_report["statistics"]["top_added_words"]: + if len(word) > 1: # تجاهل الأحرف المفردة + words_data.append({"word": word, "count": count, "type": "مضافة"}) + + if words_data: + words_df = pd.DataFrame(words_data) + + fig = px.bar( + words_df, + x="word", + y="count", + color="type", + title="الكلمات المضافة والمحذوفة الأكثر تكراراً", + labels={"word": "الكلمة", "count": "عدد المرات", "type": "النوع"}, + color_discrete_map={"محذوفة": "#d63031", "مضافة": "#0984e3"} + ) + + fig.update_layout( + font=dict(family="Arial, sans-serif", size=14), + height=350 + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.info("لا توجد بيانات كافية للكلمات المضافة والمحذوفة.") + + # عرض تحليل الأسعار والتواريخ + col1, col2 = st.columns(2) + + with col1: + st.markdown("

تحليل التغييرات في الأسعار

", unsafe_allow_html=True) + + if price_changes["doc1_prices_count"] > 0 or price_changes["doc2_prices_count"] > 0: + price_change_direction = "زيادة" if price_changes["total_change_percentage"] > 0 else "نقص" + price_change_color = "#d63031" if price_changes["total_change_percentage"] > 0 else "#00b894" + + st.markdown(f""" +
+
تغيير في إجمالي الأسعار بنسبة {abs(price_changes['total_change_percentage']):.2f}% ({price_change_direction})
+
+
+
+
{title1}
+
{title2}
+
+
+
عدد الأسعار:
+
{price_changes['doc1_prices_count']}
+
{price_changes['doc2_prices_count']}
+
+
+
الإجمالي:
+
{price_changes['doc1_total']:,.2f}
+
{price_changes['doc2_total']:,.2f}
+
+
+
المتوسط:
+
{price_changes['doc1_average']:,.2f}
+
{price_changes['doc2_average']:,.2f}
+
+
+
الحد الأدنى:
+
{price_changes['doc1_min']:,.2f}
+
{price_changes['doc2_min']:,.2f}
+
+
+
الحد الأقصى:
+
{price_changes['doc1_max']:,.2f}
+
{price_changes['doc2_max']:,.2f}
+
+
+
+ """, unsafe_allow_html=True) + + # رسم بياني للأسعار + if price_changes["doc1_prices_count"] > 0 and price_changes["doc2_prices_count"] > 0: + price_chart_data = [ + {"document": title1, "metric": "الإجمالي", "value": price_changes["doc1_total"]}, + {"document": title2, "metric": "الإجمالي", "value": price_changes["doc2_total"]}, + {"document": title1, "metric": "المتوسط", "value": price_changes["doc1_average"]}, + {"document": title2, "metric": "المتوسط", "value": price_changes["doc2_average"]}, + {"document": title1, "metric": "الحد الأقصى", "value": price_changes["doc1_max"]}, + {"document": title2, "metric": "الحد الأقصى", "value": price_changes["doc2_max"]} + ] + + price_df = pd.DataFrame(price_chart_data) + + fig = px.bar( + price_df, + x="metric", + y="value", + color="document", + barmode="group", + title="مقارنة الأسعار بين المستندين", + color_discrete_map={title1: "#0984e3", title2: "#00b894"} + ) + + fig.update_layout( + font=dict(family="Arial, sans-serif", size=14), + height=350 + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.info("لم يتم اكتشاف أي أسعار في المستندين.") + + with col2: + st.markdown("

تحليل التغييرات في التواريخ

", unsafe_allow_html=True) + + if date_changes["doc1_dates_count"] > 0 or date_changes["doc2_dates_count"] > 0: + st.markdown(f""" +
+
تم اكتشاف {date_changes['doc1_dates_count']} تاريخ في {title1} و {date_changes['doc2_dates_count']} تاريخ في {title2}
+
+
+ تواريخ مشتركة: + {len(date_changes['common_dates'])} +
+
+ تواريخ محذوفة: + {len(date_changes['removed_dates'])} +
+
+ تواريخ مضافة: + {len(date_changes['added_dates'])} +
+
+
+ """, unsafe_allow_html=True) + + # عرض التواريخ المحذوفة والمضافة + if date_changes["removed_dates"]: + st.markdown("**التواريخ المحذوفة:**") + for date in date_changes["removed_dates"][:10]: # عرض أول 10 فقط إذا كان هناك الكثير + st.markdown(f"
{date}
", unsafe_allow_html=True) + + if date_changes["added_dates"]: + st.markdown("**التواريخ المضافة:**") + for date in date_changes["added_dates"][:10]: # عرض أول 10 فقط + st.markdown(f"
{date}
", unsafe_allow_html=True) + + # رسم بياني للتواريخ + date_chart_data = [ + {"category": "تواريخ مشتركة", "count": len(date_changes["common_dates"])}, + {"category": "تواريخ محذوفة", "count": len(date_changes["removed_dates"])}, + {"category": "تواريخ مضافة", "count": len(date_changes["added_dates"])} + ] + + date_df = pd.DataFrame(date_chart_data) + + fig = px.bar( + date_df, + x="category", + y="count", + title="توزيع التغييرات في التواريخ", + color="category", + color_discrete_map={ + "تواريخ مشتركة": "#00b894", + "تواريخ محذوفة": "#d63031", + "تواريخ مضافة": "#0984e3" + } + ) + + fig.update_layout( + font=dict(family="Arial, sans-serif", size=14), + height=350 + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.info("لم يتم اكتشاف أي تواريخ في المستندين.") + + # عرض العرض المرئي للتغييرات بين المستندين + st.markdown("

العرض المرئي للتغييرات

", unsafe_allow_html=True) + + # إضافة خيار لتصفية الفروقات + st.markdown("#### تصفية الفروقات حسب النوع") + col1, col2, col3 = st.columns(3) + + with col1: + show_added = st.checkbox("عرض الإضافات", value=True) + with col2: + show_removed = st.checkbox("عرض الحذف", value=True) + with col3: + show_modified = st.checkbox("عرض التعديلات", value=True) + + # تحديد الفروقات للعرض + filtered_diffs = [] + + for diff in comparison_report["paragraph_diffs"]: + if diff["status"] == "added" and show_added: + filtered_diffs.append(diff) + elif diff["status"] == "removed" and show_removed: + filtered_diffs.append(diff) + elif diff["status"] == "modified" and show_modified: + filtered_diffs.append(diff) + + # عرض الفروقات + if filtered_diffs: + for diff in filtered_diffs: + if diff["status"] == "added": + st.markdown(f""" +
+
+
فقرة مضافة في {title2}
+
+
+ {diff["doc2_text"]} +
+
+ """, unsafe_allow_html=True) + + elif diff["status"] == "removed": + st.markdown(f""" +
+
+
فقرة محذوفة من {title1}
+
+
+ {diff["doc1_text"]} +
+
+ """, unsafe_allow_html=True) + + elif diff["status"] == "modified": + similarity_percentage = int(diff["similarity"] * 100) + + st.markdown(f""" +
+
+
فقرة معدلة (نسبة التشابه: {similarity_percentage}%)
+
+
+
+
{title1}:
+ {diff["doc1_text"]} +
+
+
{title2}:
+ {diff["doc2_text"]} +
+
+
+ """, unsafe_allow_html=True) + else: + st.info("لا توجد فروقات تطابق معايير التصفية المحددة.") + + # إضافة CSS للتنسيق + st.markdown(""" + + """, unsafe_allow_html=True) + + def render_advanced_comparison_tools(self): + """عرض أدوات المقارنة المتقدمة""" + st.markdown("

أدوات مقارنة المستندات المتقدمة

", unsafe_allow_html=True) + + st.markdown(""" +
+ استخدم هذه الأدوات لمقارنة مستندات العقود بشكل متقدم، واكتشاف التغييرات والفروقات بين نسخ المستندات المختلفة، + مع تحليل التغييرات القانونية والمالية والتواريخ. +
+ """, unsafe_allow_html=True) + + # إنشاء علامات التبويب للأدوات المختلفة + tabs = st.tabs([ + "مقارنة نصية مباشرة", + "مقارنة ملفات PDF", + "عرض تقارير المقارنة السابقة" + ]) + + with tabs[0]: + st.markdown("### مقارنة نصية مباشرة") + + col1, col2 = st.columns(2) + + with col1: + title1 = st.text_input("عنوان المستند الأول", key="text_title1") + text1 = st.text_area("نص المستند الأول", height=300, key="text_input1") + + with col2: + title2 = st.text_input("عنوان المستند الثاني", key="text_title2") + text2 = st.text_area("نص المستند الثاني", height=300, key="text_input2") + + if st.button("قارن النصوص", key="compare_text_btn"): + if text1 and text2: + self.render_document_comparison( + text1, + text2, + title1 or "المستند الأول", + title2 or "المستند الثاني" + ) + else: + st.warning("يرجى إدخال نص المستندين للمقارنة") + + with tabs[1]: + st.markdown("### مقارنة ملفات PDF") + + col1, col2 = st.columns(2) + + with col1: + title1_pdf = st.text_input("عنوان المستند الأول", key="pdf_title1") + uploaded_file1 = st.file_uploader("تحميل المستند الأول (PDF)", type=["pdf"], key="pdf_upload1") + + with col2: + title2_pdf = st.text_input("عنوان المستند الثاني", key="pdf_title2") + uploaded_file2 = st.file_uploader("تحميل المستند الثاني (PDF)", type=["pdf"], key="pdf_upload2") + + if st.button("قارن ملفات PDF", key="compare_pdf_btn"): + if uploaded_file1 is not None and uploaded_file2 is not None: + with st.spinner("جاري استخراج النصوص من ملفات PDF..."): + text1_pdf = self._extract_text_from_pdf(uploaded_file1) + text2_pdf = self._extract_text_from_pdf(uploaded_file2) + + if text1_pdf and text2_pdf: + self.render_document_comparison( + text1_pdf, + text2_pdf, + title1_pdf or uploaded_file1.name, + title2_pdf or uploaded_file2.name + ) + else: + st.error("تعذر استخراج النص من ملفات PDF. يرجى التأكد من أن الملفات تحتوي على نصوص قابلة للاستخراج.") + else: + st.warning("يرجى تحميل ملفي PDF للمقارنة") + + with tabs[2]: + st.markdown("### تقارير المقارنة السابقة") + + # الحصول على تقارير المقارنة المحفوظة + reports = self.get_comparison_reports() + + if reports: + # عرض التقارير في جدول + report_data = [] + for report in reports: + report_data.append({ + "التاريخ": report["timestamp"], + "المستند الأول": report["title1"], + "المستند الثاني": report["title2"], + "نسبة التشابه": f"{report['similarity']}%", + "الملف": report["filename"] + }) + + report_df = pd.DataFrame(report_data) + st.dataframe(report_df) + + # اختيار تقرير لعرضه + selected_report = st.selectbox( + "اختر تقريراً لعرضه", + options=[f"{r['title1']} و {r['title2']} ({r['timestamp']})" for r in reports], + format_func=lambda x: x + ) + + report_index = next((i for i, r in enumerate(reports) if f"{r['title1']} و {r['title2']} ({r['timestamp']})" == selected_report), None) + + if report_index is not None and st.button("عرض التقرير المحدد"): + selected_filename = reports[report_index]["filename"] + report_data = self.load_comparison_report(selected_filename) + + if report_data: + st.success(f"تم تحميل تقرير المقارنة بنجاح") + + # عرض ملخص التقرير + st.markdown(f"### ملخص تقرير المقارنة") + st.markdown(f"**نسبة التشابه:** {report_data['similarity']}%") + st.markdown(f"**تاريخ المقارنة:** {report_data['timestamp']}") + st.markdown(f"**ملخص التغييرات:** {report_data['summary']}") + + # استخراج الاختلافات الرئيسية + key_differences = self.extract_key_differences(report_data) + + if key_differences: + st.markdown("### الاختلافات الرئيسية") + + for diff in key_differences: + st.markdown(f"#### {diff['label']} ({diff['count']})") + + if diff["type"] == "added_paragraphs": + for item in diff["items"][:5]: # عرض أول 5 فقط + st.markdown(f"
{item}
", unsafe_allow_html=True) + + elif diff["type"] == "removed_paragraphs": + for item in diff["items"][:5]: + st.markdown(f"
{item}
", unsafe_allow_html=True) + + elif diff["type"] == "modified_paragraphs": + for item in diff["items"][:3]: + col1, col2 = st.columns(2) + with col1: + st.markdown(f"**{report_data['title1']}:**") + st.markdown(f"
{item['doc1_text']}
", unsafe_allow_html=True) + with col2: + st.markdown(f"**{report_data['title2']}:**") + st.markdown(f"
{item['doc2_text']}
", unsafe_allow_html=True) + + elif diff["type"] in ["added_words", "removed_words"]: + # عرض الكلمات في شكل جدول + word_data = [] + for word, count in diff["items"]: + if len(word) > 1: # تجاهل الأحرف المفردة + word_data.append({"الكلمة": word, "عدد المرات": count}) + + if word_data: + word_df = pd.DataFrame(word_data) + st.dataframe(word_df) + + # تحليل التغييرات القانونية + legal_changes = self.analyze_legal_changes(report_data) + + if legal_changes: + st.markdown("### تحليل التغييرات القانونية") + + for change in legal_changes[:3]: # عرض أهم 3 فئات فقط + st.markdown(f"#### {change['label']} ({change['count']})") + + for item in change["changes"][:2]: # عرض أول مثالين فقط + col1, col2 = st.columns(2) + with col1: + st.markdown(f"**{report_data['title1']}:**") + st.markdown(f"
{item['doc1_text']}
", unsafe_allow_html=True) + with col2: + st.markdown(f"**{report_data['title2']}:**") + st.markdown(f"
{item['doc2_text']}
", unsafe_allow_html=True) + else: + st.error("تعذر تحميل تقرير المقارنة") + else: + st.info("لا توجد تقارير مقارنة محفوظة") + + # إضافة CSS للتنسيق + st.markdown(""" + + """, unsafe_allow_html=True) + + def render(self): + """عرض واجهة المستخدم الرئيسية للتطبيق""" + self.render_advanced_comparison_tools() \ No newline at end of file diff --git a/modules/maps/README.md b/modules/maps/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4698548083da0a4ae7f3e011b2a6b26222364955 --- /dev/null +++ b/modules/maps/README.md @@ -0,0 +1,45 @@ +# وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد + +## نظرة عامة +تتيح هذه الوحدة عرض مواقع المشاريع على خريطة تفاعلية مع إمكانية رؤية التضاريس بشكل ثلاثي الأبعاد، مما يساعد في تقييم طبيعة الموقع بشكل أفضل قبل البدء في العمل. + +## الميزات الرئيسية + +### الخريطة التفاعلية +- عرض جميع مواقع المشاريع على خريطة تفاعلية +- إمكانية البحث عن المواقع وتصفيتها +- تجميع المواقع القريبة (Clustering) +- عرض خرائط حرارية لتوزيع المشاريع +- أدوات قياس المسافة والمساحة + +### عرض التضاريس ثلاثي الأبعاد +- عرض تضاريس موقع المشروع بشكل ثلاثي الأبعاد +- التحكم في نطاق العرض ومقياس الارتفاع +- تحليل الارتفاعات وعرض المقطع الجانبي +- إمكانية تدوير وتكبير العرض للرؤية من زوايا مختلفة + +### تحليل المواقع +- عرض توزيع المشاريع حسب المدينة والحالة +- تحليل المسافات بين المشاريع +- عرض المشاريع القريبة من مشروع محدد +- رسوم بيانية توضيحية للتوزيع الجغرافي + +### إدارة المواقع +- إضافة مواقع جديدة +- تحرير وحذف المواقع الموجودة +- استيراد وتصدير بيانات المواقع بصيغ متعددة (CSV, JSON, GeoJSON) + +## المتطلبات الفنية +- Streamlit +- Folium +- PyDeck +- Pandas +- NumPy +- Plotly +- streamlit-folium + +## المطورون +فريق تطوير نظام WAHBI AI لتحليل العقود والمناقصات - شركة شبه الجزيرة للمقاولات + +## تاريخ الإصدار +مارس 2025 \ No newline at end of file diff --git a/modules/maps/__init__.py b/modules/maps/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..03d5e034693e76c23713942b30b72703bfdfc710 --- /dev/null +++ b/modules/maps/__init__.py @@ -0,0 +1 @@ +# ملف تهيئة وحدة الخرائط \ No newline at end of file diff --git a/modules/maps/interactive_map.py b/modules/maps/interactive_map.py new file mode 100644 index 0000000000000000000000000000000000000000..653d7d6c110ba1b8b0f15dcfb0858161ff8a78bc --- /dev/null +++ b/modules/maps/interactive_map.py @@ -0,0 +1,1671 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد +تتيح هذه الوحدة عرض مواقع المشاريع على خريطة تفاعلية مع إمكانية رؤية التضاريس بشكل ثلاثي الأبعاد +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import pydeck as pdk +import folium +from folium.plugins import MarkerCluster, HeatMap, MeasureControl +from streamlit_folium import folium_static +import requests +import json +import random +from typing import List, Dict, Any, Tuple, Optional +import tempfile +import base64 +from PIL import Image +from io import BytesIO + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +from utils.components.credits import render_credits +from utils.helpers import format_number, format_currency, styled_button + + +class InteractiveMap: + """فئة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد""" + + def __init__(self): + """تهيئة وحدة الخريطة التفاعلية""" + # تهيئة مجلدات حفظ البيانات + self.data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../data/maps")) + os.makedirs(self.data_dir, exist_ok=True) + + # مفاتيح API لخدمات الخرائط + self.mapbox_token = os.environ.get("MAPBOX_TOKEN", "") + self.opentopodata_api = "https://api.opentopodata.org/v1/srtm30m" + + # تهيئة حالة الجلسة + if 'project_locations' not in st.session_state: + st.session_state.project_locations = [] + + if 'selected_location' not in st.session_state: + st.session_state.selected_location = None + + if 'terrain_data' not in st.session_state: + st.session_state.terrain_data = None + + # بيانات اختبارية للمشاريع (سيتم استبدالها بالبيانات الفعلية من قاعدة البيانات) + self._initialize_sample_projects() + + def render(self): + """عرض واجهة وحدة الخريطة التفاعلية""" + # عرض الشعار والعنوان الرئيسي + render_header("خريطة مواقع المشاريع التفاعلية") + + # تبويبات الوحدة + tabs = st.tabs([ + "الخريطة التفاعلية", + "عرض التضاريس ثلاثي الأبعاد", + "تحليل المواقع", + "إدارة المواقع" + ]) + + # تبويب الخريطة التفاعلية + with tabs[0]: + self._render_interactive_map() + + # تبويب عرض التضاريس ثلاثي الأبعاد + with tabs[1]: + self._render_3d_terrain() + + # تبويب تحليل المواقع + with tabs[2]: + self._render_location_analysis() + + # تبويب إدارة المواقع + with tabs[3]: + self._render_location_management() + + # عرض حقوق النشر + render_credits() + + def _render_interactive_map(self): + """عرض الخريطة التفاعلية""" + st.markdown(""" +
+

🗺️ الخريطة التفاعلية لمواقع المشاريع

+

خريطة تفاعلية تعرض مواقع جميع المشاريع مع إمكانية التكبير والتصغير والتحرك.

+

يمكنك النقر على أي موقع للحصول على تفاصيل المشروع.

+
+ """, unsafe_allow_html=True) + + # مربع البحث + search_query = st.text_input("البحث عن موقع أو مشروع", key="map_search") + + # أزرار تحكم للخريطة + col1, col2, col3, col4 = st.columns(4) + + with col1: + map_style = st.selectbox( + "نمط الخريطة", + options=["OpenStreetMap", "Stamen Terrain", "Stamen Toner", "CartoDB Positron"], + key="map_style" + ) + + with col2: + cluster_markers = st.checkbox("تجميع المواقع القريبة", value=True, key="cluster_markers") + + with col3: + show_heatmap = st.checkbox("عرض خريطة حرارية للمواقع", value=False, key="show_heatmap") + + with col4: + show_measurements = st.checkbox("أدوات القياس", value=False, key="show_measurements") + + # إنشاء الخريطة + if len(st.session_state.project_locations) > 0: + # بيانات النقاط على الخريطة + locations = [] + + # تصفية المشاريع حسب البحث + filtered_projects = st.session_state.project_locations + if search_query: + filtered_projects = [ + p for p in filtered_projects + if search_query.lower() in p.get("name", "").lower() or + search_query.lower() in p.get("description", "").lower() or + search_query.lower() in p.get("city", "").lower() + ] + + # عرض عدد النتائج + if search_query: + st.markdown(f"عدد النتائج: {len(filtered_projects)}") + + # تحضير البيانات للخريطة + heat_data = [] + for project in filtered_projects: + locations.append({ + "lat": project.get("latitude"), + "lon": project.get("longitude"), + "name": project.get("name"), + "description": project.get("description"), + "city": project.get("city"), + "status": project.get("status"), + "project_id": project.get("project_id") + }) + heat_data.append([project.get("latitude"), project.get("longitude"), 1]) + + # تعيين نقطة المركز والتكبير + if filtered_projects: + center_lat = sum(p.get("latitude", 0) for p in filtered_projects) / len(filtered_projects) + center_lon = sum(p.get("longitude", 0) for p in filtered_projects) / len(filtered_projects) + zoom_level = 6 # مستوى التكبير الافتراضي + else: + # مركز المملكة العربية السعودية + center_lat = 24.7136 + center_lon = 46.6753 + zoom_level = 5 + + # تحديد الإسناد (attribution) بناءً على نمط الخريطة + attribution = None + if map_style == "OpenStreetMap": + attribution = '© OpenStreetMap contributors' + elif map_style.startswith("Stamen"): + attribution = 'Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under ODbL.' + elif map_style == "CartoDB Positron": + attribution = '© OpenStreetMap contributors, © CartoDB' + + # إنشاء الخريطة + m = folium.Map( + location=[center_lat, center_lon], + zoom_start=zoom_level, + tiles=map_style, + attr=attribution # إضافة سمة الإسناد + ) + + # إضافة أدوات القياس إذا تم اختيارها + if show_measurements: + MeasureControl(position='topright', primary_length_unit='kilometers').add_to(m) + + # إضافة النقاط إلى الخريطة + if cluster_markers: + # إنشاء مجموعة تجميع + marker_cluster = MarkerCluster(name="تجميع المشاريع").add_to(m) + + # إضافة النقاط إلى المجموعة + for location in locations: + # إنشاء النافذة المنبثقة + popup_html = f""" +
+

{location['name']}

+

الوصف: {location['description']}

+

المدينة: {location['city']}

+

الحالة: {location['status']}

+

الإحداثيات: {location['lat']:.6f}, {location['lon']:.6f}

+ +
+ """ + + # تحديد لون العلامة حسب حالة المشروع + icon_color = 'green' + if location['status'] == 'قيد التنفيذ': + icon_color = 'orange' + elif location['status'] == 'متوقف': + icon_color = 'red' + elif location['status'] == 'مكتمل': + icon_color = 'blue' + + # إضافة العلامة + folium.Marker( + location=[location['lat'], location['lon']], + popup=folium.Popup(popup_html, max_width=300), + tooltip=location['name'], + icon=folium.Icon(color=icon_color, icon='info-sign') + ).add_to(marker_cluster) + else: + # إضافة النقاط مباشرة إلى الخريطة + for location in locations: + # إنشاء النافذة المنبثقة + popup_html = f""" +
+

{location['name']}

+

الوصف: {location['description']}

+

المدينة: {location['city']}

+

الحالة: {location['status']}

+

الإحداثيات: {location['lat']:.6f}, {location['lon']:.6f}

+ +
+ """ + + # تحديد لون العلامة حسب حالة المشروع + icon_color = 'green' + if location['status'] == 'قيد التنفيذ': + icon_color = 'orange' + elif location['status'] == 'متوقف': + icon_color = 'red' + elif location['status'] == 'مكتمل': + icon_color = 'blue' + + # إضافة العلامة + folium.Marker( + location=[location['lat'], location['lon']], + popup=folium.Popup(popup_html, max_width=300), + tooltip=location['name'], + icon=folium.Icon(color=icon_color, icon='info-sign') + ).add_to(m) + + # إضافة خريطة حرارية إذا تم اختيارها + if show_heatmap and heat_data: + HeatMap(heat_data, radius=15).add_to(m) + + # إضافة طبقات متنوعة للخريطة + folium.TileLayer('OpenStreetMap').add_to(m) + folium.TileLayer('Stamen Terrain').add_to(m) + folium.TileLayer('Stamen Toner').add_to(m) + folium.TileLayer('CartoDB positron').add_to(m) + folium.TileLayer('CartoDB dark_matter').add_to(m) + + # إضافة أدوات التحكم بالطبقات + folium.LayerControl().add_to(m) + + # عرض الخريطة + st_map = folium_static(m, width=1000, height=600) + + # التفاعل مع النقر على الخريطة (سيتم تنفيذه عندما يتم دعم التفاعل) + # حاليًا لا يوجد دعم مباشر للتفاعل مع الخريطة في Streamlit + + # عرض بيانات المشاريع في جدول + st.markdown("### قائمة المشاريع على الخريطة") + + projects_df = pd.DataFrame(filtered_projects) + + # إعادة تسمية الأعمدة بالعربية + renamed_columns = { + "name": "اسم المشروع", + "city": "المدينة", + "status": "الحالة", + "description": "الوصف", + "project_id": "معرف المشروع", + "latitude": "خط العرض", + "longitude": "خط الطول" + } + + # تحديد الأعمدة للعرض + display_columns = ["name", "city", "status", "project_id"] + + # إنشاء جدول للعرض + display_df = projects_df[display_columns].rename(columns=renamed_columns) + + # عرض الجدول + st.dataframe(display_df, width=1000, height=400) + + # زر لاختيار مشروع لعرض التضاريس + selected_project_id = st.selectbox( + "اختر مشروعًا لعرض التضاريس ثلاثية الأبعاد", + options=projects_df["project_id"].tolist(), + format_func=lambda x: next((p["name"] for p in filtered_projects if p["project_id"] == x), x), + key="select_project_for_terrain" + ) + + # زر عرض التضاريس + if styled_button("عرض التضاريس", key="show_terrain_btn", type="primary", icon="🏔️"): + # العثور على المشروع المحدد + selected_project = next((p for p in filtered_projects if p["project_id"] == selected_project_id), None) + + if selected_project: + # تخزين الموقع المحدد في حالة الجلسة + st.session_state.selected_location = { + "latitude": selected_project["latitude"], + "longitude": selected_project["longitude"], + "name": selected_project["name"], + "project_id": selected_project["project_id"] + } + + # جلب بيانات التضاريس + try: + terrain_data = self._fetch_terrain_data( + selected_project["latitude"], + selected_project["longitude"] + ) + + # تخزين بيانات التضاريس في حالة الجلسة + st.session_state.terrain_data = terrain_data + + # الانتقال إلى تبويب عرض التضاريس + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + else: + st.warning("لا توجد مواقع مشاريع متاحة. يرجى إضافة مواقع من تبويب 'إدارة المواقع'.") + + def _render_3d_terrain(self): + """عرض التضاريس ثلاثي الأبعاد""" + st.markdown(""" +
+

🏔️ عرض التضاريس ثلاثي الأبعاد

+

عرض تفاعلي ثلاثي الأبعاد لتضاريس موقع المشروع المحدد.

+

يمكنك تدوير وتكبير العرض للحصول على رؤية أفضل للموقع.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود موقع محدد + if st.session_state.selected_location is None: + st.info("يرجى اختيار موقع من تبويب 'الخريطة التفاعلية' أولاً.") + + # بديل: السماح بإدخال الإحداثيات يدوياً + st.markdown("### إدخال الإحداثيات يدوياً") + + col1, col2 = st.columns(2) + + with col1: + manual_lat = st.number_input("خط العرض", value=24.7136, step=0.0001, format="%.6f", key="manual_lat") + + with col2: + manual_lon = st.number_input("خط الطول", value=46.6753, step=0.0001, format="%.6f", key="manual_lon") + + if styled_button("عرض التضاريس", key="manual_terrain_btn", type="primary", icon="🏔️"): + try: + # جلب بيانات التضاريس + terrain_data = self._fetch_terrain_data(manual_lat, manual_lon) + + # تخزين بيانات التضاريس والموقع في حالة الجلسة + st.session_state.terrain_data = terrain_data + st.session_state.selected_location = { + "latitude": manual_lat, + "longitude": manual_lon, + "name": f"الموقع المخصص ({manual_lat:.4f}, {manual_lon:.4f})", + "project_id": "custom" + } + + # إعادة تشغيل التطبيق لتحديث العرض + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + + # عرض خريطة لتحديد الموقع + st.markdown("### حدد موقعًا على الخريطة") + m = folium.Map( + location=[24.7136, 46.6753], + zoom_start=6, + attr='© OpenStreetMap contributors' + ) + folium_static(m, width=1000, height=500) + + st.info("ملاحظة: لا يمكن تحديد موقع على الخريطة مباشرة في هذا الإصدار. يرجى إدخال الإحداثيات يدوياً أو اختيار مشروع من القائمة.") + + return + + # عرض معلومات الموقع المحدد + st.markdown(f"### تضاريس موقع: {st.session_state.selected_location['name']}") + st.markdown(f"الإحداثيات: {st.session_state.selected_location['latitude']:.6f}, {st.session_state.selected_location['longitude']:.6f}") + + # التحقق من وجود بيانات التضاريس + if st.session_state.terrain_data is None: + st.warning("لا توجد بيانات تضاريس متاحة لهذا الموقع. جاري جلب البيانات...") + + try: + # جلب بيانات التضاريس + terrain_data = self._fetch_terrain_data( + st.session_state.selected_location["latitude"], + st.session_state.selected_location["longitude"] + ) + + # تخزين بيانات التضاريس في حالة الجلسة + st.session_state.terrain_data = terrain_data + + # إعادة تشغيل التطبيق لتحديث العرض + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + return + + # عرض الخريطة ثنائية الأبعاد للموقع + st.markdown("### خريطة الموقع") + + # إنشاء خريطة صغيرة للموقع + mini_map = folium.Map( + location=[st.session_state.selected_location["latitude"], st.session_state.selected_location["longitude"]], + zoom_start=10, + attr='© OpenStreetMap contributors' + ) + folium.Marker(location=[st.session_state.selected_location["latitude"], st.session_state.selected_location["longitude"]], tooltip="الموقع المحدد").add_to(mini_map) + folium_static(mini_map, width=700, height=300) + + # عرض بيانات التضاريس + st.markdown("### نموذج التضاريس ثلاثي الأبعاد") + + # تحويل بيانات التضاريس إلى DataFrame + df = pd.DataFrame(st.session_state.terrain_data) + + # اختيار نظام ألوان + color_schemes = { + "Viridis": "Viridis", + "أخضر إلى بني": "Greens", + "أزرق إلى أحمر": "RdBu", + "أرجواني إلى أخضر": "PuGn", + "نظام الارتفاعات": "Terrain" + } + + color_scheme = st.selectbox( + "نظام الألوان", + options=list(color_schemes.keys()), + index=4, + key="3d_color_scheme" + ) + + # خيارات العرض + col1, col2, col3 = st.columns(3) + + with col1: + exaggeration = st.slider("تضخيم الارتفاع", 1, 50, 15, key="terrain_exaggeration") + + with col2: + radius = st.slider("نطاق العرض (كم)", 1, 20, 5, key="terrain_radius") + + with col3: + resolution = st.slider("دقة العرض", 10, 100, 50, key="terrain_resolution") + + if not df.empty and len(df) > 1: + # إعادة جلب البيانات إذا تغير النطاق + current_lat = st.session_state.selected_location["latitude"] + current_lon = st.session_state.selected_location["longitude"] + current_radius = radius + + # جلب بيانات جديدة إذا تغير النطاق + if styled_button("تحديث النطاق", key="update_radius_btn"): + try: + # جلب بيانات التضاريس + terrain_data = self._fetch_terrain_data( + current_lat, + current_lon, + radius_km=current_radius + ) + + # تخزين بيانات التضاريس في حالة الجلسة + st.session_state.terrain_data = terrain_data + + # إعادة تشغيل التطبيق لتحديث العرض + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء تحديث بيانات التضاريس: {str(e)}") + + # تحويل البيانات إلى تنسيق مناسب لـ PyDeck + x = df["longitude"].values + y = df["latitude"].values + z = df["elevation"].values * exaggeration # تضخيم الارتفاع + + # تطبيع الارتفاعات للحصول على ألوان مناسبة + normalized_elevation = (z - z.min()) / (z.max() - z.min() if z.max() != z.min() else 1) + + # الحصول على نظام الألوان + cmap = self._get_color_map(color_schemes[color_scheme]) + + # إنشاء عمود الألوان + df["color"] = [ + cmap(ne) if ne <= 1.0 else cmap(1.0) + for ne in normalized_elevation + ] + + # تهيئة عرض PyDeck + view_state = pdk.ViewState( + latitude=current_lat, + longitude=current_lon, + zoom=10, + pitch=45, + bearing=0 + ) + + # إنشاء طبقة التضاريس + terrain_layer = pdk.Layer( + "ColumnLayer", + data=df, + get_position=["longitude", "latitude"], + get_elevation="elevation * " + str(exaggeration), + get_fill_color="color", + get_radius=resolution, + pickable=True, + auto_highlight=True, + elevation_scale=1, + elevation_range=[0, 1000], + coverage=1, + ) + + # إضافة طبقة لعلامة الموقع المحدد + marker_df = pd.DataFrame({ + "latitude": [current_lat], + "longitude": [current_lon], + "size": [400] + }) + + marker_layer = pdk.Layer( + "ScatterplotLayer", + data=marker_df, + get_position=["longitude", "latitude"], + get_radius="size", + get_fill_color=[255, 0, 0, 200], + pickable=True, + ) + + # تهيئة العرض + r = pdk.Deck( + layers=[terrain_layer, marker_layer], + initial_view_state=view_state, + map_style="mapbox://styles/mapbox/satellite-v9", + tooltip={ + "html": "ارتفاع: {elevation} متر
إحداثيات: {latitude:.6f}, {longitude:.6f}", + "style": { + "backgroundColor": "steelblue", + "color": "white", + "direction": "rtl", + "text-align": "right" + } + } + ) + + # عرض نموذج التضاريس + st.pydeck_chart(r) + + # إضافة معلومات إضافية + st.markdown("### معلومات الارتفاع") + + # حساب الإحصاءات + min_elevation = df["elevation"].min() + max_elevation = df["elevation"].max() + avg_elevation = df["elevation"].mean() + + # عرض الإحصاءات + stat_col1, stat_col2, stat_col3 = st.columns(3) + + with stat_col1: + st.metric("أدنى ارتفاع", f"{min_elevation:.1f} متر") + + with stat_col2: + st.metric("متوسط الارتفاع", f"{avg_elevation:.1f} متر") + + with stat_col3: + st.metric("أعلى ارتفاع", f"{max_elevation:.1f} متر") + + # زر لتصدير البيانات + if styled_button("تصدير بيانات التضاريس", key="export_terrain_btn", type="secondary", icon="📊"): + # تحويل البيانات إلى CSV + csv = df.to_csv(index=False) + + # إنشاء رابط تنزيل + b64 = base64.b64encode(csv.encode()).decode() + href = f'تنزيل البيانات (CSV)' + st.markdown(href, unsafe_allow_html=True) + else: + st.error("لا توجد بيانات كافية لعرض نموذج التضاريس. حاول اختيار موقع آخر أو زيادة النطاق.") + + def _render_location_analysis(self): + """عرض تحليل المواقع""" + st.markdown(""" +
+

📊 تحليل موقع المشروع

+

تحليل متقدم لموقع المشروع وتضاريسه والظروف المحيطة.

+

يمكنك تحليل الارتفاعات والمسافات وقياس التكاليف المرتبطة بالموقع.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود مواقع + if len(st.session_state.project_locations) == 0: + st.warning("لا توجد مواقع مشاريع متاحة للتحليل. يرجى إضافة مواقع من تبويب 'إدارة المواقع'.") + return + + # اختيار موقع أو موقعين للتحليل + analysis_type = st.radio( + "نوع التحليل", + options=["تحليل موقع واحد", "مقارنة موقعين"], + key="location_analysis_type", + horizontal=True + ) + + # تحويل المواقع إلى DataFrame + projects_df = pd.DataFrame(st.session_state.project_locations) + + if analysis_type == "تحليل موقع واحد": + # اختيار موقع للتحليل + selected_project_id = st.selectbox( + "اختر موقع المشروع للتحليل", + options=projects_df["project_id"].tolist(), + format_func=lambda x: next((p["name"] for p in st.session_state.project_locations if p["project_id"] == x), x), + key="analysis_project" + ) + + # العثور على المشروع المحدد + selected_project = next((p for p in st.session_state.project_locations if p["project_id"] == selected_project_id), None) + + if selected_project: + # عرض معلومات المشروع + st.markdown(f"### تحليل موقع: {selected_project['name']}") + + # عرض خريطة الموقع + st.markdown("#### موقع المشروع") + + # إنشاء خريطة صغيرة للموقع + m2 = folium.Map( + location=[selected_project["latitude"], selected_project["longitude"]], + zoom_start=10, + attr='© OpenStreetMap contributors' + ) + folium.Marker(location=[selected_project["latitude"], selected_project["longitude"]], tooltip=selected_project["name"]).add_to(m2) + + # إضافة دائرة بنصف قطر محدد + analysis_radius = st.slider("نطاق التحليل (كم)", 1, 50, 10, key="analysis_radius") + folium.Circle( + location=[selected_project["latitude"], selected_project["longitude"]], + radius=analysis_radius * 1000, # تحويل إلى أمتار + color="red", + fill=True, + fill_opacity=0.2 + ).add_to(m2) + + folium_static(m2, width=700, height=400) + + # تحليل الموقع + st.markdown("#### عوامل الموقع") + + # تحليل اعتباري للموقع (يمكن استبداله بتحليل حقيقي من خدمات مثل Google Places API) + + # عوامل افتراضية - ستتغير هذه باستخدام بيانات حقيقية + factors = { + "قرب المدينة": random.uniform(0.4, 1.0), + "توفر المياه": random.uniform(0.3, 0.9), + "سهولة الوصول": random.uniform(0.5, 1.0), + "الظروف الجوية": random.uniform(0.6, 1.0), + "التضاريس": random.uniform(0.3, 0.8), + "توفر العمالة": random.uniform(0.5, 0.9), + "البنية التحتية": random.uniform(0.4, 0.9), + "المخاطر البيئية": random.uniform(0.3, 0.7) + } + + # مخطط شريطي للعوامل + factors_df = pd.DataFrame({ + "العامل": list(factors.keys()), + "التقييم": list(factors.values()) + }) + + # الترتيب تنازلياً + factors_df = factors_df.sort_values(by="التقييم", ascending=False) + + # عرض الرسم البياني + st.bar_chart(factors_df.set_index("العامل")) + + # تقييم إجمالي للموقع + overall_score = sum(factors.values()) / len(factors) + + # عرض التقييم الإجمالي + st.markdown(f"#### التقييم الإجمالي للموقع: {overall_score:.2f}/1.0") + + # مؤشر تقدم للتقييم + st.progress(overall_score) + + # تصنيف التقييم + if overall_score >= 0.8: + rating = "ممتاز" + color = "green" + elif overall_score >= 0.6: + rating = "جيد" + color = "blue" + elif overall_score >= 0.4: + rating = "مقبول" + color = "orange" + else: + rating = "ضعيف" + color = "red" + + st.markdown(f"

تصنيف الموقع: {rating}

", unsafe_allow_html=True) + + # توصيات للموقع + st.markdown("#### توصيات الموقع") + + recommendations = [ + "تحسين طرق الوصول للموقع لزيادة كفاءة نقل المواد والمعدات.", + "إجراء دراسة جيوتقنية مفصلة للتضاريس قبل البدء في أعمال الحفر.", + "التأكد من توفر مصادر المياه الكافية لاحتياجات المشروع.", + "التنسيق مع السلطات المحلية لتسهيل توصيل الخدمات للموقع.", + "وضع خطة للتعامل مع الظروف الجوية المتقلبة في المنطقة." + ] + + for rec in recommendations: + st.markdown(f"- {rec}") + + # المرافق القريبة + st.markdown("#### المرافق القريبة (تمثيل افتراضي)") + + # بيانات افتراضية للمرافق القريبة + nearby_facilities = { + "مستشفى": random.uniform(5, 30), + "مدرسة": random.uniform(2, 15), + "محطة وقود": random.uniform(2, 20), + "مركز تسوق": random.uniform(3, 25), + "مكتب حكومي": random.uniform(7, 35), + "مطار": random.uniform(15, 100), + "ميناء": random.uniform(20, 150) + } + + # عرض المرافق في جدول + facilities_df = pd.DataFrame({ + "المرفق": list(nearby_facilities.keys()), + "المسافة (كم)": list(nearby_facilities.values()) + }) + + # ترتيب حسب المسافة + facilities_df = facilities_df.sort_values(by="المسافة (كم)") + + # عرض الجدول + st.dataframe(facilities_df, width=700) + + # تقرير تكلفة الموقع + st.markdown("#### تقديرات تكلفة الموقع") + + # بنود التكلفة الافتراضية + cost_items = { + "تكلفة تسوية الأرض": random.uniform(50000, 200000), + "تكلفة البنية التحتية": random.uniform(100000, 500000), + "تكلفة النقل الإضافية": random.uniform(30000, 150000), + "تكلفة الحماية من المخاطر البيئية": random.uniform(20000, 100000), + "تكلفة توصيل الخدمات": random.uniform(40000, 200000) + } + + # عرض بنود التكلفة + st.markdown("##### بنود التكلفة") + + for item, cost in cost_items.items(): + st.markdown(f"- {item}: {format_currency(cost)} ريال") + + # إجمالي التكلفة + total_cost = sum(cost_items.values()) + st.markdown(f"##### إجمالي تكلفة الموقع: {format_currency(total_cost)} ريال") + + # خيارات تحسين الموقع + st.markdown("#### خيارات تحسين الموقع") + + improvement_options = [ + {"name": "تسوية الأرض وإزالة العوائق", "cost": 75000, "impact": 0.15}, + {"name": "تحسين طرق الوصول", "cost": 120000, "impact": 0.2}, + {"name": "بناء نظام صرف للمياه", "cost": 90000, "impact": 0.18}, + {"name": "تعزيز البنية التحتية", "cost": 180000, "impact": 0.25}, + {"name": "نظام حماية من العوامل الجوية", "cost": 60000, "impact": 0.12} + ] + + # عرض خيارات التحسين + st.markdown("اختر خيارات التحسين لتقييم التأثير والتكلفة:") + + selected_improvements = [] + for i, option in enumerate(improvement_options): + if st.checkbox(f"{option['name']} - {format_currency(option['cost'])} ريال", key=f"imp_{i}"): + selected_improvements.append(option) + + if selected_improvements: + # حساب التأثير والتكلفة الإجمالية + total_impact = sum(imp["impact"] for imp in selected_improvements) + total_improvement_cost = sum(imp["cost"] for imp in selected_improvements) + + # عرض النتائج + st.markdown(f"##### تحسين التقييم المتوقع: +{total_impact:.2f}") + new_score = min(1.0, overall_score + total_impact) + st.markdown(f"##### التقييم الجديد المتوقع: {new_score:.2f}/1.0") + st.progress(new_score) + + # تصنيف التقييم الجديد + if new_score >= 0.8: + new_rating = "ممتاز" + new_color = "green" + elif new_score >= 0.6: + new_rating = "جيد" + new_color = "blue" + elif new_score >= 0.4: + new_rating = "مقبول" + new_color = "orange" + else: + new_rating = "ضعيف" + new_color = "red" + + st.markdown(f"
التصنيف الجديد المتوقع: {new_rating}
", unsafe_allow_html=True) + + # عرض التكلفة الإجمالية + st.markdown(f"##### تكلفة التحسينات: {format_currency(total_improvement_cost)} ريال") + else: + st.error("لم يتم العثور على المشروع المحدد.") + else: # مقارنة موقعين + # اختيار موقعين للمقارنة + col1, col2 = st.columns(2) + + with col1: + project_id_1 = st.selectbox( + "الموقع الأول", + options=projects_df["project_id"].tolist(), + format_func=lambda x: next((p["name"] for p in st.session_state.project_locations if p["project_id"] == x), x), + key="compare_project_1" + ) + + with col2: + # استبعاد الموقع الأول من الخيارات + remaining_options = [pid for pid in projects_df["project_id"].tolist() if pid != project_id_1] + + if remaining_options: + project_id_2 = st.selectbox( + "الموقع الثاني", + options=remaining_options, + format_func=lambda x: next((p["name"] for p in st.session_state.project_locations if p["project_id"] == x), x), + key="compare_project_2" + ) + else: + st.warning("يجب أن يكون هناك موقعان على الأقل للمقارنة.") + return + + # العثور على المشروعين المحددين + project_1 = next((p for p in st.session_state.project_locations if p["project_id"] == project_id_1), None) + project_2 = next((p for p in st.session_state.project_locations if p["project_id"] == project_id_2), None) + + if project_1 and project_2: + # عرض عنوان المقارنة + st.markdown(f"### مقارنة بين موقعي {project_1['name']} و {project_2['name']}") + + # عرض خريطة توضح الموقعين + st.markdown("#### الموقعان على الخريطة") + + # حساب المركز والزوم المناسب + center_lat = (project_1["latitude"] + project_2["latitude"]) / 2 + center_lon = (project_1["longitude"] + project_2["longitude"]) / 2 + + # حساب المسافة بين الموقعين + distance = self._calculate_distance( + project_1["latitude"], project_1["longitude"], + project_2["latitude"], project_2["longitude"] + ) + + # تحديد مستوى التكبير حسب المسافة + zoom_level = 12 if distance < 10 else (10 if distance < 50 else 8) + + # إنشاء الخريطة + compare_map = folium.Map( + location=[center_lat, center_lon], + zoom_start=zoom_level, + attr='© OpenStreetMap contributors' + ) + + # إضافة العلامات للموقعين + folium.Marker( + location=[project_1["latitude"], project_1["longitude"]], + tooltip=project_1["name"], + icon=folium.Icon(color="blue", icon="info-sign") + ).add_to(compare_map) + + folium.Marker( + location=[project_2["latitude"], project_2["longitude"]], + tooltip=project_2["name"], + icon=folium.Icon(color="red", icon="info-sign") + ).add_to(compare_map) + + # إضافة خط يربط بين الموقعين + folium.PolyLine( + locations=[ + [project_1["latitude"], project_1["longitude"]], + [project_2["latitude"], project_2["longitude"]] + ], + color="green", + weight=3, + opacity=0.7, + tooltip=f"المسافة: {distance:.2f} كم" + ).add_to(compare_map) + + # عرض الخريطة + folium_static(compare_map, width=800, height=500) + + # عرض المسافة بين الموقعين + st.markdown(f"#### المسافة بين الموقعين: {distance:.2f} كيلومتر") + + # مقارنة معلومات الموقعين + st.markdown("#### مقارنة المعلومات الأساسية") + + # إنشاء جدول المقارنة + comparison_data = { + "المعلومات": ["المدينة", "الحالة", "خط العرض", "خط الطول", "الوصف"], + project_1["name"]: [ + project_1.get("city", ""), + project_1.get("status", ""), + f"{project_1['latitude']:.6f}", + f"{project_1['longitude']:.6f}", + project_1.get("description", "") + ], + project_2["name"]: [ + project_2.get("city", ""), + project_2.get("status", ""), + f"{project_2['latitude']:.6f}", + f"{project_2['longitude']:.6f}", + project_2.get("description", "") + ] + } + + comparison_df = pd.DataFrame(comparison_data) + st.dataframe(comparison_df, width=800) + + # مقارنة العوامل البيئية والمكانية + st.markdown("#### مقارنة العوامل") + + # بيانات افتراضية للعوامل - ستتغير هذه باستخدام بيانات حقيقية + factors_comparison = { + "العامل": ["قرب المدينة", "توفر المياه", "سهولة الوصول", "الظروف الجوية", "التضاريس", "توفر العمالة", "البنية التحتية", "المخاطر البيئية"], + project_1["name"]: [random.uniform(0.4, 1.0) for _ in range(8)], + project_2["name"]: [random.uniform(0.4, 1.0) for _ in range(8)] + } + + # تحويل إلى DataFrame + factors_df = pd.DataFrame(factors_comparison) + + # رسم بياني شريطي للمقارنة + st.bar_chart(factors_df.set_index("العامل")) + + # حساب إجمالي التقييم لكل موقع + project_1_score = sum(factors_comparison[project_1["name"]]) / len(factors_comparison[project_1["name"]]) + project_2_score = sum(factors_comparison[project_2["name"]]) / len(factors_comparison[project_2["name"]]) + + # عرض التقييم الإجمالي + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"##### تقييم {project_1['name']}: {project_1_score:.2f}/1.0") + st.progress(project_1_score) + + with col2: + st.markdown(f"##### تقييم {project_2['name']}: {project_2_score:.2f}/1.0") + st.progress(project_2_score) + + # تحديد الموقع المفضل + preferred_site = project_1["name"] if project_1_score > project_2_score else project_2["name"] + score_diff = abs(project_1_score - project_2_score) + + if score_diff < 0.1: + recommendation = "الموقعان متقاربان في التقييم ويمكن اعتبارهما متكافئين." + color = "blue" + else: + recommendation = f"الموقع الأفضل هو: {preferred_site}" + color = "green" + + st.markdown(f"

{recommendation}

", unsafe_allow_html=True) + + # تحليل التكلفة + st.markdown("#### مقارنة تقديرات التكلفة") + + # بنود التكلفة الافتراضية + cost_items = ["تسوية الأرض", "البنية التحتية", "النقل", "الحماية من المخاطر", "توصيل الخدمات"] + + site_1_costs = [random.uniform(50000, 200000) for _ in range(len(cost_items))] + site_2_costs = [random.uniform(50000, 200000) for _ in range(len(cost_items))] + + # إنشاء DataFrame للتكاليف + cost_df = pd.DataFrame({ + "بند التكلفة": cost_items, + f"{project_1['name']} (ريال)": site_1_costs, + f"{project_2['name']} (ريال)": site_2_costs + }) + + # عرض جدول التكاليف + st.dataframe(cost_df, width=800) + + # حساب إجمالي التكلفة لكل موقع + total_cost_1 = sum(site_1_costs) + total_cost_2 = sum(site_2_costs) + + # عرض إجمالي التكلفة + col1, col2 = st.columns(2) + + with col1: + st.metric( + f"إجمالي تكلفة {project_1['name']}", + f"{format_currency(total_cost_1)} ريال" + ) + + with col2: + st.metric( + f"إجمالي تكلفة {project_2['name']}", + f"{format_currency(total_cost_2)} ريال", + f"{format_currency(total_cost_2 - total_cost_1)}" + ) + + # تحليل إضافي للمقارنة + st.markdown("#### ملخص المقارنة") + + comparison_summary = f""" + بناءً على التحليل المقدم، يمكن استخلاص الملاحظات التالية: + + 1. **المسافة بين الموقعين:** {distance:.2f} كيلومتر. + 2. **التقييم:** {project_1['name']} بتقييم {project_1_score:.2f}/1.0، و{project_2['name']} بتقييم {project_2_score:.2f}/1.0. + 3. **التكلفة:** {project_1['name']} بتكلفة {format_currency(total_cost_1)} ريال، و{project_2['name']} بتكلفة {format_currency(total_cost_2)} ريال. + + بالنظر إلى العوامل أعلاه، فإن الموقع **{preferred_site}** هو الخيار الأفضل من حيث التوازن بين التقييم والتكلفة. + """ + + st.markdown(comparison_summary) + else: + st.error("لم يتم العثور على أحد المشروعين المحددين.") + + def _render_location_management(self): + """عرض إدارة المواقع""" + st.markdown(""" +
+

📍 إدارة مواقع المشاريع

+

إضافة وتعديل مواقع المشاريع وتصدير واستيراد البيانات.

+

يمكنك إدخال مواقع المشاريع الجديدة وتعديل المواقع الموجودة وحذفها.

+
+ """, unsafe_allow_html=True) + + # تبويبات فرعية للإدارة + subtabs = st.tabs([ + "إضافة موقع جديد", + "تحرير المواقع", + "استيراد/تصدير المواقع" + ]) + + # تبويب إضافة موقع جديد + with subtabs[0]: + self._render_add_location() + + # تبويب تحرير المواقع + with subtabs[1]: + self._render_edit_locations() + + # تبويب استيراد/تصدير المواقع + with subtabs[2]: + self._render_import_export_locations() + + def _render_add_location(self): + """عرض نموذج إضافة موقع جديد""" + st.markdown("### إضافة موقع مشروع جديد") + + # نموذج إضافة موقع جديد + with st.form(key="add_location_form"): + # معلومات أساسية + project_name = st.text_input("اسم المشروع", key="new_project_name") + project_description = st.text_area("وصف المشروع", key="new_project_description") + + # معلومات الموقع + col1, col2 = st.columns(2) + + with col1: + city = st.text_input("المدينة", key="new_city") + status = st.selectbox( + "حالة المشروع", + options=["مخطط", "قيد التنفيذ", "متوقف", "مكتمل"], + key="new_status" + ) + + with col2: + latitude = st.number_input("خط العرض", value=24.7136, step=0.0001, format="%.6f", key="new_latitude") + longitude = st.number_input("خط الطول", value=46.6753, step=0.0001, format="%.6f", key="new_longitude") + + # عرض الموقع على خريطة صغيرة + mini_map = folium.Map( + location=[latitude, longitude], + zoom_start=10, + attr='© OpenStreetMap contributors' + ) + folium.Marker(location=[latitude, longitude], tooltip="الموقع المحدد").add_to(mini_map) + folium_static(mini_map, width=700, height=300) + + # زر الإضافة + submit_button = st.form_submit_button("إضافة الموقع") + + # معالجة النموذج عند الإرسال + if submit_button: + if not project_name: + st.error("يرجى إدخال اسم المشروع.") + else: + # إنشاء معرف فريد للمشروع + project_id = f"PRJ{len(st.session_state.project_locations) + 1:03d}" + + # إضافة المشروع الجديد + new_project = { + "project_id": project_id, + "name": project_name, + "description": project_description, + "city": city, + "status": status, + "latitude": latitude, + "longitude": longitude + } + + # إضافة المشروع إلى القائمة + st.session_state.project_locations.append(new_project) + + # حفظ البيانات + self._save_locations_data() + + # عرض رسالة نجاح + st.success(f"تمت إضافة موقع المشروع '{project_name}' بنجاح.") + + # إعادة تحميل الصفحة + st.rerun() + + def _render_edit_locations(self): + """عرض واجهة تحرير المواقع الموجودة""" + st.markdown("### تحرير مواقع المشاريع") + + if len(st.session_state.project_locations) == 0: + st.warning("لا توجد مواقع مشاريع للتحرير. يرجى إضافة مواقع أولاً.") + return + + # عرض قائمة المشاريع + projects_df = pd.DataFrame(st.session_state.project_locations) + + # إعادة تسمية الأعمدة بالعربية + renamed_columns = { + "name": "اسم المشروع", + "city": "المدينة", + "status": "الحالة", + "description": "الوصف", + "project_id": "معرف المشروع", + "latitude": "خط العرض", + "longitude": "خط الطول" + } + + # تحديد الأعمدة للعرض + display_columns = ["project_id", "name", "city", "status"] + + # إنشاء جدول للعرض + display_df = projects_df[display_columns].rename(columns=renamed_columns) + + # عرض الجدول + st.dataframe(display_df, width=800, height=300) + + # اختيار مشروع للتحرير + selected_project_id = st.selectbox( + "اختر مشروعًا للتحرير", + options=projects_df["project_id"].tolist(), + format_func=lambda x: next((p["name"] for p in st.session_state.project_locations if p["project_id"] == x), x), + key="edit_project_id" + ) + + # العثور على المشروع المحدد + selected_project_index = next((i for i, p in enumerate(st.session_state.project_locations) if p["project_id"] == selected_project_id), None) + + if selected_project_index is not None: + selected_project = st.session_state.project_locations[selected_project_index] + + # نموذج تحرير المشروع + with st.form(key="edit_location_form"): + st.markdown(f"### تحرير مشروع: {selected_project['name']}") + + # معلومات أساسية + project_name = st.text_input("اسم المشروع", value=selected_project["name"], key="edit_project_name") + project_description = st.text_area("وصف المشروع", value=selected_project.get("description", ""), key="edit_project_description") + + # معلومات الموقع + col1, col2 = st.columns(2) + + with col1: + city = st.text_input("المدينة", value=selected_project.get("city", ""), key="edit_city") + status = st.selectbox( + "حالة المشروع", + options=["مخطط", "قيد التنفيذ", "متوقف", "مكتمل"], + index=["مخطط", "قيد التنفيذ", "متوقف", "مكتمل"].index(selected_project.get("status", "مخطط")), + key="edit_status" + ) + + with col2: + latitude = st.number_input("خط العرض", value=selected_project["latitude"], step=0.0001, format="%.6f", key="edit_latitude") + longitude = st.number_input("خط الطول", value=selected_project["longitude"], step=0.0001, format="%.6f", key="edit_longitude") + + # عرض الموقع على خريطة صغيرة + mini_map = folium.Map( + location=[latitude, longitude], + zoom_start=10, + attr='© OpenStreetMap contributors' + ) + folium.Marker(location=[latitude, longitude], tooltip="الموقع المحدد").add_to(mini_map) + folium_static(mini_map, width=700, height=300) + + # أزرار الإجراءات + col1, col2 = st.columns(2) + + with col1: + update_button = st.form_submit_button("تحديث المعلومات") + + with col2: + delete_button = st.form_submit_button("حذف المشروع", type="secondary") + + # معالجة تحديث المعلومات + if update_button: + if not project_name: + st.error("لا يمكن ترك اسم المشروع فارغًا.") + else: + # تحديث معلومات المشروع + st.session_state.project_locations[selected_project_index] = { + "project_id": selected_project["project_id"], + "name": project_name, + "description": project_description, + "city": city, + "status": status, + "latitude": latitude, + "longitude": longitude + } + + # حفظ البيانات + self._save_locations_data() + + # عرض رسالة نجاح + st.success(f"تم تحديث معلومات المشروع '{project_name}' بنجاح.") + + # إعادة تحميل الصفحة + st.rerun() + + # معالجة حذف المشروع + if delete_button: + # نافذة تأكيد الحذف + st.warning(f"هل أنت متأكد من رغبتك في حذف المشروع '{selected_project['name']}'؟") + + confirm_col1, confirm_col2 = st.columns(2) + + with confirm_col1: + if st.button("نعم، حذف المشروع", key="confirm_delete"): + # حذف المشروع + st.session_state.project_locations.pop(selected_project_index) + + # حفظ البيانات + self._save_locations_data() + + # عرض رسالة نجاح + st.success(f"تم حذف المشروع '{selected_project['name']}' بنجاح.") + + # إعادة تحميل الصفحة + st.rerun() + + with confirm_col2: + if st.button("لا، إلغاء الحذف", key="cancel_delete"): + st.rerun() + else: + st.error("لم يتم العثور على المشروع المحدد.") + + def _render_import_export_locations(self): + """عرض واجهة استيراد وتصدير المواقع""" + st.markdown("### استيراد وتصدير مواقع المشاريع") + + # تبويبات فرعية للاستيراد والتصدير + export_tab, import_tab = st.tabs(["تصدير المواقع", "استيراد المواقع"]) + + # تبويب تصدير المواقع + with export_tab: + st.markdown("#### تصدير مواقع المشاريع") + + if len(st.session_state.project_locations) == 0: + st.warning("لا توجد مواقع مشاريع للتصدير.") + else: + # اختيار تنسيق التصدير + export_format = st.radio( + "اختر تنسيق التصدير", + options=["CSV", "Excel", "JSON"], + horizontal=True, + key="export_format" + ) + + # زر التصدير + if styled_button("تصدير المواقع", key="export_btn", type="primary", icon="📤"): + # تصدير البيانات + exported_data = self._export_locations(export_format.lower()) + + if exported_data: + # تحديد نوع الملف ومعلومات التنزيل + if export_format == "CSV": + mime_type = "text/csv" + file_ext = "csv" + elif export_format == "Excel": + mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + file_ext = "xlsx" + else: # JSON + mime_type = "application/json" + file_ext = "json" + + # إنشاء رابط التنزيل + b64 = base64.b64encode(exported_data).decode() + href = f'تنزيل ملف {export_format}' + st.markdown(href, unsafe_allow_html=True) + + # عرض معاينة البيانات + if export_format == "CSV": + st.markdown("#### معاينة البيانات المصدرة") + st.text(exported_data.decode("utf-8")) + elif export_format == "JSON": + st.markdown("#### معاينة البيانات المصدرة") + st.json(json.loads(exported_data.decode("utf-8"))) + + # تبويب استيراد المواقع + with import_tab: + st.markdown("#### استيراد مواقع المشاريع") + + # اختيار تنسيق الاستيراد + import_format = st.radio( + "اختر تنسيق الاستيراد", + options=["CSV", "Excel", "JSON"], + horizontal=True, + key="import_format" + ) + + # تحميل الملف + uploaded_file = st.file_uploader(f"تحميل ملف {import_format}", type=[import_format.lower()]) + + if uploaded_file: + # معاينة الملف + st.markdown("#### معاينة الملف المحمل") + + if import_format == "CSV": + df = pd.read_csv(uploaded_file) + st.dataframe(df) + elif import_format == "Excel": + df = pd.read_excel(uploaded_file) + st.dataframe(df) + else: # JSON + json_data = json.load(uploaded_file) + st.json(json_data) + + # خيارات الاستيراد + import_mode = st.radio( + "طريقة الاستيراد", + options=["إضافة إلى المواقع الحالية", "استبدال جميع المواقع"], + key="import_mode" + ) + + # زر الاستيراد + if styled_button("استيراد المواقع", key="import_btn", type="primary", icon="📥"): + # إعادة قراءة الملف (قد يكون تم استنفاد التدفق) + uploaded_file.seek(0) + + try: + # استيراد البيانات + imported_count = self._import_locations(uploaded_file, import_format.lower()) + + if import_mode == "استبدال جميع المواقع": + st.success(f"تم استبدال جميع المواقع بنجاح. عدد المواقع الجديدة: {imported_count}") + else: + st.success(f"تمت إضافة {imported_count} مواقع جديدة بنجاح.") + + # إعادة تحميل الصفحة + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء استيراد البيانات: {str(e)}") + + def _fetch_terrain_data(self, latitude, longitude, radius_km=5): + """جلب بيانات التضاريس من واجهة برمجة التطبيقات""" + # حساب نطاق الإحداثيات + # 1 درجة تقريبًا = 111 كم + delta = radius_km / 111.0 + + # إنشاء شبكة نقاط + lat_min, lat_max = latitude - delta, latitude + delta + lon_min, lon_max = longitude - delta, longitude + delta + + # عدد نقاط الشبكة + grid_size = 20 + + # إنشاء شبكة إحداثيات + lats = np.linspace(lat_min, lat_max, grid_size) + lons = np.linspace(lon_min, lon_max, grid_size) + + # تهيئة مصفوفة النتائج + results = [] + + # بناء سلسلة الإحداثيات للطلب + locations = [] + for lat in lats: + for lon in lons: + locations.append(f"{lat:.6f},{lon:.6f}") + + # تقسيم الطلبات إلى مجموعات (واجهة البرمجة تقبل 100 نقطة كحد أقصى) + batch_size = 100 + for i in range(0, len(locations), batch_size): + batch = locations[i:i+batch_size] + + # محاولة استخدام خدمة OpenTopoData + try: + url = f"{self.opentopodata_api}?locations={'|'.join(batch)}" + response = requests.get(url) + + if response.status_code == 200: + data = response.json() + if "results" in data: + for result in data["results"]: + if "elevation" in result: + results.append({ + "latitude": result["location"]["lat"], + "longitude": result["location"]["lng"], + "elevation": result["elevation"] + }) + else: + # استخدام بيانات افتراضية في حالة فشل الطلب + st.warning(f"فشل جلب بيانات التضاريس من الخدمة (رمز الحالة: {response.status_code}). استخدام بيانات افتراضية.") + + # إنشاء بيانات افتراضية + for j, loc in enumerate(batch): + lat, lon = map(float, loc.split(",")) + # حساب ارتفاع افتراضي بناءً على المسافة من المركز + dist = self._calculate_distance(latitude, longitude, lat, lon) + # إضافة ضوضاء عشوائية للحصول على تضاريس أكثر واقعية + noise = np.sin(lat * 10) * np.cos(lon * 10) * 50 + elevation = 500 - dist * 100 + noise + + results.append({ + "latitude": lat, + "longitude": lon, + "elevation": max(0, elevation) + }) + except Exception as e: + st.warning(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}. استخدام بيانات افتراضية.") + + # إنشاء بيانات افتراضية + for j, loc in enumerate(batch): + lat, lon = map(float, loc.split(",")) + # حساب ارتفاع افتراضي بناءً على المسافة من المركز + dist = self._calculate_distance(latitude, longitude, lat, lon) + # إضافة ضوضاء عشوائية للحصول على تضاريس أكثر واقعية + noise = np.sin(lat * 10) * np.cos(lon * 10) * 50 + elevation = 500 - dist * 100 + noise + + results.append({ + "latitude": lat, + "longitude": lon, + "elevation": max(0, elevation) + }) + + return results + + def _calculate_distance(self, lat1, lon1, lat2, lon2): + """حساب المسافة بين نقطتين بالكيلومترات باستخدام صيغة هافرساين""" + from math import radians, sin, cos, sqrt, atan2 + + # تحويل الإحداثيات إلى راديان + lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2]) + + # صيغة هافرساين + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 + c = 2 * atan2(sqrt(a), sqrt(1-a)) + distance = 6371 * c # نصف قطر الأرض بالكيلومترات + + return distance + + def _get_color_map(self, scheme): + """الحصول على خريطة الألوان حسب النظام المختار""" + import matplotlib.cm as cm + import matplotlib.colors as colors + + # الحصول على خريطة الألوان + colormap = cm.get_cmap(scheme) + + # إرجاع دالة لتطبيق خريطة الألوان + return lambda x: colors.rgb2hex(colormap(x)) + + def _export_locations(self, format): + """تصدير مواقع المشاريع إلى ملف""" + try: + # تحويل البيانات إلى DataFrame + df = pd.DataFrame(st.session_state.project_locations) + + # تصدير البيانات حسب التنسيق المطلوب + if format == "csv": + csv_data = df.to_csv(index=False).encode("utf-8") + return csv_data + elif format == "excel": + # إنشاء ملف إكسل مؤقت + with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp: + df.to_excel(temp.name, index=False, engine="xlsxwriter") + temp.flush() + + # قراءة الملف كبيانات ثنائية + with open(temp.name, "rb") as f: + excel_data = f.read() + + # حذف الملف المؤقت + os.unlink(temp.name) + + return excel_data + elif format == "json": + json_data = json.dumps(st.session_state.project_locations, ensure_ascii=False, indent=4).encode("utf-8") + return json_data + else: + st.error(f"تنسيق غير مدعوم: {format}") + return None + except Exception as e: + st.error(f"حدث خطأ أثناء تصدير البيانات: {str(e)}") + return None + + def _import_locations(self, uploaded_file, format): + """استيراد مواقع المشاريع من ملف""" + try: + imported_data = [] + + # تحميل البيانات حسب التنسيق + if format == "csv": + df = pd.read_csv(uploaded_file) + imported_data = df.to_dict("records") + elif format == "excel": + df = pd.read_excel(uploaded_file) + imported_data = df.to_dict("records") + elif format == "json": + imported_data = json.load(uploaded_file) + else: + raise ValueError(f"تنسيق غير مدعوم: {format}") + + # التحقق من صحة البيانات + required_fields = ["project_id", "name", "latitude", "longitude"] + + for item in imported_data: + missing_fields = [field for field in required_fields if field not in item] + + if missing_fields: + raise ValueError(f"الحقول المطلوبة مفقودة: {', '.join(missing_fields)}") + + # تحديث البيانات + if "import_mode" in st.session_state and st.session_state.import_mode == "استبدال جميع المواقع": + # استبدال جميع البيانات + st.session_state.project_locations = imported_data + else: + # إضافة البيانات الجديدة فقط + existing_ids = {p["project_id"] for p in st.session_state.project_locations} + new_items = [item for item in imported_data if item["project_id"] not in existing_ids] + st.session_state.project_locations.extend(new_items) + imported_data = new_items + + # حفظ البيانات + self._save_locations_data() + + return len(imported_data) + except Exception as e: + raise Exception(f"حدث خطأ أثناء استيراد البيانات: {str(e)}") + + def _save_locations_data(self): + """حفظ بيانات المواقع""" + try: + # إنشاء مسار الملف + file_path = os.path.join(self.data_dir, "project_locations.json") + + # حفظ البيانات كملف JSON + with open(file_path, "w", encoding="utf-8") as f: + json.dump(st.session_state.project_locations, f, ensure_ascii=False, indent=4) + except Exception as e: + st.error(f"حدث خطأ أثناء حفظ بيانات المواقع: {str(e)}") + + def _load_locations_data(self): + """تحميل بيانات المواقع""" + try: + # إنشاء مسار الملف + file_path = os.path.join(self.data_dir, "project_locations.json") + + # التحقق من وجود الملف + if os.path.exists(file_path): + # تحميل البيانات من ملف JSON + with open(file_path, "r", encoding="utf-8") as f: + st.session_state.project_locations = json.load(f) + else: + # تهيئة بيانات اختبارية + self._initialize_sample_projects() + except Exception as e: + st.error(f"حدث خطأ أثناء تحميل بيانات المواقع: {str(e)}") + # تهيئة بيانات اختبارية + self._initialize_sample_projects() + + def _initialize_sample_projects(self): + """تهيئة بيانات اختبارية للمشاريع""" + # قائمة بأسماء مدن المملكة العربية السعودية + saudi_cities = [ + {"name": "الرياض", "lat": 24.7136, "lon": 46.6753}, + {"name": "جدة", "lat": 21.4858, "lon": 39.1925}, + {"name": "مكة المكرمة", "lat": 21.3891, "lon": 39.8579}, + {"name": "المدينة المنورة", "lat": 24.5247, "lon": 39.5692}, + {"name": "الدمام", "lat": 26.4207, "lon": 50.0888}, + {"name": "الطائف", "lat": 21.2704, "lon": 40.4157}, + {"name": "تبوك", "lat": 28.3835, "lon": 36.5662}, + {"name": "بريدة", "lat": 26.3267, "lon": 43.9717}, + {"name": "الخبر", "lat": 26.2172, "lon": 50.1971}, + {"name": "أبها", "lat": 18.2164, "lon": 42.5053} + ] + + # قائمة بأنواع المشاريع + project_types = [ + "إنشاء مبنى سكني", + "تطوير طريق سريع", + "بناء جسر", + "إنشاء مدرسة", + "تطوير حديقة عامة", + "بناء مستشفى", + "إنشاء محطة تحلية مياه", + "تطوير مركز تجاري", + "بناء مصنع", + "توسعة مطار" + ] + + # قائمة بحالات المشاريع + project_statuses = ["مخطط", "قيد التنفيذ", "متوقف", "مكتمل"] + + # إنشاء مشاريع اختبارية + sample_projects = [] + + for i in range(10): + city = saudi_cities[i] + + # إضافة اختلاف عشوائي صغير للإحداثيات + lat_offset = random.uniform(-0.05, 0.05) + lon_offset = random.uniform(-0.05, 0.05) + + project = { + "project_id": f"PRJ{i+1:03d}", + "name": f"{project_types[i]} في {city['name']}", + "description": f"مشروع {project_types[i]} بمدينة {city['name']}. هذا وصف اختباري للمشروع يوضح تفاصيله وأهدافه ونطاق العمل.", + "city": city["name"], + "status": random.choice(project_statuses), + "latitude": city["lat"] + lat_offset, + "longitude": city["lon"] + lon_offset + } + + sample_projects.append(project) + + # حفظ المشاريع الاختبارية في حالة الجلسة + st.session_state.project_locations = sample_projects + + +if __name__ == "__main__": + """تشغيل وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد بشكل مستقل""" + interactive_map = InteractiveMap() + interactive_map.render() \ No newline at end of file diff --git a/modules/maps/interactive_map.py.bak b/modules/maps/interactive_map.py.bak new file mode 100644 index 0000000000000000000000000000000000000000..9a161132b1327788cd1ec7b6e9c035ecf8f4aa00 --- /dev/null +++ b/modules/maps/interactive_map.py.bak @@ -0,0 +1,1647 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد +تتيح هذه الوحدة عرض مواقع المشاريع على خريطة تفاعلية مع إمكانية رؤية التضاريس بشكل ثلاثي الأبعاد +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import pydeck as pdk +import folium +from folium.plugins import MarkerCluster, HeatMap, MeasureControl +from streamlit_folium import folium_static +import requests +import json +import random +from typing import List, Dict, Any, Tuple, Optional +import tempfile +import base64 +from PIL import Image +from io import BytesIO + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +from utils.components.credits import render_credits +from utils.helpers import format_number, format_currency, styled_button + + +class InteractiveMap: + """فئة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد""" + + def __init__(self): + """تهيئة وحدة الخريطة التفاعلية""" + # تهيئة مجلدات حفظ البيانات + self.data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../data/maps")) + os.makedirs(self.data_dir, exist_ok=True) + + # مفاتيح API لخدمات الخرائط + self.mapbox_token = os.environ.get("MAPBOX_TOKEN", "") + self.opentopodata_api = "https://api.opentopodata.org/v1/srtm30m" + + # تهيئة حالة الجلسة + if 'project_locations' not in st.session_state: + st.session_state.project_locations = [] + + if 'selected_location' not in st.session_state: + st.session_state.selected_location = None + + if 'terrain_data' not in st.session_state: + st.session_state.terrain_data = None + + # بيانات اختبارية للمشاريع (سيتم استبدالها بالبيانات الفعلية من قاعدة البيانات) + self._initialize_sample_projects() + + def render(self): + """عرض واجهة وحدة الخريطة التفاعلية""" + # عرض الشعار والعنوان الرئيسي + render_header("خريطة مواقع المشاريع التفاعلية") + + # تبويبات الوحدة + tabs = st.tabs([ + "الخريطة التفاعلية", + "عرض التضاريس ثلاثي الأبعاد", + "تحليل المواقع", + "إدارة المواقع" + ]) + + # تبويب الخريطة التفاعلية + with tabs[0]: + self._render_interactive_map() + + # تبويب عرض التضاريس ثلاثي الأبعاد + with tabs[1]: + self._render_3d_terrain() + + # تبويب تحليل المواقع + with tabs[2]: + self._render_location_analysis() + + # تبويب إدارة المواقع + with tabs[3]: + self._render_location_management() + + # عرض حقوق النشر + render_credits() + + def _render_interactive_map(self): + """عرض الخريطة التفاعلية""" + st.markdown(""" +
+

🗺️ الخريطة التفاعلية لمواقع المشاريع

+

خريطة تفاعلية تعرض مواقع جميع المشاريع مع إمكانية التكبير والتصغير والتحرك.

+

يمكنك النقر على أي موقع للحصول على تفاصيل المشروع.

+
+ """, unsafe_allow_html=True) + + # مربع البحث + search_query = st.text_input("البحث عن موقع أو مشروع", key="map_search") + + # أزرار تحكم للخريطة + col1, col2, col3, col4 = st.columns(4) + + with col1: + map_style = st.selectbox( + "نمط الخريطة", + options=["OpenStreetMap", "Stamen Terrain", "Stamen Toner", "CartoDB Positron"], + key="map_style" + ) + + with col2: + cluster_markers = st.checkbox("تجميع المواقع القريبة", value=True, key="cluster_markers") + + with col3: + show_heatmap = st.checkbox("عرض خريطة حرارية للمواقع", value=False, key="show_heatmap") + + with col4: + show_measurements = st.checkbox("أدوات القياس", value=False, key="show_measurements") + + # إنشاء الخريطة + if len(st.session_state.project_locations) > 0: + # بيانات النقاط على الخريطة + locations = [] + + # تصفية المشاريع حسب البحث + filtered_projects = st.session_state.project_locations + if search_query: + filtered_projects = [ + p for p in filtered_projects + if search_query.lower() in p.get("name", "").lower() or + search_query.lower() in p.get("description", "").lower() or + search_query.lower() in p.get("city", "").lower() + ] + + # عرض عدد النتائج + if search_query: + st.markdown(f"عدد النتائج: {len(filtered_projects)}") + + # تحضير البيانات للخريطة + heat_data = [] + for project in filtered_projects: + locations.append({ + "lat": project.get("latitude"), + "lon": project.get("longitude"), + "name": project.get("name"), + "description": project.get("description"), + "city": project.get("city"), + "status": project.get("status"), + "project_id": project.get("project_id") + }) + heat_data.append([project.get("latitude"), project.get("longitude"), 1]) + + # تعيين نقطة المركز والتكبير + if filtered_projects: + center_lat = sum(p.get("latitude", 0) for p in filtered_projects) / len(filtered_projects) + center_lon = sum(p.get("longitude", 0) for p in filtered_projects) / len(filtered_projects) + zoom_level = 6 # مستوى التكبير الافتراضي + else: + # مركز المملكة العربية السعودية + center_lat = 24.7136 + center_lon = 46.6753 + zoom_level = 5 + + # تحديد الإسناد (attribution) بناءً على نمط الخريطة + attribution = None + if map_style == "OpenStreetMap": + attribution = '© OpenStreetMap contributors' + elif map_style.startswith("Stamen"): + attribution = 'Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under ODbL.' + elif map_style == "CartoDB Positron": + attribution = '© OpenStreetMap contributors, © CartoDB' + + # إنشاء الخريطة + m = folium.Map( + location=[center_lat, center_lon], + zoom_start=zoom_level, + tiles=map_style, + attr=attribution # إضافة سمة الإسناد + ) + + # إضافة أدوات القياس إذا تم اختيارها + if show_measurements: + MeasureControl(position='topright', primary_length_unit='kilometers').add_to(m) + + # إضافة النقاط إلى الخريطة + if cluster_markers: + # إنشاء مجموعة تجميع + marker_cluster = MarkerCluster(name="تجميع المشاريع").add_to(m) + + # إضافة النقاط إلى المجموعة + for location in locations: + # إنشاء النافذة المنبثقة + popup_html = f""" +
+

{location['name']}

+

الوصف: {location['description']}

+

المدينة: {location['city']}

+

الحالة: {location['status']}

+

الإحداثيات: {location['lat']:.6f}, {location['lon']:.6f}

+ +
+ """ + + # تحديد لون العلامة حسب حالة المشروع + icon_color = 'green' + if location['status'] == 'قيد التنفيذ': + icon_color = 'orange' + elif location['status'] == 'متوقف': + icon_color = 'red' + elif location['status'] == 'مكتمل': + icon_color = 'blue' + + # إضافة العلامة + folium.Marker( + location=[location['lat'], location['lon']], + popup=folium.Popup(popup_html, max_width=300), + tooltip=location['name'], + icon=folium.Icon(color=icon_color, icon='info-sign') + ).add_to(marker_cluster) + else: + # إضافة النقاط مباشرة إلى الخريطة + for location in locations: + # إنشاء النافذة المنبثقة + popup_html = f""" +
+

{location['name']}

+

الوصف: {location['description']}

+

المدينة: {location['city']}

+

الحالة: {location['status']}

+

الإحداثيات: {location['lat']:.6f}, {location['lon']:.6f}

+ +
+ """ + + # تحديد لون العلامة حسب حالة المشروع + icon_color = 'green' + if location['status'] == 'قيد التنفيذ': + icon_color = 'orange' + elif location['status'] == 'متوقف': + icon_color = 'red' + elif location['status'] == 'مكتمل': + icon_color = 'blue' + + # إضافة العلامة + folium.Marker( + location=[location['lat'], location['lon']], + popup=folium.Popup(popup_html, max_width=300), + tooltip=location['name'], + icon=folium.Icon(color=icon_color, icon='info-sign') + ).add_to(m) + + # إضافة خريطة حرارية إذا تم اختيارها + if show_heatmap and heat_data: + HeatMap(heat_data, radius=15).add_to(m) + + # إضافة طبقات متنوعة للخريطة + folium.TileLayer('OpenStreetMap').add_to(m) + folium.TileLayer('Stamen Terrain').add_to(m) + folium.TileLayer('Stamen Toner').add_to(m) + folium.TileLayer('CartoDB positron').add_to(m) + folium.TileLayer('CartoDB dark_matter').add_to(m) + + # إضافة أدوات التحكم بالطبقات + folium.LayerControl().add_to(m) + + # عرض الخريطة + st_map = folium_static(m, width=1000, height=600) + + # التفاعل مع النقر على الخريطة (سيتم تنفيذه عندما يتم دعم التفاعل) + # حاليًا لا يوجد دعم مباشر للتفاعل مع الخريطة في Streamlit + + # عرض بيانات المشاريع في جدول + st.markdown("### قائمة المشاريع على الخريطة") + + projects_df = pd.DataFrame(filtered_projects) + + # إعادة تسمية الأعمدة بالعربية + renamed_columns = { + "name": "اسم المشروع", + "city": "المدينة", + "status": "الحالة", + "description": "الوصف", + "project_id": "معرف المشروع", + "latitude": "خط العرض", + "longitude": "خط الطول" + } + + # تحديد الأعمدة للعرض + display_columns = ["name", "city", "status", "project_id"] + + # إنشاء جدول للعرض + display_df = projects_df[display_columns].rename(columns=renamed_columns) + + # عرض الجدول + st.dataframe(display_df, width=1000, height=400) + + # زر لاختيار مشروع لعرض التضاريس + selected_project_id = st.selectbox( + "اختر مشروعًا لعرض التضاريس ثلاثية الأبعاد", + options=projects_df["project_id"].tolist(), + format_func=lambda x: next((p["name"] for p in filtered_projects if p["project_id"] == x), x), + key="select_project_for_terrain" + ) + + # زر عرض التضاريس + if styled_button("عرض التضاريس", key="show_terrain_btn", type="primary", icon="🏔️"): + # العثور على المشروع المحدد + selected_project = next((p for p in filtered_projects if p["project_id"] == selected_project_id), None) + + if selected_project: + # تخزين الموقع المحدد في حالة الجلسة + st.session_state.selected_location = { + "latitude": selected_project["latitude"], + "longitude": selected_project["longitude"], + "name": selected_project["name"], + "project_id": selected_project["project_id"] + } + + # جلب بيانات التضاريس + try: + terrain_data = self._fetch_terrain_data( + selected_project["latitude"], + selected_project["longitude"] + ) + + # تخزين بيانات التضاريس في حالة الجلسة + st.session_state.terrain_data = terrain_data + + # الانتقال إلى تبويب عرض التضاريس + st.experimental_rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + else: + st.warning("لا توجد مواقع مشاريع متاحة. يرجى إضافة مواقع من تبويب 'إدارة المواقع'.") + + def _render_3d_terrain(self): + """عرض التضاريس ثلاثي الأبعاد""" + st.markdown(""" +
+

🏔️ عرض التضاريس ثلاثي الأبعاد

+

عرض تفاعلي ثلاثي الأبعاد لتضاريس موقع المشروع المحدد.

+

يمكنك تدوير وتكبير العرض للحصول على رؤية أفضل للموقع.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود موقع محدد + if st.session_state.selected_location is None: + st.info("يرجى اختيار موقع من تبويب 'الخريطة التفاعلية' أولاً.") + + # بديل: السماح بإدخال الإحداثيات يدوياً + st.markdown("### إدخال الإحداثيات يدوياً") + + col1, col2 = st.columns(2) + + with col1: + manual_lat = st.number_input("خط العرض", value=24.7136, step=0.0001, format="%.6f", key="manual_lat") + + with col2: + manual_lon = st.number_input("خط الطول", value=46.6753, step=0.0001, format="%.6f", key="manual_lon") + + if styled_button("عرض التضاريس", key="manual_terrain_btn", type="primary", icon="🏔️"): + try: + # جلب بيانات التضاريس + terrain_data = self._fetch_terrain_data(manual_lat, manual_lon) + + # تخزين بيانات التضاريس والموقع في حالة الجلسة + st.session_state.terrain_data = terrain_data + st.session_state.selected_location = { + "latitude": manual_lat, + "longitude": manual_lon, + "name": "موقع مخصص", + "project_id": "custom" + } + + st.success("تم جلب بيانات التضاريس بنجاح!") + st.experimental_rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + + return + + # عرض معلومات الموقع المحدد + location = st.session_state.selected_location + st.markdown(f"### عرض تضاريس موقع: {location['name']}") + st.markdown(f"**الإحداثيات:** {location['latitude']:.6f}, {location['longitude']:.6f}") + + # تجهيز بيانات التضاريس + if st.session_state.terrain_data is None: + # محاولة جلب بيانات التضاريس + try: + terrain_data = self._fetch_terrain_data( + location["latitude"], + location["longitude"] + ) + + # تخزين بيانات التضاريس في حالة الجلسة + st.session_state.terrain_data = terrain_data + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + return + + # استخدام بيانات التضاريس المخزنة + terrain_data = st.session_state.terrain_data + + # عرض نطاق التضاريس وإعدادات الارتفاع + col1, col2, col3 = st.columns(3) + + with col1: + elevation_scale = st.slider( + "مقياس الارتفاع", + min_value=1, + max_value=50, + value=15, + key="elevation_scale" + ) + + with col2: + radius = st.slider( + "نطاق العرض (كم)", + min_value=1, + max_value=20, + value=5, + key="terrain_radius" + ) + + with col3: + color_scheme = st.selectbox( + "نظام الألوان", + options=["terrain", "elevation", "custom"], + key="color_scheme" + ) + + # إنشاء نموذج PyDeck للعرض ثلاثي الأبعاد + try: + # تحويل بيانات التضاريس إلى DataFrame + terrain_df = pd.DataFrame(terrain_data) + + # تعيين حجم الخلية بناءً على النطاق + cell_size = radius * 100 # تحويل الكيلومترات إلى أمتار وتقسيمها + + # إنشاء طبقة التضاريس + terrain_layer = pdk.Layer( + "TerrainLayer", + data=None, + elevation_decoder={ + "elevations": "elevation", + "bounds": terrain_df["bounds"].iloc[0] + }, + texture=None, + elevation_data=terrain_df["terrain"].iloc[0], + elevation_scale=elevation_scale, + color_map=self._get_color_map(color_scheme), + wireframe=True, + pickable=True + ) + + # إنشاء طبقة النقطة المركزية + point_layer = pdk.Layer( + "ScatterplotLayer", + data=[{ + "position": [location["longitude"], location["latitude"]], + "name": location["name"] + }], + get_position="position", + get_radius=100, + get_fill_color=[255, 0, 0, 200], + pickable=True + ) + + # إنشاء عرض PyDeck + INITIAL_VIEW_STATE = pdk.ViewState( + longitude=location["longitude"], + latitude=location["latitude"], + zoom=12, + max_zoom=20, + pitch=45, + bearing=0 + ) + + deck = pdk.Deck( + map_style="mapbox://styles/mapbox/satellite-v9", + initial_view_state=INITIAL_VIEW_STATE, + api_keys={"mapbox": self.mapbox_token} if self.mapbox_token else None, + layers=[terrain_layer, point_layer], + tooltip={ + "html": "{name}", + "style": { + "backgroundColor": "steelblue", + "color": "white" + } + } + ) + + # عرض النموذج ثلاثي الأبعاد + st.pydeck_chart(deck) + + # عرض تحليل التضاريس + if "elevation_stats" in terrain_df: + elevation_stats = terrain_df["elevation_stats"].iloc[0] + + st.markdown("### تحليل التضاريس") + + stats_col1, stats_col2, stats_col3, stats_col4 = st.columns(4) + + with stats_col1: + st.metric("أدنى ارتفاع", f"{elevation_stats['min']:.1f} م") + + with stats_col2: + st.metric("أعلى ارتفاع", f"{elevation_stats['max']:.1f} م") + + with stats_col3: + st.metric("متوسط الارتفاع", f"{elevation_stats['mean']:.1f} م") + + with stats_col4: + st.metric("فرق الارتفاع", f"{elevation_stats['range']:.1f} م") + + # عرض رسم بياني للارتفاعات + if "elevation_profile" in terrain_df: + elevation_profile = terrain_df["elevation_profile"].iloc[0] + + # إنشاء DataFrame للرسم البياني + profile_df = pd.DataFrame(elevation_profile) + + # عرض الرسم البياني + st.markdown("### مقطع الارتفاع") + + # استخدام Plotly Express + import plotly.express as px + + fig = px.line( + profile_df, + x="distance", + y="elevation", + title="مقطع الارتفاع عبر الموقع", + labels={"distance": "المسافة (كم)", "elevation": "الارتفاع (م)"} + ) + + fig.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig, use_container_width=True) + + # أزرار التحكم الإضافية + col1, col2 = st.columns(2) + + with col1: + if styled_button("إعادة تحميل بيانات التضاريس", key="reload_terrain", type="primary", icon="🔄"): + # حذف بيانات التضاريس الحالية + st.session_state.terrain_data = None + st.experimental_rerun() + + with col2: + if styled_button("العودة للخريطة التفاعلية", key="back_to_map", type="secondary", icon="🗺️"): + # إعادة تعيين الموقع المحدد + st.session_state.selected_location = None + st.session_state.terrain_data = None + st.experimental_rerun() + + except Exception as e: + st.error(f"حدث خطأ أثناء عرض التضاريس ثلاثي الأبعاد: {str(e)}") + + def _render_location_analysis(self): + """عرض تحليل المواقع""" + st.markdown(""" +
+

📊 تحليل المواقع

+

تحليل لمواقع المشاريع وتوزيعها الجغرافي.

+

يمكنك عرض إحصائيات وتقارير متنوعة حول مواقع المشاريع.

+
+ """, unsafe_allow_html=True) + + # التحقق من وجود مواقع + if not st.session_state.project_locations: + st.warning("لا توجد مواقع مشاريع متاحة. يرجى إضافة مواقع من تبويب 'إدارة المواقع'.") + return + + # تحويل بيانات المواقع إلى DataFrame + locations_df = pd.DataFrame(st.session_state.project_locations) + + # عرض توزيع المشاريع حسب المدينة + st.markdown("### توزيع المشاريع حسب المدينة") + + city_counts = locations_df["city"].value_counts().reset_index() + city_counts.columns = ["المدينة", "عدد المشاريع"] + + # عرض الرسم البياني + import plotly.express as px + + fig = px.bar( + city_counts, + x="المدينة", + y="عدد المشاريع", + title="توزيع المشاريع حسب المدينة", + color="عدد المشاريع", + color_continuous_scale="Viridis" + ) + + fig.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض توزيع المشاريع حسب الحالة + st.markdown("### توزيع المشاريع حسب الحالة") + + status_counts = locations_df["status"].value_counts().reset_index() + status_counts.columns = ["الحالة", "عدد المشاريع"] + + # عرض الرسم البياني + fig2 = px.pie( + status_counts, + values="عدد المشاريع", + names="الحالة", + title="توزيع المشاريع حسب الحالة", + color_discrete_sequence=px.colors.qualitative.Set3 + ) + + fig2.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig2, use_container_width=True) + + # عرض تحليل المسافات بين المشاريع + st.markdown("### تحليل المسافات بين المشاريع") + + # حساب مصفوفة المسافات + if len(locations_df) > 1: + # اختيار مشروع كنقطة مرجعية + reference_project = st.selectbox( + "اختر مشروعًا كنقطة مرجعية", + options=locations_df["project_id"].tolist(), + format_func=lambda x: next((p["name"] for p in st.session_state.project_locations if p["project_id"] == x), x), + key="reference_project" + ) + + # العثور على المشروع المرجعي + ref_project_data = locations_df[locations_df["project_id"] == reference_project].iloc[0] + + # حساب المسافات + distances = [] + for _, project in locations_df.iterrows(): + if project["project_id"] != reference_project: + distance = self._calculate_distance( + ref_project_data["latitude"], ref_project_data["longitude"], + project["latitude"], project["longitude"] + ) + + distances.append({ + "project_id": project["project_id"], + "name": project["name"], + "city": project["city"], + "distance": distance + }) + + # تحويل البيانات إلى DataFrame + distances_df = pd.DataFrame(distances) + + # ترتيب المشاريع حسب المسافة + distances_df = distances_df.sort_values("distance") + + # عرض المسافات + st.markdown(f"المسافات من مشروع: **{ref_project_data['name']}**") + + # إعادة تسمية الأعمدة + distances_df = distances_df.rename(columns={ + "name": "اسم المشروع", + "city": "المدينة", + "distance": "المسافة (كم)" + }) + + # تنسيق المسافة + distances_df["المسافة (كم)"] = distances_df["المسافة (كم)"].round(2) + + # عرض الجدول + st.dataframe(distances_df[["اسم المشروع", "المدينة", "المسافة (كم)"]], width=800) + + # عرض رسم بياني للمسافات + fig3 = px.bar( + distances_df, + x="اسم المشروع", + y="المسافة (كم)", + title=f"المسافات من مشروع {ref_project_data['name']}", + color="المسافة (كم)", + color_continuous_scale="Viridis" + ) + + fig3.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig3, use_container_width=True) + + # عرض المشاريع القريبة على خريطة + st.markdown("### المشاريع القريبة على الخريطة") + + # إنشاء الخريطة + m2 = folium.Map( + location=[ref_project_data["latitude"], ref_project_data["longitude"]], + zoom_start=8, + tiles="OpenStreetMap", + attr='© OpenStreetMap contributors' + ) + + # إضافة المشروع المرجعي + folium.Marker( + location=[ref_project_data["latitude"], ref_project_data["longitude"]], + popup=ref_project_data["name"], + tooltip=ref_project_data["name"], + icon=folium.Icon(color='red', icon='star') + ).add_to(m2) + + # إضافة الدوائر + folium.Circle( + location=[ref_project_data["latitude"], ref_project_data["longitude"]], + radius=50000, # 50 كم + color='red', + fill=True, + fill_opacity=0.1, + popup="50 كم" + ).add_to(m2) + + folium.Circle( + location=[ref_project_data["latitude"], ref_project_data["longitude"]], + radius=100000, # 100 كم + color='orange', + fill=True, + fill_opacity=0.1, + popup="100 كم" + ).add_to(m2) + + folium.Circle( + location=[ref_project_data["latitude"], ref_project_data["longitude"]], + radius=200000, # 200 كم + color='blue', + fill=True, + fill_opacity=0.1, + popup="200 كم" + ).add_to(m2) + + # إضافة المشاريع الأخرى + for _, project in distances_df.iterrows(): + project_data = locations_df[locations_df["project_id"] == project["project_id"]].iloc[0] + + folium.Marker( + location=[project_data["latitude"], project_data["longitude"]], + popup=f"{project_data['name']} - {project['المسافة (كم)']} كم", + tooltip=project_data["name"], + icon=folium.Icon(color='green', icon='info-sign') + ).add_to(m2) + + # إضافة خط للربط + folium.PolyLine( + locations=[ + [ref_project_data["latitude"], ref_project_data["longitude"]], + [project_data["latitude"], project_data["longitude"]] + ], + color='gray', + weight=2, + opacity=0.5, + popup=f"{project['المسافة (كم)']} كم" + ).add_to(m2) + + # عرض الخريطة + folium_static(m2, width=800, height=500) + else: + st.info("يجب وجود أكثر من مشروع واحد لحساب المسافات.") + + def _render_location_management(self): + """عرض إدارة المواقع""" + st.markdown(""" +
+

⚙️ إدارة المواقع

+

إضافة وتحرير وحذف مواقع المشاريع.

+

يمكنك إضافة مواقع جديدة أو تحديث المواقع الموجودة.

+
+ """, unsafe_allow_html=True) + + # تبويبات إدارة المواقع + management_tabs = st.tabs(["إضافة موقع جديد", "تحرير المواقع الموجودة", "استيراد وتصدير المواقع"]) + + # تبويب إضافة موقع جديد + with management_tabs[0]: + self._render_add_location() + + # تبويب تحرير المواقع الموجودة + with management_tabs[1]: + self._render_edit_locations() + + # تبويب استيراد وتصدير المواقع + with management_tabs[2]: + self._render_import_export_locations() + + def _render_add_location(self): + """عرض نموذج إضافة موقع جديد""" + st.markdown("### إضافة موقع مشروع جديد") + + # البيانات الأساسية + project_name = st.text_input("اسم المشروع", key="new_project_name") + project_desc = st.text_area("وصف المشروع", key="new_project_desc") + + col1, col2 = st.columns(2) + + with col1: + project_city = st.text_input("المدينة", key="new_project_city") + project_status = st.selectbox( + "حالة المشروع", + options=["جديد", "قيد التنفيذ", "متوقف", "مكتمل"], + key="new_project_status" + ) + + with col2: + project_id = st.text_input("معرف المشروع (اختياري)", key="new_project_id", placeholder="سيتم إنشاؤه تلقائيًا إذا تُرك فارغًا") + + # إدخال إحداثيات الموقع + st.markdown("#### إحداثيات الموقع") + location_method = st.radio( + "طريقة تحديد الموقع", + options=["إدخال يدوي", "اختيار من الخريطة"], + key="new_location_method" + ) + + # تحديد الموقع + if location_method == "إدخال يدوي": + loc_col1, loc_col2 = st.columns(2) + + with loc_col1: + latitude = st.number_input("خط العرض", value=24.7136, step=0.0001, format="%.6f", key="new_latitude") + + with loc_col2: + longitude = st.number_input("خط الطول", value=46.6753, step=0.0001, format="%.6f", key="new_longitude") + + # عرض الموقع على خريطة صغيرة + mini_map = folium.Map(location=[latitude, longitude], zoom_start=10) + folium.Marker(location=[latitude, longitude], tooltip="الموقع المحدد").add_to(mini_map) + folium_static(mini_map, width=700, height=300) + else: + st.markdown("#### اختر الموقع من الخريطة") + st.info("انقر على الخريطة لتحديد الموقع.") + + # إنشاء خريطة + m = folium.Map(location=[24.7136, 46.6753], zoom_start=6) + + # إضافة محدد النقر + m.add_child(folium.ClickForMarker(popup="الموقع المحدد")) + + # عرض الخريطة + map_data = folium_static(m, width=700, height=400) + + # استخراج الإحداثيات المحددة (ليس مدعومًا حاليًا في Streamlit) + st.warning("ملاحظة: خاصية النقر على الخريطة غير مدعومة حاليًا في Streamlit. يرجى استخدام الإدخال اليدوي.") + + latitude = st.number_input("خط العرض", value=24.7136, step=0.0001, format="%.6f", key="map_latitude") + longitude = st.number_input("خط الطول", value=46.6753, step=0.0001, format="%.6f", key="map_longitude") + + # زر إضافة الموقع + if styled_button("إضافة الموقع", key="add_location", type="primary", icon="➕"): + if not project_name or not project_desc or not project_city: + st.error("يرجى تعبئة جميع الحقول المطلوبة.") + else: + # إنشاء معرف فريد للمشروع إذا لم يتم تحديده + if not project_id: + project_id = f"PRJ-{len(st.session_state.project_locations) + 1:04d}" + + # إنشاء كائن الموقع + new_location = { + "name": project_name, + "description": project_desc, + "city": project_city, + "status": project_status, + "latitude": latitude, + "longitude": longitude, + "project_id": project_id, + "created_at": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S"), + "updated_at": pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S") + } + + # إضافة الموقع للقائمة + st.session_state.project_locations.append(new_location) + + # حفظ البيانات + self._save_locations_data() + + st.success(f"تمت إضافة موقع المشروع '{project_name}' بنجاح!") + st.balloons() + + def _render_edit_locations(self): + """عرض واجهة تحرير المواقع الموجودة""" + st.markdown("### تحرير أو حذف مواقع المشاريع") + + # التحقق من وجود مواقع + if not st.session_state.project_locations: + st.warning("لا توجد مواقع مشاريع متاحة. يرجى إضافة مواقع أولاً.") + return + + # اختيار المشروع للتحرير + selected_project_id = st.selectbox( + "اختر مشروعًا للتحرير", + options=[p["project_id"] for p in st.session_state.project_locations], + format_func=lambda x: next((p["name"] for p in st.session_state.project_locations if p["project_id"] == x), x), + key="edit_project_select" + ) + + # العثور على المشروع المحدد + selected_project = next((p for p in st.session_state.project_locations if p["project_id"] == selected_project_id), None) + + if selected_project: + # عرض نموذج التحرير + st.markdown(f"### تحرير مشروع: {selected_project['name']}") + + # البيانات الأساسية + project_name = st.text_input("اسم المشروع", value=selected_project["name"], key="edit_project_name") + project_desc = st.text_area("وصف المشروع", value=selected_project["description"], key="edit_project_desc") + + col1, col2 = st.columns(2) + + with col1: + project_city = st.text_input("المدينة", value=selected_project["city"], key="edit_project_city") + project_status = st.selectbox( + "حالة المشروع", + options=["جديد", "قيد التنفيذ", "متوقف", "مكتمل"], + index=["جديد", "قيد التنفيذ", "متوقف", "مكتمل"].index(selected_project["status"]), + key="edit_project_status" + ) + + with col2: + st.text_input("معرف المشروع", value=selected_project["project_id"], disabled=True, key="edit_project_id") + + # إدخال إحداثيات الموقع + st.markdown("#### إحداثيات الموقع") + + # تحديد الموقع + loc_col1, loc_col2 = st.columns(2) + + with loc_col1: + latitude = st.number_input( + "خط العرض", + value=selected_project["latitude"], + step=0.0001, + format="%.6f", + key="edit_latitude" + ) + + with loc_col2: + longitude = st.number_input( + "خط الطول", + value=selected_project["longitude"], + step=0.0001, + format="%.6f", + key="edit_longitude" + ) + + # عرض الموقع على خريطة صغيرة + mini_map = folium.Map(location=[latitude, longitude], zoom_start=10) + folium.Marker(location=[latitude, longitude], tooltip="الموقع المحدد").add_to(mini_map) + folium_static(mini_map, width=700, height=300) + + # أزرار الإجراءات + col1, col2 = st.columns(2) + + with col1: + if styled_button("حفظ التغييرات", key="save_location_changes", type="primary", icon="💾"): + if not project_name or not project_desc or not project_city: + st.error("يرجى تعبئة جميع الحقول المطلوبة.") + else: + # تحديث بيانات المشروع + selected_project["name"] = project_name + selected_project["description"] = project_desc + selected_project["city"] = project_city + selected_project["status"] = project_status + selected_project["latitude"] = latitude + selected_project["longitude"] = longitude + selected_project["updated_at"] = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S") + + # حفظ البيانات + self._save_locations_data() + + st.success(f"تم تحديث بيانات المشروع '{project_name}' بنجاح!") + st.experimental_rerun() + + with col2: + if styled_button("حذف المشروع", key="delete_location", type="danger", icon="🗑️"): + # تأكيد الحذف + st.warning(f"هل أنت متأكد من حذف المشروع '{selected_project['name']}'؟") + + confirm_col1, confirm_col2 = st.columns(2) + + with confirm_col1: + if styled_button("تأكيد الحذف", key="confirm_delete", type="danger", icon="✓"): + # إزالة المشروع من القائمة + st.session_state.project_locations.remove(selected_project) + + # حفظ البيانات + self._save_locations_data() + + st.success(f"تم حذف المشروع '{selected_project['name']}' بنجاح!") + st.experimental_rerun() + + with confirm_col2: + if styled_button("إلغاء", key="cancel_delete", type="secondary", icon="❌"): + st.experimental_rerun() + + def _render_import_export_locations(self): + """عرض واجهة استيراد وتصدير المواقع""" + st.markdown("### استيراد وتصدير مواقع المشاريع") + + col1, col2 = st.columns(2) + + with col1: + st.markdown("#### تصدير المواقع") + + export_format = st.selectbox( + "صيغة التصدير", + options=["CSV", "JSON", "GeoJSON"], + key="export_format" + ) + + if styled_button("تصدير المواقع", key="export_locations", type="primary", icon="📤"): + self._export_locations(export_format) + + with col2: + st.markdown("#### استيراد المواقع") + + import_format = st.selectbox( + "صيغة الاستيراد", + options=["CSV", "JSON", "GeoJSON"], + key="import_format" + ) + + uploaded_file = st.file_uploader( + "اختر ملف للاستيراد", + type=["csv", "json", "geojson"], + key="import_locations_file" + ) + + if uploaded_file is not None: + if styled_button("استيراد المواقع", key="import_locations", type="success", icon="📥"): + self._import_locations(uploaded_file, import_format) + + # عرض إحصائيات البيانات + st.markdown("### إحصائيات البيانات") + + stats_col1, stats_col2, stats_col3 = st.columns(3) + + with stats_col1: + st.metric("عدد المشاريع", len(st.session_state.project_locations)) + + with stats_col2: + cities = set(p["city"] for p in st.session_state.project_locations) + st.metric("عدد المدن", len(cities)) + + with stats_col3: + statuses = {} + for p in st.session_state.project_locations: + statuses[p["status"]] = statuses.get(p["status"], 0) + 1 + + status_str = ", ".join([f"{k}: {v}" for k, v in statuses.items()]) + st.metric("توزيع الحالات", status_str if statuses else "لا توجد بيانات") + + # خيارات متقدمة + with st.expander("خيارات متقدمة"): + if styled_button("حذف جميع المواقع", key="clear_locations", type="danger", icon="🗑️"): + # تأكيد الحذف + st.warning("هل أنت متأكد من حذف جميع مواقع المشاريع؟ لا يمكن التراجع عن هذا الإجراء.") + + confirm_col1, confirm_col2 = st.columns(2) + + with confirm_col1: + if styled_button("تأكيد الحذف", key="confirm_clear", type="danger", icon="✓"): + # مسح القائمة + st.session_state.project_locations = [] + + # حفظ البيانات + self._save_locations_data() + + st.success("تم حذف جميع مواقع المشاريع بنجاح!") + st.experimental_rerun() + + with confirm_col2: + if styled_button("إلغاء", key="cancel_clear", type="secondary", icon="❌"): + st.experimental_rerun() + + def _fetch_terrain_data(self, latitude, longitude, radius_km=5): + """جلب بيانات التضاريس من واجهة برمجة التطبيقات""" + try: + # تحديث حالة الجلسة + import plotly.express as px + + # تعيين الإحداثيات وحجم المنطقة + center_lat, center_lon = latitude, longitude + + # تحويل نصف القطر من كم إلى درجات (تقريبي) + radius_deg = radius_km / 111.0 # تقريب: 1 درجة = 111 كم + + # تحديد حدود المنطقة + min_lat = center_lat - radius_deg + max_lat = center_lat + radius_deg + min_lon = center_lon - radius_deg + max_lon = center_lon + radius_deg + + # إنشاء شبكة من النقاط + resolution = 50 # عدد النقاط في كل اتجاه + lats = np.linspace(min_lat, max_lat, resolution) + lons = np.linspace(min_lon, max_lon, resolution) + + # إنشاء مصفوفة للإحداثيات + grid_lats, grid_lons = np.meshgrid(lats, lons) + + # تحويل الشبكة إلى قائمة من النقاط + points = [] + for i in range(grid_lats.shape[0]): + for j in range(grid_lats.shape[1]): + points.append((grid_lats[i, j], grid_lons[i, j])) + + # تقسيم النقاط إلى مجموعات لتقليل عدد الطلبات + batch_size = 100 + batches = [points[i:i + batch_size] for i in range(0, len(points), batch_size)] + + # إنشاء بيانات التضاريس + elevation_data = np.zeros((len(lats), len(lons))) + + # محاكاة بيانات التضاريس (يمكن استبدالها بواجهة برمجة تطبيقات حقيقية) + for batch_idx, batch in enumerate(batches): + # في بيئة الإنتاج، سيتم استبدال هذا بطلب API حقيقي + # هنا نقوم بمحاكاة بيانات التضاريس لأغراض العرض + for point_idx, (lat, lon) in enumerate(batch): + # حساب المؤشر في مصفوفة الارتفاع + lat_idx = np.abs(lats - lat).argmin() + lon_idx = np.abs(lons - lon).argmin() + + # محاكاة الارتفاع (في بيئة الإنتاج سيكون هذا من واجهة برمجة التطبيقات) + # هنا نصنع تضاريس اصطناعية باستخدام دالة جيبية + dist_from_center = np.sqrt( + (lat - center_lat) ** 2 + (lon - center_lon) ** 2 + ) + + # إنشاء بعض التلال والوديان الاصطناعية + elevation = 500 + 200 * np.sin(dist_from_center * 100) + 100 * np.cos(lat * 30) + 150 * np.sin(lon * 40) + + # إضافة بعض الضوضاء العشوائية + elevation += np.random.normal(0, 30) + + # تخزين الارتفاع + elevation_data[lat_idx, lon_idx] = elevation + + # حساب إحصائيات الارتفاع + elevation_stats = { + "min": float(np.min(elevation_data)), + "max": float(np.max(elevation_data)), + "mean": float(np.mean(elevation_data)), + "range": float(np.max(elevation_data) - np.min(elevation_data)) + } + + # إنشاء مقطع ارتفاع من الشمال إلى الجنوب عبر المركز + center_lon_idx = np.abs(lons - center_lon).argmin() + ns_profile = [] + for i, lat in enumerate(lats): + ns_profile.append({ + "distance": (lat - min_lat) * 111.0, # تحويل الدرجات إلى كم + "elevation": float(elevation_data[i, center_lon_idx]) + }) + + # إنشاء مقطع ارتفاع من الشرق إلى الغرب عبر المركز + center_lat_idx = np.abs(lats - center_lat).argmin() + ew_profile = [] + for i, lon in enumerate(lons): + ew_profile.append({ + "distance": (lon - min_lon) * 111.0 * np.cos(np.radians(center_lat)), # تحويل الدرجات إلى كم مع تصحيح خط العرض + "elevation": float(elevation_data[center_lat_idx, i]) + }) + + # دمج المقاطع + elevation_profile = ns_profile + ew_profile + + # تحضير بيانات التضاريس للعرض ثلاثي الأبعاد + bounds = [min_lon, min_lat, max_lon, max_lat] + + # تحويل مصفوفة الارتفاع إلى تنسيق مناسب لـ PyDeck + terrain_array = elevation_data.astype(np.float32) + + # إنشاء كائن للتضاريس + terrain_data = [{ + "bounds": bounds, + "terrain": terrain_array.tolist(), + "elevation_stats": elevation_stats, + "elevation_profile": elevation_profile + }] + + return terrain_data + + except Exception as e: + st.error(f"حدث خطأ أثناء جلب بيانات التضاريس: {str(e)}") + raise e + + def _calculate_distance(self, lat1, lon1, lat2, lon2): + """حساب المسافة بين نقطتين بالكيلومترات باستخدام صيغة هافرساين""" + import math + + # تحويل الإحداثيات إلى راديان + lat1 = math.radians(lat1) + lon1 = math.radians(lon1) + lat2 = math.radians(lat2) + lon2 = math.radians(lon2) + + # صيغة هافرساين + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2 + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a)) + distance = 6371 * c # نصف قطر الأرض بالكيلومترات + + return distance + + def _get_color_map(self, scheme): + """الحصول على خريطة الألوان حسب النظام المختار""" + if scheme == "terrain": + return [ + [0, (0, 50, 0)], + [0.1, (0, 100, 0)], + [0.25, (0, 150, 0)], + [0.4, (200, 170, 0)], + [0.6, (150, 100, 0)], + [0.8, (100, 50, 0)], + [1, (200, 200, 200)] + ] + elif scheme == "elevation": + return [ + [0, (0, 0, 100)], + [0.2, (0, 100, 150)], + [0.4, (0, 150, 50)], + [0.6, (150, 150, 0)], + [0.8, (150, 50, 0)], + [1, (100, 0, 0)] + ] + else: # custom + return [ + [0, (30, 100, 200)], + [0.3, (60, 170, 250)], + [0.5, (200, 220, 150)], + [0.7, (180, 120, 60)], + [0.9, (110, 60, 30)], + [1, (80, 30, 10)] + ] + + def _export_locations(self, format): + """تصدير مواقع المشاريع إلى ملف""" + try: + if not st.session_state.project_locations: + st.error("لا توجد مواقع مشاريع للتصدير.") + return + + if format == "CSV": + # تصدير إلى CSV + df = pd.DataFrame(st.session_state.project_locations) + + csv_data = df.to_csv(index=False) + + st.download_button( + label="تنزيل ملف CSV", + data=csv_data, + file_name="project_locations.csv", + mime="text/csv" + ) + + elif format == "JSON": + # تصدير إلى JSON + json_data = json.dumps(st.session_state.project_locations, ensure_ascii=False, indent=2) + + st.download_button( + label="تنزيل ملف JSON", + data=json_data, + file_name="project_locations.json", + mime="application/json" + ) + + elif format == "GeoJSON": + # تصدير إلى GeoJSON + features = [] + + for location in st.session_state.project_locations: + feature = { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [location["longitude"], location["latitude"]] + }, + "properties": { + "name": location["name"], + "description": location["description"], + "city": location["city"], + "status": location["status"], + "project_id": location["project_id"], + "created_at": location.get("created_at", ""), + "updated_at": location.get("updated_at", "") + } + } + + features.append(feature) + + geojson = { + "type": "FeatureCollection", + "features": features + } + + geojson_data = json.dumps(geojson, ensure_ascii=False, indent=2) + + st.download_button( + label="تنزيل ملف GeoJSON", + data=geojson_data, + file_name="project_locations.geojson", + mime="application/geo+json" + ) + + st.success(f"تم تصدير {len(st.session_state.project_locations)} موقع بنجاح!") + + except Exception as e: + st.error(f"حدث خطأ أثناء تصدير المواقع: {str(e)}") + + def _import_locations(self, uploaded_file, format): + """استيراد مواقع المشاريع من ملف""" + try: + if format == "CSV": + # استيراد من CSV + df = pd.read_csv(uploaded_file) + + # التحقق من وجود الأعمدة المطلوبة + required_columns = ["name", "latitude", "longitude"] + missing_columns = [col for col in required_columns if col not in df.columns] + + if missing_columns: + st.error(f"الملف لا يحتوي على الأعمدة التالية: {', '.join(missing_columns)}") + return + + # تحويل DataFrame إلى قائمة من القواميس + imported_locations = df.to_dict("records") + + elif format == "JSON": + # استيراد من JSON + imported_locations = json.loads(uploaded_file.read()) + + elif format == "GeoJSON": + # استيراد من GeoJSON + geojson = json.loads(uploaded_file.read()) + + # التحقق من صحة التنسيق + if "type" not in geojson or geojson["type"] != "FeatureCollection" or "features" not in geojson: + st.error("تنسيق GeoJSON غير صحيح.") + return + + # تحويل المميزات إلى مواقع + imported_locations = [] + + for feature in geojson["features"]: + if feature["type"] == "Feature" and feature["geometry"]["type"] == "Point": + coords = feature["geometry"]["coordinates"] + properties = feature["properties"] + + location = { + "name": properties.get("name", ""), + "description": properties.get("description", ""), + "city": properties.get("city", ""), + "status": properties.get("status", "جديد"), + "longitude": coords[0], + "latitude": coords[1], + "project_id": properties.get("project_id", f"PRJ-{len(imported_locations)+1:04d}"), + "created_at": properties.get("created_at", pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")), + "updated_at": properties.get("updated_at", pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S")) + } + + imported_locations.append(location) + + # التحقق من وجود البيانات المطلوبة في الملف المستورد + valid_locations = [] + for location in imported_locations: + # التحقق من وجود الحقول المطلوبة + if "name" not in location or "latitude" not in location or "longitude" not in location: + continue + + # إضافة القيم الافتراضية إذا لم تكن موجودة + if "description" not in location: + location["description"] = "" + + if "city" not in location: + location["city"] = "" + + if "status" not in location: + location["status"] = "جديد" + + if "project_id" not in location: + location["project_id"] = f"PRJ-{len(valid_locations)+1:04d}" + + if "created_at" not in location: + location["created_at"] = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S") + + if "updated_at" not in location: + location["updated_at"] = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M:%S") + + valid_locations.append(location) + + if not valid_locations: + st.error("لم يتم العثور على مواقع صالحة في الملف.") + return + + # سؤال المستخدم عن كيفية الاستيراد + import_mode = st.radio( + "كيفية الاستيراد", + options=["إضافة إلى المواقع الموجودة", "استبدال المواقع الموجودة"], + key="import_mode" + ) + + if styled_button("تأكيد الاستيراد", key="confirm_import", type="success", icon="✓"): + if import_mode == "إضافة إلى المواقع الموجودة": + # إضافة المواقع المستوردة إلى القائمة الحالية + st.session_state.project_locations.extend(valid_locations) + else: + # استبدال المواقع الموجودة بالمواقع المستوردة + st.session_state.project_locations = valid_locations + + # حفظ البيانات + self._save_locations_data() + + st.success(f"تم استيراد {len(valid_locations)} موقع بنجاح!") + st.experimental_rerun() + + except Exception as e: + st.error(f"حدث خطأ أثناء استيراد المواقع: {str(e)}") + + def _save_locations_data(self): + """حفظ بيانات المواقع""" + try: + # التأكد من وجود المجلد + os.makedirs(self.data_dir, exist_ok=True) + + # حفظ البيانات + locations_file = os.path.join(self.data_dir, "project_locations.json") + + with open(locations_file, 'w', encoding='utf-8') as f: + json.dump(st.session_state.project_locations, f, ensure_ascii=False, indent=2) + except Exception as e: + st.error(f"حدث خطأ أثناء حفظ بيانات المواقع: {str(e)}") + + def _load_locations_data(self): + """تحميل بيانات المواقع""" + try: + # التحقق من وجود الملف + locations_file = os.path.join(self.data_dir, "project_locations.json") + + if os.path.exists(locations_file): + with open(locations_file, 'r', encoding='utf-8') as f: + locations = json.load(f) + + # تحديث حالة الجلسة + st.session_state.project_locations = locations + except Exception as e: + st.error(f"حدث خطأ أثناء تحميل بيانات المواقع: {str(e)}") + + def _initialize_sample_projects(self): + """تهيئة بيانات اختبارية للمشاريع""" + # التحقق من وجود بيانات محفوظة + locations_file = os.path.join(self.data_dir, "project_locations.json") + + if os.path.exists(locations_file): + # تحميل البيانات المحفوظة + self._load_locations_data() + return + + # إنشاء بيانات اختبارية إذا لم تكن هناك بيانات محفوظة + sample_projects = [ + { + "name": "تطوير شبكة الطرق في منطقة الرياض", + "description": "مشروع تطوير وتوسعة شبكة الطرق الرئيسية في منطقة الرياض", + "city": "الرياض", + "status": "قيد التنفيذ", + "latitude": 24.7136, + "longitude": 46.6753, + "project_id": "PRJ-0001", + "created_at": "2025-01-15 10:30:00", + "updated_at": "2025-01-15 10:30:00" + }, + { + "name": "إنشاء سد وادي حنيفة", + "description": "مشروع إنشاء سد لحجز مياه الأمطار في وادي حنيفة", + "city": "الرياض", + "status": "جديد", + "latitude": 24.6748, + "longitude": 46.5831, + "project_id": "PRJ-0002", + "created_at": "2025-02-01 14:45:00", + "updated_at": "2025-02-01 14:45:00" + }, + { + "name": "تطوير ميناء جدة الإسلامي", + "description": "مشروع تطوير وتوسعة ميناء جدة الإسلامي لزيادة الطاقة الاستيعابية", + "city": "جدة", + "status": "قيد التنفيذ", + "latitude": 21.4858, + "longitude": 39.1925, + "project_id": "PRJ-0003", + "created_at": "2024-11-20 09:15:00", + "updated_at": "2024-11-20 09:15:00" + }, + { + "name": "إنشاء مطار الدمام الجديد", + "description": "مشروع إنشاء مطار جديد في مدينة الدمام لتلبية الطلب المتزايد", + "city": "الدمام", + "status": "متوقف", + "latitude": 26.4207, + "longitude": 50.0888, + "project_id": "PRJ-0004", + "created_at": "2024-10-05 11:30:00", + "updated_at": "2024-10-05 11:30:00" + }, + { + "name": "توسعة جامعة الملك فهد للبترول والمعادن", + "description": "مشروع توسعة مباني ومرافق جامعة الملك فهد للبترول والمعادن", + "city": "الظهران", + "status": "قيد التنفيذ", + "latitude": 26.3927, + "longitude": 50.1150, + "project_id": "PRJ-0005", + "created_at": "2025-01-10 08:00:00", + "updated_at": "2025-01-10 08:00:00" + }, + { + "name": "إنشاء محطة تحلية مياه القنفذة", + "description": "مشروع إنشاء محطة تحلية مياه جديدة في محافظة القنفذة", + "city": "القنفذة", + "status": "جديد", + "latitude": 19.1299, + "longitude": 41.0825, + "project_id": "PRJ-0006", + "created_at": "2025-02-20 15:20:00", + "updated_at": "2025-02-20 15:20:00" + }, + { + "name": "تطوير مجمع حكومي في حائل", + "description": "مشروع إنشاء وتطوير مجمع للدوائر الحكومية في مدينة حائل", + "city": "حائل", + "status": "مكتمل", + "latitude": 27.5114, + "longitude": 41.7208, + "project_id": "PRJ-0007", + "created_at": "2024-06-15 10:00:00", + "updated_at": "2024-12-10 14:30:00" + }, + { + "name": "إنشاء مستشفى الإحساء العام", + "description": "مشروع إنشاء مستشفى عام جديد في محافظة الإحساء بسعة 500 سرير", + "city": "الإحساء", + "status": "قيد التنفيذ", + "latitude": 25.3753, + "longitude": 49.5873, + "project_id": "PRJ-0008", + "created_at": "2024-09-01 09:45:00", + "updated_at": "2024-09-01 09:45:00" + }, + { + "name": "تطوير شبكة الصرف الصحي في أبها", + "description": "مشروع تطوير وتوسعة شبكة الصرف الصحي في مدينة أبها", + "city": "أبها", + "status": "جديد", + "latitude": 18.2164, + "longitude": 42.5053, + "project_id": "PRJ-0009", + "created_at": "2025-02-25 11:15:00", + "updated_at": "2025-02-25 11:15:00" + }, + { + "name": "إنشاء مدينة صناعية في سكاكا", + "description": "مشروع إنشاء مدينة صناعية جديدة في منطقة سكاكا", + "city": "سكاكا", + "status": "متوقف", + "latitude": 29.9720, + "longitude": 40.2006, + "project_id": "PRJ-0010", + "created_at": "2024-07-20 13:30:00", + "updated_at": "2024-07-20 13:30:00" + } + ] + + # تحديث حالة الجلسة + st.session_state.project_locations = sample_projects + + # حفظ البيانات + self._save_locations_data() + + +# فئة تحويل Folium إلى Streamlit +class folium_static: + """فئة لعرض خرائط Folium في Streamlit""" + + def __init__(self, fig, width=700, height=500): + """عرض خريطة Folium في Streamlit""" + import streamlit.components.v1 as components + + # تحويل خريطة Folium إلى HTML + fig_html = fig._repr_html_() + + # إنشاء مكون HTML مخصص + components.html(fig_html, width=width, height=height) + + +# تشغيل الوحدة بشكل مستقل +def main(): + """تشغيل وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد بشكل مستقل""" + # تهيئة الواجهة + st.set_page_config( + page_title="الخريطة التفاعلية | WAHBi AI", + page_icon="🗺️", + layout="wide", + initial_sidebar_state="expanded", + menu_items={ + 'Get Help': 'mailto:support@wahbi-ai.com', + 'Report a bug': 'mailto:support@wahbi-ai.com', + 'About': 'وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد - جزء من نظام WAHBi AI لتحليل المناقصات' + } + ) + + # تهيئة وحدة الخريطة التفاعلية + interactive_map = InteractiveMap() + + # عرض واجهة الوحدة + interactive_map.render() + +# تشغيل الوحدة عند استدعاء الملف مباشرة +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/maps/maps_app.py b/modules/maps/maps_app.py index 663eb4d46abb04361c81bac89ea0f8cfa9b8ead8..cbd8753a34662ccdf1af4454e3de51fb79e8457f 100644 --- a/modules/maps/maps_app.py +++ b/modules/maps/maps_app.py @@ -1,456 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -وحدة الخرائط والمواقع - نظام تحليل المناقصات +وحدة تطبيق الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد """ +import os +import sys import streamlit as st import pandas as pd import numpy as np -import folium -from streamlit_folium import folium_static -import json -import os -import sys -from pathlib import Path -# إضافة مسار المشروع للنظام -sys.path.append(str(Path(__file__).parent.parent)) +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات الخريطة التفاعلية +from modules.maps.interactive_map import InteractiveMap -# استيراد محسن واجهة المستخدم -from styling.enhanced_ui import UIEnhancer class MapsApp: - """تطبيق الخرائط والمواقع""" + """وحدة تطبيق الخريطة التفاعلية""" def __init__(self): - """تهيئة تطبيق الخرائط والمواقع""" - self.ui = UIEnhancer(page_title="الخرائط والمواقع - نظام تحليل المناقصات", page_icon="🗺️") - self.ui.apply_theme_colors() - - # بيانات المشاريع (نموذجية) - self.projects_data = [ - { - "id": "P001", - "name": "إنشاء مبنى إداري - الرياض", - "location": "الرياض", - "coordinates": [24.7136, 46.6753], - "status": "جاري التنفيذ", - "budget": 15000000, - "completion": 45, - "client": "وزارة الإسكان", - "start_date": "2024-10-15", - "end_date": "2025-12-30" - }, - { - "id": "P002", - "name": "تطوير طريق الملك فهد - جدة", - "location": "جدة", - "coordinates": [21.5433, 39.1728], - "status": "قيد الدراسة", - "budget": 8500000, - "completion": 0, - "client": "أمانة جدة", - "start_date": "2025-05-01", - "end_date": "2026-02-28" - }, - { - "id": "P003", - "name": "إنشاء مجمع سكني - الدمام", - "location": "الدمام", - "coordinates": [26.4207, 50.0888], - "status": "مكتمل", - "budget": 22000000, - "completion": 100, - "client": "شركة الإسكان للتطوير", - "start_date": "2023-08-10", - "end_date": "2025-01-15" - }, - { - "id": "P004", - "name": "بناء مدرسة - أبها", - "location": "أبها", - "coordinates": [18.2164, 42.5053], - "status": "جاري التنفيذ", - "budget": 5200000, - "completion": 75, - "client": "وزارة التعليم", - "start_date": "2024-06-20", - "end_date": "2025-07-30" - }, - { - "id": "P005", - "name": "تطوير شبكة مياه - المدينة المنورة", - "location": "المدينة المنورة", - "coordinates": [24.5247, 39.5692], - "status": "جاري التنفيذ", - "budget": 12800000, - "completion": 30, - "client": "شركة المياه الوطنية", - "start_date": "2024-11-05", - "end_date": "2026-03-15" - } - ] - - def run(self): - """تشغيل تطبيق الخرائط والمواقع""" - # إنشاء قائمة العناصر - menu_items = [ - {"name": "لوحة المعلومات", "icon": "house"}, - {"name": "المناقصات والعقود", "icon": "file-text"}, - {"name": "تحليل المستندات", "icon": "file-earmark-text"}, - {"name": "نظام التسعير", "icon": "calculator"}, - {"name": "حاسبة تكاليف البناء", "icon": "building"}, - {"name": "الموارد والتكاليف", "icon": "people"}, - {"name": "تحليل المخاطر", "icon": "exclamation-triangle"}, - {"name": "إدارة المشاريع", "icon": "kanban"}, - {"name": "الخرائط والمواقع", "icon": "geo-alt"}, - {"name": "الجدول الزمني", "icon": "calendar3"}, - {"name": "الإشعارات", "icon": "bell"}, - {"name": "مقارنة المستندات", "icon": "files"}, - {"name": "المساعد الذكي", "icon": "robot"}, - {"name": "التقارير", "icon": "bar-chart"}, - {"name": "الإعدادات", "icon": "gear"} - ] - - # إنشاء الشريط الجانبي - selected = self.ui.create_sidebar(menu_items) - - # إنشاء ترويسة الصفحة - self.ui.create_header("الخرائط والمواقع", "عرض وإدارة مواقع المشاريع") - - # إنشاء علامات تبويب للوظائف المختلفة - tabs = st.tabs(["خريطة المشاريع", "تفاصيل المواقع", "إضافة موقع جديد", "تحليل المناطق"]) - - # علامة تبويب خريطة المشاريع - with tabs[0]: - self.show_projects_map() - - # علامة تبويب تفاصيل المواقع - with tabs[1]: - self.show_location_details() - - # علامة تبويب إضافة موقع جديد - with tabs[2]: - self.add_new_location() - - # علامة تبويب تحليل المناطق - with tabs[3]: - self.analyze_regions() + """تهيئة وحدة تطبيق الخريطة التفاعلية""" + self.interactive_map = InteractiveMap() - def show_projects_map(self): - """عرض خريطة المشاريع""" - # إنشاء فلاتر للخريطة - col1, col2, col3 = st.columns(3) - - with col1: - status_filter = st.multiselect( - "حالة المشروع", - options=["الكل", "جاري التنفيذ", "قيد الدراسة", "مكتمل"], - default=["الكل"] - ) - - with col2: - location_filter = st.multiselect( - "الموقع", - options=["الكل"] + list(set([p["location"] for p in self.projects_data])), - default=["الكل"] - ) - - with col3: - budget_range = st.slider( - "نطاق الميزانية (مليون ريال)", - 0.0, 25.0, (0.0, 25.0), - step=0.5 - ) - - # تطبيق الفلاتر - filtered_projects = self.projects_data - - if "الكل" not in status_filter and status_filter: - filtered_projects = [p for p in filtered_projects if p["status"] in status_filter] - - if "الكل" not in location_filter and location_filter: - filtered_projects = [p for p in filtered_projects if p["location"] in location_filter] - - filtered_projects = [p for p in filtered_projects if budget_range[0] * 1000000 <= p["budget"] <= budget_range[1] * 1000000] - - # إنشاء الخريطة - st.markdown("### خريطة المشاريع") - - # تحديد مركز الخريطة (وسط المملكة العربية السعودية تقريباً) - center = [24.0, 45.0] - - # إنشاء خريطة folium - m = folium.Map(location=center, zoom_start=5, tiles="OpenStreetMap") - - # إضافة المشاريع إلى الخريطة - for project in filtered_projects: - # تحديد لون العلامة بناءً على حالة المشروع - if project["status"] == "جاري التنفيذ": - color = "blue" - elif project["status"] == "قيد الدراسة": - color = "orange" - elif project["status"] == "مكتمل": - color = "green" - else: - color = "gray" - - # إنشاء نص النافذة المنبثقة - popup_text = f""" -
-

{project['name']}

-

الحالة: {project['status']}

-

الميزانية: {project['budget']:,} ريال

-

نسبة الإنجاز: {project['completion']}%

-

العميل: {project['client']}

-

تاريخ البدء: {project['start_date']}

-

تاريخ الانتهاء: {project['end_date']}

- عرض التفاصيل -
- """ - - # إضافة علامة للمشروع - folium.Marker( - location=project["coordinates"], - popup=folium.Popup(popup_text, max_width=300), - tooltip=project["name"], - icon=folium.Icon(color=color, icon="info-sign") - ).add_to(m) - - # عرض الخريطة - folium_static(m, width=1000, height=500) - - # عرض إحصائيات المشاريع - st.markdown("### إحصائيات المشاريع") - - col1, col2, col3, col4 = st.columns(4) + def render(self): + """عرض واجهة وحدة تطبيق الخريطة التفاعلية""" + st.markdown("

وحدة الخريطة التفاعلية مع عرض التضاريس ثلاثي الأبعاد

", unsafe_allow_html=True) - with col1: - self.ui.create_metric_card( - "إجمالي المشاريع", - str(len(filtered_projects)), - None, - self.ui.COLORS['primary'] - ) - - with col2: - projects_in_progress = len([p for p in filtered_projects if p["status"] == "جاري التنفيذ"]) - self.ui.create_metric_card( - "مشاريع جارية", - str(projects_in_progress), - None, - self.ui.COLORS['secondary'] - ) - - with col3: - total_budget = sum([p["budget"] for p in filtered_projects]) - self.ui.create_metric_card( - "إجمالي الميزانية", - f"{total_budget/1000000:.1f} مليون ريال", - None, - self.ui.COLORS['accent'] - ) - - with col4: - avg_completion = np.mean([p["completion"] for p in filtered_projects]) - self.ui.create_metric_card( - "متوسط نسبة الإنجاز", - f"{avg_completion:.1f}%", - None, - self.ui.COLORS['success'] - ) - - def show_location_details(self): - """عرض تفاصيل المواقع""" - st.markdown("### تفاصيل مواقع المشاريع") - - # إنشاء جدول بيانات المشاريع - projects_df = pd.DataFrame(self.projects_data) - projects_df = projects_df.rename(columns={ - "id": "رقم المشروع", - "name": "اسم المشروع", - "location": "الموقع", - "status": "الحالة", - "budget": "الميزانية (ريال)", - "completion": "نسبة الإنجاز (%)", - "client": "العميل", - "start_date": "تاريخ البدء", - "end_date": "تاريخ الانتهاء" - }) - - # حذف عمود الإحداثيات من العرض - projects_df = projects_df.drop(columns=["coordinates"]) - - # عرض الجدول - st.dataframe( - projects_df, - use_container_width=True, - hide_index=True - ) - - # إضافة خيار تصدير البيانات - col1, col2 = st.columns([1, 5]) - with col1: - self.ui.create_button("تصدير البيانات", "primary") - - # عرض تفاصيل مشروع محدد - st.markdown("### تفاصيل مشروع محدد") - - selected_project = st.selectbox( - "اختر مشروعاً لعرض التفاصيل", - options=[p["name"] for p in self.projects_data] - ) - - # العثور على المشروع المحدد - project = next((p for p in self.projects_data if p["name"] == selected_project), None) - - if project: - col1, col2 = st.columns([2, 1]) - - with col1: - # عرض تفاصيل المشروع - st.markdown(f"#### {project['name']}") - st.markdown(f"**الموقع:** {project['location']}") - st.markdown(f"**الحالة:** {project['status']}") - st.markdown(f"**الميزانية:** {project['budget']:,} ريال") - st.markdown(f"**نسبة الإنجاز:** {project['completion']}%") - st.markdown(f"**العميل:** {project['client']}") - st.markdown(f"**تاريخ البدء:** {project['start_date']}") - st.markdown(f"**تاريخ الانتهاء:** {project['end_date']}") - - # أزرار الإجراءات - col1, col2, col3 = st.columns(3) - with col1: - self.ui.create_button("تعديل البيانات", "primary") - with col2: - self.ui.create_button("عرض المستندات", "secondary") - with col3: - self.ui.create_button("تقرير الموقع", "accent") - - with col2: - # عرض خريطة مصغرة للمشروع - m = folium.Map(location=project["coordinates"], zoom_start=12) - folium.Marker( - location=project["coordinates"], - tooltip=project["name"], - icon=folium.Icon(color="red", icon="info-sign") - ).add_to(m) - folium_static(m, width=300, height=300) - - def add_new_location(self): - """إضافة موقع جديد""" - st.markdown("### إضافة موقع مشروع جديد") - - # نموذج إضافة موقع جديد - with st.form("new_location_form"): - col1, col2 = st.columns(2) - - with col1: - project_id = st.text_input("رقم المشروع", value="P00" + str(len(self.projects_data) + 1)) - project_name = st.text_input("اسم المشروع") - location = st.text_input("الموقع") - status = st.selectbox( - "الحالة", - options=["جاري التنفيذ", "قيد الدراسة", "مكتمل"] - ) - budget = st.number_input("الميزانية (ريال)", min_value=0, step=100000) - - with col2: - completion = st.slider("نسبة الإنجاز (%)", 0, 100, 0) - client = st.text_input("العميل") - start_date = st.date_input("تاريخ البدء") - end_date = st.date_input("تاريخ الانتهاء") - - st.markdown("### تحديد الموقع على الخريطة") - st.markdown("انقر على الخريطة لتحديد موقع المشروع أو أدخل الإحداثيات يدوياً") - - col1, col2 = st.columns(2) - - with col1: - latitude = st.number_input("خط العرض", value=24.0, format="%.4f") - - with col2: - longitude = st.number_input("خط الطول", value=45.0, format="%.4f") - - # عرض الخريطة لتحديد الموقع - m = folium.Map(location=[latitude, longitude], zoom_start=5) - folium.Marker( - location=[latitude, longitude], - tooltip="موقع المشروع الجديد", - icon=folium.Icon(color="red", icon="info-sign") - ).add_to(m) - folium_static(m, width=700, height=300) - - # زر الإرسال - submit_button = st.form_submit_button("إضافة المشروع") - - if submit_button: - # إضافة المشروع الجديد (في تطبيق حقيقي، سيتم حفظ البيانات في قاعدة البيانات) - st.success("تم إضافة المشروع بنجاح!") - - # إعادة تعيين النموذج - st.experimental_rerun() - - def analyze_regions(self): - """تحليل المناطق""" - st.markdown("### تحليل المناطق") - - # إنشاء بيانات المناطق (نموذجية) - regions_data = { - "المنطقة": ["الرياض", "مكة المكرمة", "المدينة المنورة", "القصيم", "المنطقة الشرقية", "عسير", "تبوك", "حائل", "الحدود الشمالية", "جازان", "نجران", "الباحة", "الجوف"], - "عدد المشاريع": [15, 12, 8, 5, 18, 7, 4, 3, 2, 6, 3, 2, 3], - "إجمالي الميزانية (مليون ريال)": [120, 95, 45, 30, 150, 40, 25, 18, 12, 35, 20, 15, 22], - "متوسط مدة المشروع (شهر)": [18, 16, 14, 12, 20, 15, 12, 10, 9, 14, 12, 10, 11] - } - - regions_df = pd.DataFrame(regions_data) - - # عرض خريطة حرارية للمناطق - st.markdown("#### توزيع المشاريع حسب المناطق") - - # في تطبيق حقيقي، يمكن استخدام خريطة حرارية حقيقية للمملكة - st.image("https://via.placeholder.com/800x400?text=خريطة+حرارية+للمشاريع+حسب+المناطق", use_column_width=True) - - # عرض إحصائيات المناطق - st.markdown("#### إحصائيات المناطق") - - # عرض الجدول - st.dataframe( - regions_df, - use_container_width=True, - hide_index=True - ) - - # عرض رسوم بيانية للمقارنة - st.markdown("#### مقارنة المناطق") - - chart_type = st.radio( - "نوع الرسم البياني", - options=["عدد المشاريع", "إجمالي الميزانية", "متوسط مدة المشروع"], - horizontal=True - ) - - if chart_type == "عدد المشاريع": - chart_data = regions_df[["المنطقة", "عدد المشاريع"]].sort_values(by="عدد المشاريع", ascending=False) - st.bar_chart(chart_data.set_index("المنطقة")) - elif chart_type == "إجمالي الميزانية": - chart_data = regions_df[["المنطقة", "إجمالي الميزانية (مليون ريال)"]].sort_values(by="إجمالي الميزانية (مليون ريال)", ascending=False) - st.bar_chart(chart_data.set_index("المنطقة")) - else: - chart_data = regions_df[["المنطقة", "متوسط مدة المشروع (شهر)"]].sort_values(by="متوسط مدة المشروع (شهر)", ascending=False) - st.bar_chart(chart_data.set_index("المنطقة")) - - # تحليل الكثافة - st.markdown("#### تحليل كثافة المشاريع") st.markdown(""" - يوضح هذا التحليل توزيع المشاريع حسب المناطق الجغرافية، مما يساعد في: - - تحديد المناطق ذات النشاط العالي - - تحديد فرص النمو في المناطق الأقل نشاطاً - - تخطيط الموارد بناءً على التوزيع الجغرافي - """) - - # في تطبيق حقيقي، يمكن إضافة تحليلات أكثر تفصيلاً +
+ تمكنك هذه الوحدة من عرض وإدارة مواقع المشاريع على خريطة تفاعلية، مع إمكانية عرض التضاريس بشكل ثلاثي الأبعاد. + يمكنك إضافة وتحرير مواقع المشاريع، وتحليل توزيعها الجغرافي، وعرض المعلومات الطبوغرافية للمواقع. +
+ """, unsafe_allow_html=True) + + # عرض وحدة الخريطة التفاعلية + self.interactive_map.render() + -# تشغيل التطبيق +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة if __name__ == "__main__": - maps_app = MapsApp() - maps_app.run() + st.set_page_config( + page_title="الخريطة التفاعلية | WAHBi AI", + page_icon="🗺️", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = MapsApp() + app.render() \ No newline at end of file diff --git a/modules/notifications/__init__.py b/modules/notifications/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a177748231ffad32b4284b13461159d77057db3d --- /dev/null +++ b/modules/notifications/__init__.py @@ -0,0 +1 @@ +# ملف تهيئة وحدة الإشعارات الذكية \ No newline at end of file diff --git a/modules/notifications/notifications_app.py b/modules/notifications/notifications_app.py index 8052ca90da563e7dd99aae37022185dd9de0e847..16aae363ddc6a62d64c8d66ecb40d0999c009f57 100644 --- a/modules/notifications/notifications_app.py +++ b/modules/notifications/notifications_app.py @@ -1,672 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + """ -وحدة الإشعارات الذكية - نظام تحليل المناقصات +وحدة تطبيق نظام الإشعارات الذكي لتحديثات المشروع والتنبيهات """ -import streamlit as st -import pandas as pd -import datetime -import json import os import sys -from pathlib import Path +import streamlit as st +import pandas as pd +import numpy as np -# إضافة مسار المشروع للنظام -sys.path.append(str(Path(__file__).parent.parent)) +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات الإشعارات الذكية +from modules.notifications.smart_notifications import SmartNotificationSystem -# استيراد محسن واجهة المستخدم -from styling.enhanced_ui import UIEnhancer class NotificationsApp: - """تطبيق الإشعارات الذكية""" + """وحدة تطبيق نظام الإشعارات الذكي""" def __init__(self): - """تهيئة تطبيق الإشعارات الذكية""" - self.ui = UIEnhancer(page_title="الإشعارات الذكية - نظام تحليل المناقصات", page_icon="🔔") - self.ui.apply_theme_colors() - - # بيانات الإشعارات (نموذجية) - self.notifications_data = [ - { - "id": "N001", - "title": "موعد تسليم مناقصة", - "message": "موعد تسليم مناقصة T-2025-001 (إنشاء مبنى إداري) بعد 5 أيام", - "type": "deadline", - "priority": "high", - "related_entity": "T-2025-001", - "created_at": "2025-03-25T10:30:00", - "is_read": False - }, - { - "id": "N002", - "title": "ترسية مناقصة", - "message": "تم ترسية مناقصة T-2025-003 (توريد معدات) بنجاح", - "type": "award", - "priority": "medium", - "related_entity": "T-2025-003", - "created_at": "2025-03-28T14:15:00", - "is_read": True - }, - { - "id": "N003", - "title": "تحديث مستندات", - "message": "تم تحديث مستندات مناقصة T-2025-002 (صيانة طرق)", - "type": "document", - "priority": "medium", - "related_entity": "T-2025-002", - "created_at": "2025-03-29T09:45:00", - "is_read": False - }, - { - "id": "N004", - "title": "تغيير في المواصفات", - "message": "تم تغيير المواصفات الفنية لمناقصة T-2025-001 (إنشاء مبنى إداري)", - "type": "change", - "priority": "high", - "related_entity": "T-2025-001", - "created_at": "2025-03-27T11:20:00", - "is_read": False - }, - { - "id": "N005", - "title": "تأخير في المشروع", - "message": "تأخير في تنفيذ مشروع P002 (تطوير طريق الملك فهد - جدة)", - "type": "delay", - "priority": "high", - "related_entity": "P002", - "created_at": "2025-03-26T16:10:00", - "is_read": True - }, - { - "id": "N006", - "title": "اكتمال مرحلة", - "message": "اكتمال مرحلة الأساسات في مشروع P001 (إنشاء مبنى إداري - الرياض)", - "type": "milestone", - "priority": "low", - "related_entity": "P001", - "created_at": "2025-03-24T13:30:00", - "is_read": True - }, - { - "id": "N007", - "title": "طلب معلومات إضافية", - "message": "طلب معلومات إضافية لمناقصة T-2025-004 (تجهيز مختبرات)", - "type": "request", - "priority": "medium", - "related_entity": "T-2025-004", - "created_at": "2025-03-30T08:15:00", - "is_read": False - }, - { - "id": "N008", - "title": "تحديث أسعار المواد", - "message": "تم تحديث أسعار مواد البناء في قاعدة البيانات", - "type": "update", - "priority": "low", - "related_entity": "DB-MATERIALS", - "created_at": "2025-03-29T15:40:00", - "is_read": False - }, - { - "id": "N009", - "title": "اجتماع فريق العمل", - "message": "اجتماع فريق العمل لمناقشة مناقصة T-2025-001 غداً الساعة 10:00 صباحاً", - "type": "meeting", - "priority": "medium", - "related_entity": "T-2025-001", - "created_at": "2025-03-28T16:20:00", - "is_read": True - }, - { - "id": "N010", - "title": "تغيير في الميزانية", - "message": "تم تغيير الميزانية المخصصة لمشروع P004 (بناء مدرسة - أبها)", - "type": "budget", - "priority": "high", - "related_entity": "P004", - "created_at": "2025-03-25T14:50:00", - "is_read": False - } - ] - - # إعدادات الإشعارات (نموذجية) - self.notification_settings = { - "deadline": True, - "award": True, - "document": True, - "change": True, - "delay": True, - "milestone": True, - "request": True, - "update": True, - "meeting": True, - "budget": True, - "email_notifications": True, - "sms_notifications": False, - "push_notifications": True, - "notification_frequency": "realtime" - } - - def run(self): - """تشغيل تطبيق الإشعارات الذكية""" - # إنشاء قائمة العناصر - menu_items = [ - {"name": "لوحة المعلومات", "icon": "house"}, - {"name": "المناقصات والعقود", "icon": "file-text"}, - {"name": "تحليل المستندات", "icon": "file-earmark-text"}, - {"name": "نظام التسعير", "icon": "calculator"}, - {"name": "حاسبة تكاليف البناء", "icon": "building"}, - {"name": "الموارد والتكاليف", "icon": "people"}, - {"name": "تحليل المخاطر", "icon": "exclamation-triangle"}, - {"name": "إدارة المشاريع", "icon": "kanban"}, - {"name": "الخرائط والمواقع", "icon": "geo-alt"}, - {"name": "الجدول الزمني", "icon": "calendar3"}, - {"name": "الإشعارات", "icon": "bell"}, - {"name": "مقارنة المستندات", "icon": "files"}, - {"name": "المساعد الذكي", "icon": "robot"}, - {"name": "التقارير", "icon": "bar-chart"}, - {"name": "الإعدادات", "icon": "gear"} - ] - - # إنشاء الشريط الجانبي - selected = self.ui.create_sidebar(menu_items) - - # إنشاء ترويسة الصفحة - self.ui.create_header("الإشعارات الذكية", "إدارة ومتابعة الإشعارات والتنبيهات") - - # إنشاء علامات تبويب للوظائف المختلفة - tabs = st.tabs(["الإشعارات الحالية", "إعدادات الإشعارات", "إنشاء إشعار", "سجل الإشعارات"]) - - # علامة تبويب الإشعارات الحالية - with tabs[0]: - self.show_current_notifications() - - # علامة تبويب إعدادات الإشعارات - with tabs[1]: - self.show_notification_settings() - - # علامة تبويب إنشاء إشعار - with tabs[2]: - self.create_notification() - - # علامة تبويب سجل الإشعارات - with tabs[3]: - self.show_notification_history() - - def show_current_notifications(self): - """عرض الإشعارات الحالية""" - st.markdown("### الإشعارات الحالية") - - # إنشاء فلاتر للإشعارات - col1, col2, col3 = st.columns(3) - - with col1: - type_filter = st.multiselect( - "نوع الإشعار", - options=["الكل", "موعد نهائي", "ترسية", "مستند", "تغيير", "تأخير", "مرحلة", "طلب", "تحديث", "اجتماع", "ميزانية"], - default=["الكل"] - ) - - with col2: - priority_filter = st.multiselect( - "الأولوية", - options=["الكل", "عالية", "متوسطة", "منخفضة"], - default=["الكل"] - ) - - with col3: - read_filter = st.radio( - "الحالة", - options=["الكل", "غير مقروءة", "مقروءة"], - horizontal=True - ) - - # تطبيق الفلاتر - filtered_notifications = self.notifications_data - - # تحويل أنواع الإشعارات من الإنجليزية إلى العربية للفلترة - type_mapping = { - "موعد نهائي": "deadline", - "ترسية": "award", - "مستند": "document", - "تغيير": "change", - "تأخير": "delay", - "مرحلة": "milestone", - "طلب": "request", - "تحديث": "update", - "اجتماع": "meeting", - "ميزانية": "budget" - } - - # تحويل الأولويات من العربية إلى الإنجليزية للفلترة - priority_mapping = { - "عالية": "high", - "متوسطة": "medium", - "منخفضة": "low" - } - - if "الكل" not in type_filter and type_filter: - filtered_types = [type_mapping[t] for t in type_filter if t in type_mapping] - filtered_notifications = [n for n in filtered_notifications if n["type"] in filtered_types] - - if "الكل" not in priority_filter and priority_filter: - filtered_priorities = [priority_mapping[p] for p in priority_filter if p in priority_mapping] - filtered_notifications = [n for n in filtered_notifications if n["priority"] in filtered_priorities] - - if read_filter == "غير مقروءة": - filtered_notifications = [n for n in filtered_notifications if not n["is_read"]] - elif read_filter == "مقروءة": - filtered_notifications = [n for n in filtered_notifications if n["is_read"]] - - # عرض عدد الإشعارات غير المقروءة - unread_count = len([n for n in filtered_notifications if not n["is_read"]]) - - st.markdown(f"**عدد الإشعارات غير المقروءة:** {unread_count}") - - # زر تحديث وتعليم الكل كمقروء - col1, col2 = st.columns([1, 1]) - with col1: - if st.button("تحديث الإشعارات", use_container_width=True): - st.success("تم تحديث الإشعارات بنجاح") - - with col2: - if st.button("تعليم الكل كمقروء", use_container_width=True): - st.success("تم تعليم جميع الإشعارات كمقروءة") - - # عرض الإشعارات - if not filtered_notifications: - st.info("لا توجد إشعارات تطابق الفلاتر المحددة") - else: - for notification in filtered_notifications: - self.display_notification(notification) - - def display_notification(self, notification): - """عرض إشعار واحد""" - # تحديد لون الإشعار بناءً على الأولوية - if notification["priority"] == "high": - color = self.ui.COLORS['danger'] - priority_text = "عالية" - elif notification["priority"] == "medium": - color = self.ui.COLORS['warning'] - priority_text = "متوسطة" - else: - color = self.ui.COLORS['secondary'] - priority_text = "منخفضة" - - # تحويل نوع الإشعار إلى العربية - type_mapping = { - "deadline": "موعد نهائي", - "award": "ترسية", - "document": "مستند", - "change": "تغيير", - "delay": "تأخير", - "milestone": "مرحلة", - "request": "طلب", - "update": "تحديث", - "meeting": "اجتماع", - "budget": "ميزانية" - } - - notification_type = type_mapping.get(notification["type"], notification["type"]) - - # تحويل التاريخ إلى تنسيق مناسب - created_at = datetime.datetime.fromisoformat(notification["created_at"]) - formatted_date = created_at.strftime("%Y-%m-%d %H:%M") - - # تحديد أيقونة الإشعار - icon_mapping = { - "deadline": "⏰", - "award": "🏆", - "document": "📄", - "change": "🔄", - "delay": "⚠️", - "milestone": "🏁", - "request": "❓", - "update": "🔄", - "meeting": "👥", - "budget": "💰" - } - - icon = icon_mapping.get(notification["type"], "📌") - - # إنشاء بطاقة الإشعار - st.markdown( - f""" -
-
-
-

{icon} {notification['title']}

-

{notification['message']}

-
- النوع: {notification_type} - الأولوية: {priority_text} - التاريخ: {formatted_date} -
-
-
- - -
-
-
- """, - unsafe_allow_html=True - ) - - def show_notification_settings(self): - """عرض إعدادات الإشعارات""" - st.markdown("### إعدادات الإشعارات") - - # إنشاء نموذج الإعدادات - with st.form("notification_settings_form"): - st.markdown("#### أنواع الإشعارات") - - col1, col2 = st.columns(2) - - with col1: - deadline = st.checkbox("المواعيد النهائية", value=self.notification_settings["deadline"]) - award = st.checkbox("ترسية المناقصات", value=self.notification_settings["award"]) - document = st.checkbox("تحديثات المستندات", value=self.notification_settings["document"]) - change = st.checkbox("التغييرات في المواصفات", value=self.notification_settings["change"]) - delay = st.checkbox("التأخيرات في المشاريع", value=self.notification_settings["delay"]) - - with col2: - milestone = st.checkbox("اكتمال المراحل", value=self.notification_settings["milestone"]) - request = st.checkbox("طلبات المعلومات", value=self.notification_settings["request"]) - update = st.checkbox("تحديثات النظام", value=self.notification_settings["update"]) - meeting = st.checkbox("الاجتماعات", value=self.notification_settings["meeting"]) - budget = st.checkbox("تغييرات الميزانية", value=self.notification_settings["budget"]) - - st.markdown("#### طرق الإشعار") - - col1, col2, col3 = st.columns(3) - - with col1: - email_notifications = st.checkbox("البريد الإلكتروني", value=self.notification_settings["email_notifications"]) - - with col2: - sms_notifications = st.checkbox("الرسائل النصية", value=self.notification_settings["sms_notifications"]) - - with col3: - push_notifications = st.checkbox("إشعارات الويب", value=self.notification_settings["push_notifications"]) - - st.markdown("#### تكرار الإشعارات") - - notification_frequency = st.radio( - "تكرار الإشعارات", - options=["في الوقت الحقيقي", "مرة واحدة يومياً", "مرة واحدة أسبوعياً"], - index=0 if self.notification_settings["notification_frequency"] == "realtime" else 1 if self.notification_settings["notification_frequency"] == "daily" else 2, - horizontal=True - ) - - # زر حفظ الإعدادات - submit_button = st.form_submit_button("حفظ الإعدادات") - - if submit_button: - # تحديث الإعدادات (في تطبيق حقيقي، سيتم حفظ الإعدادات في قاعدة البيانات) - self.notification_settings.update({ - "deadline": deadline, - "award": award, - "document": document, - "change": change, - "delay": delay, - "milestone": milestone, - "request": request, - "update": update, - "meeting": meeting, - "budget": budget, - "email_notifications": email_notifications, - "sms_notifications": sms_notifications, - "push_notifications": push_notifications, - "notification_frequency": "realtime" if notification_frequency == "في الوقت الحقيقي" else "daily" if notification_frequency == "مرة واحدة يومياً" else "weekly" - }) - - st.success("تم حفظ الإعدادات بنجاح") - - # إعدادات متقدمة - st.markdown("### إعدادات متقدمة") - - with st.expander("إعدادات متقدمة"): - st.markdown("#### جدولة الإشعارات") - - col1, col2 = st.columns(2) - - with col1: - st.time_input("وقت الإشعارات اليومية", datetime.time(9, 0)) - - with col2: - st.selectbox( - "يوم الإشعارات الأسبوعية", - options=["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"], - index=0 - ) - - st.markdown("#### فلترة الإشعارات") - - min_priority = st.select_slider( - "الحد الأدنى للأولوية", - options=["منخفضة", "متوسطة", "عالية"], - value="منخفضة" - ) - - st.markdown("#### حفظ الإشعارات") - - retention_period = st.slider( - "فترة الاحتفاظ بالإشعارات (بالأيام)", - min_value=7, - max_value=365, - value=90, - step=1 - ) - - if st.button("حفظ الإعدادات المتقدمة"): - st.success("تم حفظ الإعدادات المتقدمة بنجاح") + """تهيئة وحدة تطبيق نظام الإشعارات الذكي""" + self.smart_notification_system = SmartNotificationSystem() - def create_notification(self): - """إنشاء إشعار جديد""" - st.markdown("### إنشاء إشعار جديد") - - # إنشاء نموذج إشعار جديد - with st.form("new_notification_form"): - title = st.text_input("عنوان الإشعار") - message = st.text_area("نص الإشعار") - - col1, col2 = st.columns(2) - - with col1: - notification_type = st.selectbox( - "نوع الإشعار", - options=["موعد نهائي", "ترسية", "مستند", "تغيير", "تأخير", "مرحلة", "طلب", "تحديث", "اجتماع", "ميزانية"] - ) - - # تحويل نوع الإشعار إلى الإنجليزية - type_mapping = { - "موعد نهائي": "deadline", - "ترسية": "award", - "مستند": "document", - "تغيير": "change", - "تأخير": "delay", - "مرحلة": "milestone", - "طلب": "request", - "تحديث": "update", - "اجتماع": "meeting", - "ميزانية": "budget" - } - - notification_type_en = type_mapping.get(notification_type, "update") - - with col2: - priority = st.selectbox( - "الأولوية", - options=["عالية", "متوسطة", "منخفضة"] - ) - - # تحويل الأولوية إلى الإنجليزية - priority_mapping = { - "عالية": "high", - "متوسطة": "medium", - "منخفضة": "low" - } - - priority_en = priority_mapping.get(priority, "medium") - - related_entity = st.text_input("الكيان المرتبط (مثل: رقم المناقصة أو المشروع)") - - col1, col2 = st.columns(2) - - with col1: - send_email = st.checkbox("إرسال بريد إلكتروني") - - with col2: - send_push = st.checkbox("إرسال إشعار ويب") - - # زر إنشاء الإشعار - submit_button = st.form_submit_button("إنشاء الإشعار") - - if submit_button: - if not title or not message: - st.error("يرجى ملء جميع الحقول المطلوبة") - else: - # إنشاء الإشعار الجديد (في تطبيق حقيقي، سيتم حفظ الإشعار في قاعدة البيانات) - new_notification = { - "id": f"N{len(self.notifications_data) + 1:03d}", - "title": title, - "message": message, - "type": notification_type_en, - "priority": priority_en, - "related_entity": related_entity, - "created_at": datetime.datetime.now().isoformat(), - "is_read": False - } - - # إضافة الإشعار إلى القائمة (في تطبيق حقيقي، سيتم إضافته إلى قاعدة البيانات) - self.notifications_data.append(new_notification) - - st.success("تم إنشاء الإشعار بنجاح") - - # إظهار تفاصيل الإرسال - if send_email: - st.info("تم إرسال الإشعار عبر البريد الإلكتروني") - - if send_push: - st.info("تم إرسال إشعار الويب") - - def show_notification_history(self): - """عرض سجل الإشعارات""" - st.markdown("### سجل الإشعارات") - - # إنشاء فلاتر للسجل - col1, col2 = st.columns(2) - - with col1: - date_range = st.date_input( - "نطاق التاريخ", - value=( - datetime.datetime.now() - datetime.timedelta(days=30), - datetime.datetime.now() - ) - ) - - with col2: - entity_filter = st.text_input("البحث حسب الكيان المرتبط") - - # تحويل البيانات إلى DataFrame - notifications_df = pd.DataFrame(self.notifications_data) - - # تحويل حقل created_at إلى datetime - notifications_df["created_at"] = pd.to_datetime(notifications_df["created_at"]) - - # تطبيق فلتر التاريخ - if len(date_range) == 2: - start_date, end_date = date_range - start_date = pd.to_datetime(start_date) - end_date = pd.to_datetime(end_date) + datetime.timedelta(days=1) # لتضمين اليوم الأخير - notifications_df = notifications_df[(notifications_df["created_at"] >= start_date) & (notifications_df["created_at"] <= end_date)] - - # تطبيق فلتر الكيان المرتبط - if entity_filter: - notifications_df = notifications_df[notifications_df["related_entity"].str.contains(entity_filter, case=False)] - - # تحويل أنواع الإشعارات من الإنجليزية إلى العربية للعرض - type_mapping = { - "deadline": "موعد نهائي", - "award": "ترسية", - "document": "مستند", - "change": "تغيير", - "delay": "تأخير", - "milestone": "مرحلة", - "request": "طلب", - "update": "تحديث", - "meeting": "اجتماع", - "budget": "ميزانية" - } - - notifications_df["type_ar"] = notifications_df["type"].map(type_mapping) - - # تحويل الأولويات من الإنجليزية إلى العربية للعرض - priority_mapping = { - "high": "عالية", - "medium": "متوسطة", - "low": "منخفضة" - } - - notifications_df["priority_ar"] = notifications_df["priority"].map(priority_mapping) - - # تحويل حالة القراءة إلى نص - notifications_df["is_read_text"] = notifications_df["is_read"].map({True: "مقروءة", False: "غير مقروءة"}) - - # تنسيق التاريخ - notifications_df["created_at_formatted"] = notifications_df["created_at"].dt.strftime("%Y-%m-%d %H:%M") - - # إعادة ترتيب الأعمدة وتغيير أسمائها - display_df = notifications_df[[ - "id", "title", "type_ar", "priority_ar", "related_entity", "created_at_formatted", "is_read_text" - ]].rename(columns={ - "id": "الرقم", - "title": "العنوان", - "type_ar": "النوع", - "priority_ar": "الأولوية", - "related_entity": "الكيان المرتبط", - "created_at_formatted": "تاريخ الإنشاء", - "is_read_text": "الحالة" - }) - - # عرض الجدول - st.dataframe( - display_df, - use_container_width=True, - hide_index=True - ) - - # إضافة خيارات التصدير - col1, col2 = st.columns([1, 5]) - with col1: - if st.button("تصدير البيانات", use_container_width=True): - st.success("تم تصدير البيانات بنجاح") - - # عرض إحصائيات - st.markdown("### إحصائيات الإشعارات") - - col1, col2, col3 = st.columns(3) - - with col1: - # إحصائيات حسب النوع - type_counts = notifications_df["type_ar"].value_counts() - st.markdown("#### الإشعارات حسب النوع") - st.bar_chart(type_counts) - - with col2: - # إحصائيات حسب الأولوية - priority_counts = notifications_df["priority_ar"].value_counts() - st.markdown("#### الإشعارات حسب الأولوية") - st.bar_chart(priority_counts) - - with col3: - # إحصائيات حسب الحالة - read_counts = notifications_df["is_read_text"].value_counts() - st.markdown("#### الإشعارات حسب الحالة") - st.bar_chart(read_counts) + def render(self): + """عرض واجهة وحدة تطبيق نظام الإشعارات الذكي""" + st.markdown("

نظام الإشعارات الذكي لتحديثات المشروع والتنبيهات

", unsafe_allow_html=True) + + st.markdown(""" +
+ يتيح لك نظام الإشعارات الذكي متابعة تحديثات المشاريع وتلقي تنبيهات مخصصة حسب أدوارك واهتماماتك. + يمكنك تخصيص إعدادات الإشعارات وجدولة التذكيرات للمواعيد النهائية والمهام. +
+ """, unsafe_allow_html=True) + + # عرض نظام الإشعارات الذكي + self.smart_notification_system.render() -# تشغيل التطبيق + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة if __name__ == "__main__": - notifications_app = NotificationsApp() - notifications_app.run() + st.set_page_config( + page_title="نظام الإشعارات الذكي | WAHBi AI", + page_icon="🔔", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = NotificationsApp() + app.render() \ No newline at end of file diff --git a/modules/notifications/smart_notifications.py b/modules/notifications/smart_notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..cb9cf6615e230c40ff913ae9e7d8e2b226f725d9 --- /dev/null +++ b/modules/notifications/smart_notifications.py @@ -0,0 +1,1237 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة نظام الإشعارات الذكي لتحديثات المشروع والتنبيهات +تتيح هذه الوحدة متابعة تحديثات المشاريع وإرسال تنبيهات ذكية مخصصة للمستخدمين بناءً على أدوارهم واهتماماتهم +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import json +import datetime +import time +import threading +import logging +from typing import List, Dict, Any, Tuple, Optional, Union + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +from utils.components.credits import render_credits +from utils.helpers import format_number, format_currency, styled_button + + +class SmartNotificationSystem: + """فئة نظام الإشعارات الذكي لتحديثات المشروع والتنبيهات""" + + def __init__(self): + """تهيئة نظام الإشعارات الذكي""" + # تهيئة مجلدات حفظ البيانات + self.data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../data/notifications")) + os.makedirs(self.data_dir, exist_ok=True) + + # تهيئة قائمة الإشعارات + if 'notifications' not in st.session_state: + st.session_state.notifications = [] + + if 'unread_count' not in st.session_state: + st.session_state.unread_count = 0 + + if 'notification_channels' not in st.session_state: + st.session_state.notification_channels = { + "browser": True, + "email": False, + "sms": False, + "mobile_app": False + } + + if 'notification_preferences' not in st.session_state: + st.session_state.notification_preferences = { + "project_updates": True, + "document_analysis": True, + "deadline_reminders": True, + "risk_alerts": True, + "price_changes": True, + "team_mentions": True, + "system_updates": True + } + + # تحميل الإشعارات المحفوظة + self._load_notifications() + + # تسجيل الأحداث + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(os.path.join(self.data_dir, "notifications.log")), + logging.StreamHandler() + ] + ) + self.logger = logging.getLogger("smart_notifications") + + def render(self): + """عرض واجهة نظام الإشعارات الذكي""" + render_header("نظام الإشعارات الذكي") + + # تبويبات الوحدة + tabs = st.tabs([ + "جميع الإشعارات", + "إشعارات غير مقروءة", + "إعدادات الإشعارات", + "جدولة الإشعارات", + "تقارير وإحصائيات" + ]) + + # تبويب جميع الإشعارات + with tabs[0]: + self._render_all_notifications() + + # تبويب الإشعارات غير المقروءة + with tabs[1]: + self._render_unread_notifications() + + # تبويب إعدادات الإشعارات + with tabs[2]: + self._render_notification_settings() + + # تبويب جدولة الإشعارات + with tabs[3]: + self._render_notification_scheduling() + + # تبويب تقارير وإحصائيات + with tabs[4]: + self._render_notification_analytics() + + # عرض حقوق النشر + render_credits() + + def _render_all_notifications(self): + """عرض جميع الإشعارات""" + st.markdown(""" +
+

🔔 جميع الإشعارات

+

عرض كافة الإشعارات والتنبيهات الخاصة بالمشاريع والنظام.

+
+ """, unsafe_allow_html=True) + + # أزرار التحكم + col1, col2, col3 = st.columns([1, 1, 1]) + + with col1: + if styled_button("تحديث الإشعارات", key="refresh_notifications", type="primary", icon="🔄"): + self._load_notifications() + st.success("تم تحديث الإشعارات بنجاح") + + with col2: + if styled_button("تعليم الكل كمقروء", key="mark_all_read", type="secondary", icon="✓"): + self._mark_all_as_read() + st.success("تم تعليم جميع الإشعارات كمقروءة") + + with col3: + if styled_button("حذف جميع الإشعارات", key="clear_notifications", type="danger", icon="🗑️"): + confirmed = st.text_input("اكتب 'تأكيد' لحذف جميع الإشعارات", key="confirm_clear") + if confirmed == "تأكيد": + self._clear_all_notifications() + st.success("تم حذف جميع الإشعارات بنجاح") + + # فلترة الإشعارات + filter_col1, filter_col2 = st.columns(2) + + with filter_col1: + notification_type = st.multiselect( + "تصفية حسب النوع", + options=[ + "تحديث مشروع", "وثيقة جديدة", "تذكير موعد نهائي", + "تنبيه مخاطر", "تغيير سعر", "إشارة فريق العمل", "تحديث النظام" + ], + key="filter_notification_type" + ) + + with filter_col2: + date_range = st.date_input( + "نطاق التاريخ", + value=( + datetime.datetime.now() - datetime.timedelta(days=30), + datetime.datetime.now() + ), + key="filter_date_range" + ) + + # تصفية الإشعارات + filtered_notifications = self._filter_notifications( + notification_type=notification_type, + date_range=date_range + ) + + # عرض الإشعارات المصفاة + if filtered_notifications: + for notification in filtered_notifications: + self._render_notification_card(notification) + else: + st.info("لا توجد إشعارات متاحة") + + def _render_unread_notifications(self): + """عرض الإشعارات غير المقروءة""" + st.markdown(""" +
+

🔔 الإشعارات غير المقروءة

+

عرض الإشعارات والتنبيهات التي لم تتم قراءتها بعد.

+
+ """, unsafe_allow_html=True) + + # أزرار التحكم + col1, col2 = st.columns(2) + + with col1: + if styled_button("تحديث الإشعارات", key="refresh_unread", type="primary", icon="🔄"): + self._load_notifications() + st.success("تم تحديث الإشعارات بنجاح") + + with col2: + if styled_button("تعليم الكل كمقروء", key="mark_unread_read", type="secondary", icon="✓"): + self._mark_all_as_read() + st.success("تم تعليم جميع الإشعارات كمقروءة") + + # فلترة الإشعارات غير المقروءة + unread_notifications = [n for n in st.session_state.notifications if not n.get("read", False)] + + # عرض الإشعارات غير المقروءة + if unread_notifications: + for notification in unread_notifications: + self._render_notification_card(notification, show_mark_button=True) + else: + st.success("لا توجد إشعارات غير مقروءة") + + def _render_notification_settings(self): + """عرض إعدادات الإشعارات""" + st.markdown(""" +
+

⚙️ إعدادات الإشعارات

+

تخصيص إعدادات وتفضيلات الإشعارات الخاصة بك.

+
+ """, unsafe_allow_html=True) + + # قسم قنوات الإشعارات + st.markdown("### قنوات الإشعارات") + st.markdown("حدد الطرق التي ترغب في تلقي الإشعارات من خلالها.") + + channels_col1, channels_col2 = st.columns(2) + + with channels_col1: + st.session_state.notification_channels["browser"] = st.checkbox( + "إشعارات المتصفح", + value=st.session_state.notification_channels.get("browser", True), + key="channel_browser" + ) + + st.session_state.notification_channels["email"] = st.checkbox( + "البريد الإلكتروني", + value=st.session_state.notification_channels.get("email", False), + key="channel_email" + ) + + if st.session_state.notification_channels["email"]: + email = st.text_input( + "البريد الإلكتروني للإشعارات", + value=st.session_state.get("notification_email", ""), + key="notification_email" + ) + st.session_state.notification_email = email + + with channels_col2: + st.session_state.notification_channels["sms"] = st.checkbox( + "الرسائل النصية (SMS)", + value=st.session_state.notification_channels.get("sms", False), + key="channel_sms" + ) + + if st.session_state.notification_channels["sms"]: + phone = st.text_input( + "رقم الهاتف للإشعارات", + value=st.session_state.get("notification_phone", ""), + key="notification_phone" + ) + st.session_state.notification_phone = phone + + st.session_state.notification_channels["mobile_app"] = st.checkbox( + "تطبيق الهاتف المحمول", + value=st.session_state.notification_channels.get("mobile_app", False), + key="channel_mobile_app" + ) + + # قسم تفضيلات الإشعارات + st.markdown("### أنواع الإشعارات") + st.markdown("حدد أنواع الإشعارات التي ترغب في تلقيها.") + + prefs_col1, prefs_col2 = st.columns(2) + + with prefs_col1: + st.session_state.notification_preferences["project_updates"] = st.checkbox( + "تحديثات المشاريع", + value=st.session_state.notification_preferences.get("project_updates", True), + key="pref_project_updates" + ) + + st.session_state.notification_preferences["document_analysis"] = st.checkbox( + "تحليل المستندات", + value=st.session_state.notification_preferences.get("document_analysis", True), + key="pref_document_analysis" + ) + + st.session_state.notification_preferences["deadline_reminders"] = st.checkbox( + "تذكيرات المواعيد النهائية", + value=st.session_state.notification_preferences.get("deadline_reminders", True), + key="pref_deadline_reminders" + ) + + st.session_state.notification_preferences["risk_alerts"] = st.checkbox( + "تنبيهات المخاطر", + value=st.session_state.notification_preferences.get("risk_alerts", True), + key="pref_risk_alerts" + ) + + with prefs_col2: + st.session_state.notification_preferences["price_changes"] = st.checkbox( + "تغييرات الأسعار", + value=st.session_state.notification_preferences.get("price_changes", True), + key="pref_price_changes" + ) + + st.session_state.notification_preferences["team_mentions"] = st.checkbox( + "إشارات فريق العمل", + value=st.session_state.notification_preferences.get("team_mentions", True), + key="pref_team_mentions" + ) + + st.session_state.notification_preferences["system_updates"] = st.checkbox( + "تحديثات النظام", + value=st.session_state.notification_preferences.get("system_updates", True), + key="pref_system_updates" + ) + + # إعدادات التكرار + st.markdown("### إعدادات التكرار") + + frequency = st.radio( + "تكرار الإشعارات المتشابهة", + options=["فوري", "تجميع كل ساعة", "تجميع كل يوم", "مخصص"], + index=0, + key="notification_frequency" + ) + + if frequency == "مخصص": + custom_hours = st.number_input( + "التجميع كل (ساعات)", + min_value=1, + max_value=24, + value=4, + key="custom_frequency_hours" + ) + st.session_state.custom_frequency_hours = custom_hours + + # إعدادات متقدمة + with st.expander("إعدادات متقدمة"): + st.checkbox( + "عرض الإشعارات عند بدء تشغيل النظام", + value=True, + key="show_on_startup" + ) + + st.checkbox( + "الإشعارات الصوتية", + value=False, + key="audio_notifications" + ) + + st.checkbox( + "حفظ سجل الإشعارات", + value=True, + key="log_notifications" + ) + + # استدعاء القيمة من session_state إذا كانت موجودة أو استخدام القيمة الافتراضية + retention_days = st.slider( + "الاحتفاظ بالإشعارات (أيام)", + min_value=7, + max_value=365, + value=st.session_state.get("retention_days_value", 90), + key="retention_days" + ) + # حفظ القيمة في مفتاح آخر بعد تحديثها عن طريق المستخدم + if "retention_days_value" not in st.session_state: + st.session_state.retention_days_value = retention_days + + # زر حفظ الإعدادات + if styled_button("حفظ الإعدادات", key="save_notification_settings", type="primary", icon="💾"): + self._save_notification_settings() + st.success("تم حفظ إعدادات الإشعارات بنجاح") + + def _render_notification_scheduling(self): + """عرض واجهة جدولة الإشعارات""" + st.markdown(""" +
+

🕒 جدولة الإشعارات

+

إنشاء وإدارة الإشعارات المجدولة والتذكيرات الدورية.

+
+ """, unsafe_allow_html=True) + + # إنشاء تذكير جديد + st.markdown("### إنشاء تذكير جديد") + + col1, col2 = st.columns(2) + + with col1: + reminder_name = st.text_input("عنوان التذكير", key="new_reminder_name") + reminder_desc = st.text_area("وصف التذكير", key="new_reminder_desc") + reminder_date = st.date_input("تاريخ التذكير", key="new_reminder_date") + reminder_time = st.time_input("وقت التذكير", key="new_reminder_time") + + with col2: + reminder_type = st.selectbox( + "نوع التذكير", + options=[ + "موعد نهائي للمناقصة", + "اجتماع مشروع", + "زيارة موقع", + "تسليم مستندات", + "دفعة مالية", + "مراجعة أداء", + "أخرى" + ], + key="new_reminder_type" + ) + + reminder_priority = st.select_slider( + "الأولوية", + options=["منخفضة", "متوسطة", "عالية", "حرجة"], + value="متوسطة", + key="new_reminder_priority" + ) + + reminder_repeat = st.selectbox( + "التكرار", + options=[ + "مرة واحدة", + "يومياً", + "أسبوعياً", + "شهرياً", + "سنوياً" + ], + key="new_reminder_repeat" + ) + + if reminder_type == "أخرى": + custom_type = st.text_input("حدد نوع التذكير", key="custom_reminder_type") + + # زر إضافة التذكير + if styled_button("إضافة التذكير", key="add_reminder", type="primary", icon="➕"): + if not reminder_name or not reminder_desc: + st.error("يرجى تعبئة حقول العنوان والوصف") + else: + self._add_scheduled_notification( + title=reminder_name, + message=reminder_desc, + notification_date=datetime.datetime.combine(reminder_date, reminder_time), + notification_type=reminder_type if reminder_type != "أخرى" else custom_type, + priority=reminder_priority, + repeat=reminder_repeat + ) + st.success("تم إضافة التذكير بنجاح") + + # عرض التذكيرات المجدولة + st.markdown("### التذكيرات المجدولة") + + # التحقق من وجود تذكيرات مجدولة + scheduled_notifications = self._get_scheduled_notifications() + + if scheduled_notifications: + # عرض التذكيرات في جدول + scheduled_df = pd.DataFrame(scheduled_notifications) + + # تنسيق البيانات للعرض + display_df = scheduled_df.copy() + display_df["التاريخ والوقت"] = display_df["notification_date"].apply(lambda x: x.strftime("%Y-%m-%d %H:%M")) + display_df["العنوان"] = display_df["title"] + display_df["النوع"] = display_df["notification_type"] + display_df["الأولوية"] = display_df["priority"] + display_df["التكرار"] = display_df["repeat"] + + # عرض الجدول + st.dataframe( + display_df[["العنوان", "النوع", "التاريخ والوقت", "الأولوية", "التكرار"]], + use_container_width=True + ) + + # عرض الإشعارات المجدولة كبطاقات + for notification in scheduled_notifications: + with st.expander(f"{notification['title']} - {notification['notification_date'].strftime('%Y-%m-%d %H:%M')}"): + notification_col1, notification_col2 = st.columns([3, 1]) + + with notification_col1: + st.markdown(f"**الوصف:** {notification['message']}") + st.markdown(f"**النوع:** {notification['notification_type']}") + st.markdown(f"**الأولوية:** {notification['priority']}") + st.markdown(f"**التكرار:** {notification['repeat']}") + + with notification_col2: + if styled_button("تعديل", key=f"edit_{notification['id']}", type="secondary", icon="✏️"): + # تنفيذ في المرحلة القادمة + st.info("ميزة التعديل قيد التطوير") + + if styled_button("حذف", key=f"delete_{notification['id']}", type="danger", icon="🗑️"): + self._delete_scheduled_notification(notification['id']) + st.rerun() + else: + st.info("لا توجد تذكيرات مجدولة") + + def _render_notification_analytics(self): + """عرض تقارير وإحصائيات الإشعارات""" + st.markdown(""" +
+

📊 تقارير وإحصائيات الإشعارات

+

تحليل وعرض إحصائيات الإشعارات والتنبيهات.

+
+ """, unsafe_allow_html=True) + + # إحصائيات عامة + st.markdown("### إحصائيات عامة") + + # التحقق من وجود إشعارات + if st.session_state.notifications: + # إعداد البيانات + total_count = len(st.session_state.notifications) + read_count = len([n for n in st.session_state.notifications if n.get("read", False)]) + unread_count = total_count - read_count + + # تصنيف الإشعارات حسب النوع + notification_types = {} + for notification in st.session_state.notifications: + notification_type = notification.get("notification_type", "أخرى") + notification_types[notification_type] = notification_types.get(notification_type, 0) + 1 + + # عرض الإحصائيات + metric_col1, metric_col2, metric_col3 = st.columns(3) + + with metric_col1: + st.metric("إجمالي الإشعارات", total_count) + + with metric_col2: + st.metric("الإشعارات المقروءة", read_count, delta=f"{read_count/total_count*100:.1f}%" if total_count > 0 else "0%") + + with metric_col3: + st.metric("الإشعارات غير المقروءة", unread_count, delta=f"{unread_count/total_count*100:.1f}%" if total_count > 0 else "0%") + + # رسم بياني لتوزيع الإشعارات حسب النوع + st.markdown("### توزيع الإشعارات حسب النوع") + + # إنشاء DataFrame للرسم البياني + types_df = pd.DataFrame({ + "النوع": list(notification_types.keys()), + "العدد": list(notification_types.values()) + }) + + # رسم بياني دائري + import plotly.express as px + + fig = px.pie( + types_df, + values="العدد", + names="النوع", + title="توزيع الإشعارات حسب النوع", + color_discrete_sequence=px.colors.sequential.RdBu + ) + + fig.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig, use_container_width=True) + + # رسم بياني لتوزيع الإشعارات حسب الوقت + st.markdown("### توزيع الإشعارات حسب الوقت") + + # تحويل التواريخ إلى DataFrame + dates = [ + n.get("timestamp", datetime.datetime.now()).replace(hour=0, minute=0, second=0, microsecond=0) + for n in st.session_state.notifications + if "timestamp" in n + ] + + if dates: + date_counts = pd.Series(dates).value_counts().sort_index() + + # إنشاء DataFrame للرسم البياني + date_df = pd.DataFrame({ + "التاريخ": date_counts.index, + "العدد": date_counts.values + }) + + # رسم بياني خطي + fig2 = px.line( + date_df, + x="التاريخ", + y="العدد", + title="توزيع الإشعارات حسب التاريخ", + markers=True + ) + + fig2.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig2, use_container_width=True) + + # تصدير البيانات + st.markdown("### تصدير بيانات الإشعارات") + + export_col1, export_col2 = st.columns(2) + + with export_col1: + if styled_button("تصدير CSV", key="export_csv", type="primary", icon="📄"): + # تحويل الإشعارات إلى DataFrame + export_df = pd.DataFrame(st.session_state.notifications) + + # تنسيق البيانات + if "timestamp" in export_df.columns: + export_df["timestamp"] = export_df["timestamp"].apply( + lambda x: x.strftime("%Y-%m-%d %H:%M:%S") if isinstance(x, datetime.datetime) else str(x) + ) + + # تصدير إلى CSV + csv_data = export_df.to_csv(index=False) + + # تنزيل الملف + st.download_button( + label="تنزيل ملف CSV", + data=csv_data, + file_name=f"notifications_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", + mime="text/csv" + ) + + with export_col2: + if styled_button("تصدير JSON", key="export_json", type="primary", icon="📄"): + # تنسيق البيانات + export_data = [] + for notification in st.session_state.notifications: + export_item = notification.copy() + if "timestamp" in export_item and isinstance(export_item["timestamp"], datetime.datetime): + export_item["timestamp"] = export_item["timestamp"].strftime("%Y-%m-%d %H:%M:%S") + export_data.append(export_item) + + # تحويل إلى JSON + json_data = json.dumps(export_data, ensure_ascii=False, indent=2) + + # تنزيل الملف + st.download_button( + label="تنزيل ملف JSON", + data=json_data, + file_name=f"notifications_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json", + mime="application/json" + ) + else: + st.info("لا توجد بيانات كافية لعرض الرسم البياني") + else: + st.info("لا توجد إشعارات لعرض الإحصائيات") + + def _render_notification_card(self, notification, show_mark_button=False): + """عرض بطاقة إشعار""" + # تعيين نمط البطاقة حسب الأولوية والحالة + card_style = "notification-card" + if not notification.get("read", False): + card_style += " unread-notification" + + priority = notification.get("priority", "متوسطة") + if priority == "عالية" or priority == "حرجة": + card_style += " high-priority-notification" + + # تعيين الأيقونة حسب نوع الإشعار + icon_map = { + "تحديث مشروع": "🔄", + "وثيقة جديدة": "📄", + "تذكير موعد نهائي": "⏰", + "تنبيه مخاطر": "⚠️", + "تغيير سعر": "💰", + "إشارة فريق العمل": "👥", + "تحديث النظام": "🖥️" + } + + notification_type = notification.get("notification_type", "تحديث مشروع") + icon = icon_map.get(notification_type, "🔔") + + # تنسيق التاريخ + timestamp = notification.get("timestamp", datetime.datetime.now()) + if isinstance(timestamp, datetime.datetime): + time_str = timestamp.strftime("%Y-%m-%d %H:%M") + else: + time_str = str(timestamp) + + # إنشاء HTML للبطاقة + card_html = f""" +
+
+ {icon} + {notification.get('title', 'إشعار جديد')} + {time_str} +
+
+

{notification.get('message', '')}

+
+ +
+ """ + + # عرض البطاقة + st.markdown(card_html, unsafe_allow_html=True) + + # إضافة أزرار التحكم + if show_mark_button: + col1, col2 = st.columns([1, 4]) + + with col1: + if styled_button("تعليم كمقروء", key=f"mark_read_{notification.get('id', '')}", type="secondary", icon="✓"): + self._mark_notification_as_read(notification.get('id', '')) + st.rerun() + + with col2: + if notification.get("link"): + if styled_button("عرض التفاصيل", key=f"view_details_{notification.get('id', '')}", type="primary", icon="🔍"): + # افتح الرابط المرتبط بالإشعار + # ملاحظة: هذا سيعمل بشكل مختلف حسب بيئة التشغيل + st.markdown(f"[عرض التفاصيل]({notification.get('link')})") + + def add_notification(self, title, message, notification_type="تحديث مشروع", priority="متوسطة", link=None): + """ + إضافة إشعار جديد + + المعلمات: + title: عنوان الإشعار + message: نص الإشعار + notification_type: نوع الإشعار + priority: أولوية الإشعار + link: رابط مرتبط بالإشعار (اختياري) + + الإرجاع: + معرف الإشعار الجديد + """ + # إنشاء معرف فريد للإشعار + notification_id = f"notif_{int(time.time())}_{len(st.session_state.notifications)}" + + # إنشاء كائن الإشعار + notification = { + "id": notification_id, + "title": title, + "message": message, + "notification_type": notification_type, + "priority": priority, + "read": False, + "timestamp": datetime.datetime.now(), + "link": link + } + + # إضافة الإشعار لقائمة الإشعارات + st.session_state.notifications.append(notification) + + # زيادة عداد الإشعارات غير المقروءة + st.session_state.unread_count += 1 + + # حفظ الإشعارات + self._save_notifications() + + # تسجيل الإشعار + self.logger.info( + f"تمت إضافة إشعار جديد: {title} ({notification_type})" + ) + + return notification_id + + def _mark_notification_as_read(self, notification_id): + """ + تعليم إشعار كمقروء + + المعلمات: + notification_id: معرف الإشعار + + الإرجاع: + قيمة بوليانية تشير إلى نجاح العملية + """ + # البحث عن الإشعار + for i, notification in enumerate(st.session_state.notifications): + if notification.get("id") == notification_id and not notification.get("read", False): + # تعليم الإشعار كمقروء + st.session_state.notifications[i]["read"] = True + + # تحديث عداد الإشعارات غير المقروءة + st.session_state.unread_count = max(0, st.session_state.unread_count - 1) + + # حفظ الإشعارات + self._save_notifications() + + return True + + return False + + def _mark_all_as_read(self): + """ + تعليم جميع الإشعارات كمقروءة + + الإرجاع: + عدد الإشعارات التي تم تعليمها + """ + count = 0 + + # تعليم جميع الإشعارات كمقروءة + for i, notification in enumerate(st.session_state.notifications): + if not notification.get("read", False): + st.session_state.notifications[i]["read"] = True + count += 1 + + # إعادة تعيين عداد الإشعارات غير المقروءة + st.session_state.unread_count = 0 + + # حفظ الإشعارات + self._save_notifications() + + return count + + def _clear_all_notifications(self): + """ + حذف جميع الإشعارات + + الإرجاع: + عدد الإشعارات التي تم حذفها + """ + count = len(st.session_state.notifications) + + # مسح قائمة الإشعارات + st.session_state.notifications = [] + + # إعادة تعيين عداد الإشعارات غير المقروءة + st.session_state.unread_count = 0 + + # حفظ الإشعارات + self._save_notifications() + + return count + + def _filter_notifications(self, notification_type=None, date_range=None): + """ + تصفية الإشعارات حسب النوع والتاريخ + + المعلمات: + notification_type: قائمة أنواع الإشعارات + date_range: نطاق تاريخ الإشعارات + + الإرجاع: + قائمة الإشعارات المصفاة + """ + filtered_notifications = st.session_state.notifications.copy() + + # تصفية حسب النوع + if notification_type and len(notification_type) > 0: + filtered_notifications = [ + n for n in filtered_notifications + if n.get("notification_type") in notification_type + ] + + # تصفية حسب نطاق التاريخ + if date_range and len(date_range) == 2: + start_date, end_date = date_range + + # تحويل التواريخ إلى datetime + start_date = datetime.datetime.combine(start_date, datetime.time.min) + end_date = datetime.datetime.combine(end_date, datetime.time.max) + + filtered_notifications = [ + n for n in filtered_notifications + if isinstance(n.get("timestamp"), datetime.datetime) and + start_date <= n.get("timestamp") <= end_date + ] + + return filtered_notifications + + def _add_scheduled_notification(self, title, message, notification_date, notification_type="تذكير", priority="متوسطة", repeat="مرة واحدة"): + """ + إضافة إشعار مجدول + + المعلمات: + title: عنوان الإشعار + message: نص الإشعار + notification_date: تاريخ ووقت الإشعار + notification_type: نوع الإشعار + priority: أولوية الإشعار + repeat: نمط تكرار الإشعار + + الإرجاع: + معرف الإشعار المجدول + """ + # إنشاء معرف فريد للإشعار المجدول + scheduled_id = f"sched_{int(time.time())}_{len(self._get_scheduled_notifications())}" + + # إنشاء كائن الإشعار المجدول + scheduled_notification = { + "id": scheduled_id, + "title": title, + "message": message, + "notification_date": notification_date, + "notification_type": notification_type, + "priority": priority, + "repeat": repeat, + "created_at": datetime.datetime.now(), + "last_triggered": None + } + + # إضافة الإشعار المجدول للقائمة + scheduled_notifications = self._get_scheduled_notifications() + scheduled_notifications.append(scheduled_notification) + + # حفظ الإشعارات المجدولة + self._save_scheduled_notifications(scheduled_notifications) + + # تسجيل الإشعار المجدول + self.logger.info( + f"تمت إضافة إشعار مجدول: {title} ({notification_date.strftime('%Y-%m-%d %H:%M')})" + ) + + return scheduled_id + + def _delete_scheduled_notification(self, notification_id): + """ + حذف إشعار مجدول + + المعلمات: + notification_id: معرف الإشعار المجدول + + الإرجاع: + قيمة بوليانية تشير إلى نجاح العملية + """ + scheduled_notifications = self._get_scheduled_notifications() + + # البحث عن الإشعار المجدول + for i, notification in enumerate(scheduled_notifications): + if notification.get("id") == notification_id: + # حذف الإشعار المجدول + del scheduled_notifications[i] + + # حفظ الإشعارات المجدولة + self._save_scheduled_notifications(scheduled_notifications) + + # تسجيل الحذف + self.logger.info( + f"تم حذف الإشعار المجدول: {notification_id}" + ) + + return True + + return False + + def _get_scheduled_notifications(self): + """ + الحصول على قائمة الإشعارات المجدولة + + الإرجاع: + قائمة الإشعارات المجدولة + """ + try: + # التحقق من وجود ملف الإشعارات المجدولة + scheduled_file = os.path.join(self.data_dir, "scheduled_notifications.json") + + if os.path.exists(scheduled_file): + with open(scheduled_file, 'r', encoding='utf-8') as f: + scheduled_data = json.load(f) + + # تحويل التواريخ من نصوص إلى كائنات datetime + for notification in scheduled_data: + if "notification_date" in notification: + notification["notification_date"] = datetime.datetime.fromisoformat(notification["notification_date"]) + + if "created_at" in notification: + notification["created_at"] = datetime.datetime.fromisoformat(notification["created_at"]) + + if "last_triggered" in notification and notification["last_triggered"]: + notification["last_triggered"] = datetime.datetime.fromisoformat(notification["last_triggered"]) + + return scheduled_data + + return [] + + except Exception as e: + self.logger.error(f"حدث خطأ أثناء قراءة الإشعارات المجدولة: {str(e)}") + return [] + + def _save_scheduled_notifications(self, scheduled_notifications): + """ + حفظ قائمة الإشعارات المجدولة + + المعلمات: + scheduled_notifications: قائمة الإشعارات المجدولة + """ + try: + # التأكد من وجود المجلد + os.makedirs(self.data_dir, exist_ok=True) + + # تحويل كائنات datetime إلى نصوص + scheduled_data = [] + + for notification in scheduled_notifications: + notification_copy = notification.copy() + + if "notification_date" in notification_copy and isinstance(notification_copy["notification_date"], datetime.datetime): + notification_copy["notification_date"] = notification_copy["notification_date"].isoformat() + + if "created_at" in notification_copy and isinstance(notification_copy["created_at"], datetime.datetime): + notification_copy["created_at"] = notification_copy["created_at"].isoformat() + + if "last_triggered" in notification_copy and isinstance(notification_copy["last_triggered"], datetime.datetime): + notification_copy["last_triggered"] = notification_copy["last_triggered"].isoformat() + + scheduled_data.append(notification_copy) + + # حفظ البيانات + scheduled_file = os.path.join(self.data_dir, "scheduled_notifications.json") + + with open(scheduled_file, 'w', encoding='utf-8') as f: + json.dump(scheduled_data, f, ensure_ascii=False, indent=2) + + except Exception as e: + self.logger.error(f"حدث خطأ أثناء حفظ الإشعارات المجدولة: {str(e)}") + + def _save_notification_settings(self): + """حفظ إعدادات الإشعارات""" + try: + # التأكد من وجود المجلد + os.makedirs(self.data_dir, exist_ok=True) + + # إعداد البيانات + settings_data = { + "notification_channels": st.session_state.notification_channels, + "notification_preferences": st.session_state.notification_preferences, + "notification_email": st.session_state.get("notification_email", ""), + "notification_phone": st.session_state.get("notification_phone", ""), + "notification_frequency": st.session_state.get("notification_frequency", "فوري"), + "custom_frequency_hours": st.session_state.get("custom_frequency_hours", 4), + "show_on_startup": st.session_state.get("show_on_startup", True), + "audio_notifications": st.session_state.get("audio_notifications", False), + "log_notifications": st.session_state.get("log_notifications", True), + "retention_days": st.session_state.get("retention_days", 90) + } + + # حفظ البيانات + settings_file = os.path.join(self.data_dir, "notification_settings.json") + + with open(settings_file, 'w', encoding='utf-8') as f: + json.dump(settings_data, f, ensure_ascii=False, indent=2) + + # تسجيل الحفظ + self.logger.info("تم حفظ إعدادات الإشعارات بنجاح") + + except Exception as e: + self.logger.error(f"حدث خطأ أثناء حفظ إعدادات الإشعارات: {str(e)}") + + def _load_notification_settings(self): + """تحميل إعدادات الإشعارات""" + try: + # التحقق من وجود ملف الإعدادات + settings_file = os.path.join(self.data_dir, "notification_settings.json") + + if os.path.exists(settings_file): + with open(settings_file, 'r', encoding='utf-8') as f: + settings_data = json.load(f) + + # تحديث حالة الجلسة + st.session_state.notification_channels = settings_data.get("notification_channels", {}) + st.session_state.notification_preferences = settings_data.get("notification_preferences", {}) + st.session_state.notification_email = settings_data.get("notification_email", "") + st.session_state.notification_phone = settings_data.get("notification_phone", "") + st.session_state.notification_frequency = settings_data.get("notification_frequency", "فوري") + st.session_state.custom_frequency_hours = settings_data.get("custom_frequency_hours", 4) + st.session_state.show_on_startup = settings_data.get("show_on_startup", True) + st.session_state.audio_notifications = settings_data.get("audio_notifications", False) + st.session_state.log_notifications = settings_data.get("log_notifications", True) + st.session_state.retention_days = settings_data.get("retention_days", 90) + + # تسجيل التحميل + self.logger.info("تم تحميل إعدادات الإشعارات بنجاح") + + except Exception as e: + self.logger.error(f"حدث خطأ أثناء تحميل إعدادات الإشعارات: {str(e)}") + + def _save_notifications(self): + """حفظ الإشعارات""" + try: + # التأكد من وجود المجلد + os.makedirs(self.data_dir, exist_ok=True) + + # تحويل كائنات datetime إلى نصوص + notifications_data = [] + + for notification in st.session_state.notifications: + notification_copy = notification.copy() + + if "timestamp" in notification_copy and isinstance(notification_copy["timestamp"], datetime.datetime): + notification_copy["timestamp"] = notification_copy["timestamp"].isoformat() + + notifications_data.append(notification_copy) + + # حفظ البيانات + notifications_file = os.path.join(self.data_dir, "notifications.json") + + with open(notifications_file, 'w', encoding='utf-8') as f: + json.dump(notifications_data, f, ensure_ascii=False, indent=2) + + except Exception as e: + self.logger.error(f"حدث خطأ أثناء حفظ الإشعارات: {str(e)}") + + def _load_notifications(self): + """تحميل الإشعارات""" + try: + # التحقق من وجود ملف الإشعارات + notifications_file = os.path.join(self.data_dir, "notifications.json") + + if os.path.exists(notifications_file): + with open(notifications_file, 'r', encoding='utf-8') as f: + notifications_data = json.load(f) + + # تحويل النصوص إلى كائنات datetime + for notification in notifications_data: + if "timestamp" in notification: + notification["timestamp"] = datetime.datetime.fromisoformat(notification["timestamp"]) + + # تحديث حالة الجلسة + st.session_state.notifications = notifications_data + + # حساب عدد الإشعارات غير المقروءة + st.session_state.unread_count = len([ + n for n in st.session_state.notifications + if not n.get("read", False) + ]) + + # تحميل إعدادات الإشعارات + self._load_notification_settings() + + # تسجيل التحميل + self.logger.info(f"تم تحميل {len(notifications_data)} إشعار بنجاح") + + except Exception as e: + self.logger.error(f"حدث خطأ أثناء تحميل الإشعارات: {str(e)}") + + def check_scheduled_notifications(self): + """ + التحقق من الإشعارات المجدولة وإطلاقها إذا حان وقتها + + الإرجاع: + عدد الإشعارات التي تم إطلاقها + """ + count = 0 + + # الحصول على الإشعارات المجدولة + scheduled_notifications = self._get_scheduled_notifications() + + # الوقت الحالي + now = datetime.datetime.now() + + # التحقق من كل إشعار مجدول + for notification in scheduled_notifications: + notification_date = notification.get("notification_date") + + if notification_date and notification_date <= now: + # إنشاء إشعار جديد + self.add_notification( + title=notification.get("title"), + message=notification.get("message"), + notification_type=notification.get("notification_type"), + priority=notification.get("priority") + ) + + # تحديث آخر مرة تم فيها إطلاق الإشعار + notification["last_triggered"] = now + + # التعامل مع التكرار + repeat = notification.get("repeat", "مرة واحدة") + + if repeat == "مرة واحدة": + # حذف الإشعار المجدول + self._delete_scheduled_notification(notification.get("id")) + else: + # حساب التاريخ التالي + if repeat == "يومياً": + new_date = notification_date + datetime.timedelta(days=1) + elif repeat == "أسبوعياً": + new_date = notification_date + datetime.timedelta(weeks=1) + elif repeat == "شهرياً": + # إضافة شهر (تقريبي) + new_month = notification_date.month + 1 + new_year = notification_date.year + + if new_month > 12: + new_month = 1 + new_year += 1 + + new_date = notification_date.replace(year=new_year, month=new_month) + elif repeat == "سنوياً": + new_date = notification_date.replace(year=notification_date.year + 1) + else: + # افتراضي: يومياً + new_date = notification_date + datetime.timedelta(days=1) + + # تحديث تاريخ الإشعار المجدول + notification["notification_date"] = new_date + + count += 1 + + # حفظ الإشعارات المجدولة إذا تم تغييرها + if count > 0: + self._save_scheduled_notifications(scheduled_notifications) + + return count + + +# تطبيق وحدة نظام الإشعارات الذكي +class NotificationsApp: + """وحدة تطبيق نظام الإشعارات الذكي""" + + def __init__(self): + """تهيئة وحدة تطبيق نظام الإشعارات الذكي""" + self.smart_notification_system = SmartNotificationSystem() + + def render(self): + """عرض واجهة وحدة تطبيق نظام الإشعارات الذكي""" + st.markdown("

نظام الإشعارات الذكي لتحديثات المشروع والتنبيهات

", unsafe_allow_html=True) + + st.markdown(""" +
+ يتيح لك نظام الإشعارات الذكي متابعة تحديثات المشاريع وتلقي تنبيهات مخصصة حسب أدوارك واهتماماتك. + يمكنك تخصيص إعدادات الإشعارات وجدولة التذكيرات للمواعيد النهائية والمهام. +
+ """, unsafe_allow_html=True) + + # عرض نظام الإشعارات الذكي + self.smart_notification_system.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="نظام الإشعارات الذكي | WAHBi AI", + page_icon="🔔", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = NotificationsApp() + app.render() \ No newline at end of file diff --git a/modules/pricing/constants.py b/modules/pricing/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..47389637d89c60506bf252b975c1caf981e05798 --- /dev/null +++ b/modules/pricing/constants.py @@ -0,0 +1,113 @@ +""" +ثوابت وحدة التسعير +""" + +# أوزان المحتوى المحلي +LOCAL_CONTENT_WEIGHTS = { + 'منتجات_البناء': 1.5, # المنتجات الأساسية في البناء لها وزن أكبر + 'المنتجات_الإنشائية': 1.5, # المنتجات الإنشائية لها وزن أكبر + 'منتجات_التشطيب': 1.0, # منتجات التشطيب لها وزن عادي + 'الخدمات_الهندسية': 1.3, # الخدمات الهندسية لها وزن أكبر + 'الخدمات_الإدارية': 1.0, # الخدمات الإدارية لها وزن عادي + 'القوى_العاملة_الفنية': 1.2, # القوى العاملة الفنية لها وزن أكبر + 'القوى_العاملة_العادية': 1.0, # القوى العاملة العادية لها وزن عادي + 'القوى_العاملة_الإدارية': 0.8 # القوى العاملة الإدارية لها وزن أقل +} + +# فئات التكاليف +COST_CATEGORIES = { + 'مباشرة': [ + 'مواد', + 'عمالة', + 'معدات', + 'مقاولين من الباطن' + ], + 'غير_مباشرة': [ + 'إدارة المشروع', + 'ضمانات بنكية', + 'تأمينات', + 'مكاتب الموقع', + 'نقل وسكن', + 'مرافق', + 'أمن وسلامة' + ], + 'مصاريف_عامة': [ + 'مصاريف إدارية', + 'رواتب إدارية', + 'إيجارات', + 'اتصالات', + 'قرطاسية', + 'تسويق وعلاقات عامة' + ], + 'احتياطيات': [ + 'احتياطي مخاطر', + 'احتياطي تضخم', + 'احتياطي تغييرات' + ] +} + +# أنواع التسعير +PRICING_TYPES = { + 'قياسي': 'التسعير المتوازن لجميع البنود', + 'غير_متزن': 'تحميل بعض البنود بسعر أعلى وتخفيض بنود أخرى مع الحفاظ على نفس الإجمالي', + 'تنافسي': 'التسعير بناءً على أسعار المنافسين', + 'ربحية': 'التسعير بناءً على هامش الربح المستهدف' +} + +# أنواع استراتيجيات التسعير غير المتزن +UNBALANCED_PRICING_STRATEGIES = { + 'تحميل_أمامي': 'زيادة أسعار البنود المبكرة في المشروع', + 'تحميل_خلفي': 'زيادة أسعار البنود المتأخرة في المشروع', + 'تحميل_مؤكد': 'زيادة أسعار البنود المؤكدة التنفيذ', + 'تخفيض_متغير': 'تخفيض أسعار البنود المحتمل تغير كمياتها' +} + +# معلمات افتراضية للمشروع +DEFAULT_PROJECT_PARAMS = { + 'نسبة_المصاريف_العامة': 8.0, # 8% من التكاليف المباشرة + 'نسبة_الأرباح': 10.0, # 10% من التكاليف الكلية + 'نسبة_احتياطي_المخاطر': 5.0, # 5% من التكاليف المباشرة + 'نسبة_ضمان_ابتدائي': 2.0, # 2% من قيمة العطاء + 'نسبة_ضمان_نهائي': 5.0, # 5% من قيمة العطاء + 'نسبة_محتجزات': 10.0, # 10% من قيمة المستخلصات + 'نسبة_دفعة_مقدمة': 10.0 # 10% من قيمة العطاء +} + +# وحدات القياس +UNITS_OF_MEASURE = { + 'طولية': ['م.ط', 'متر طولي', 'م'], + 'مسطحة': ['م2', 'متر مربع'], + 'حجمية': ['م3', 'متر مكعب'], + 'وزن': ['كجم', 'طن', 'جم'], + 'عدد': ['عدد', 'وحدة', 'قطعة'], + 'زمن': ['يوم', 'ساعة', 'شهر'], + 'نقطة': ['نقطة', 'مخرج'] +} + +# نسب الزيادة في التكاليف +COST_INCREASE_FACTORS = { + 'تعقيد_مرتفع': 1.25, # زيادة 25% للأعمال المعقدة + 'تعقيد_متوسط': 1.15, # زيادة 15% للأعمال متوسطة التعقيد + 'منطقة_نائية': 1.2, # زيادة 20% للمناطق النائية + 'ظروف_جوية_قاسية': 1.15, # زيادة 15% للظروف الجوية القاسية + 'ظروف_الموقع_صعبة': 1.2, # زيادة 20% لظروف الموقع الصعبة + 'عاجل': 1.3 # زيادة 30% للأعمال العاجلة +} + +# أنواع المشاريع +PROJECT_TYPES = [ + 'سكني', + 'تجاري', + 'صناعي', + 'تعليمي', + 'صحي', + 'بنية تحتية', + 'طرق', + 'نقل', + 'طاقة', + 'مياه وصرف صحي', + 'اتصالات', + 'عسكري', + 'ترفيهي', + 'متعدد الاستخدام' +] \ No newline at end of file diff --git a/modules/pricing/construction_calculator.py b/modules/pricing/construction_calculator.py new file mode 100644 index 0000000000000000000000000000000000000000..4423dbcd8aaab2cece0a4865df496eea39aa2667 --- /dev/null +++ b/modules/pricing/construction_calculator.py @@ -0,0 +1,787 @@ +""" +حاسبة تكاليف البناء المتكاملة +تتضمن العناصر التالية: +- المواد الخام +- المعدات +- العمالة +- المصاريف الإدارية +- هامش الربح +""" + +import streamlit as st +import pandas as pd +import numpy as np +import plotly.express as px +import plotly.graph_objects as go + + +def render_construction_calculator(): + """ + عرض حاسبة تكاليف البناء المتكاملة + """ + # التأكد من وجود المتغيرات في حالة الجلسة + if 'materials_cost' not in st.session_state: + st.session_state.materials_cost = 0.0 + if 'equipment_cost' not in st.session_state: + st.session_state.equipment_cost = 0.0 + if 'labor_cost' not in st.session_state: + st.session_state.labor_cost = 0.0 + if 'admin_cost' not in st.session_state: + st.session_state.admin_cost = 0.0 + if 'profit_margin' not in st.session_state: + st.session_state.profit_margin = 15.0 + + st.markdown("

حاسبة تكاليف البناء المتكاملة

", unsafe_allow_html=True) + + # معلومات المشروع + st.markdown("

معلومات المشروع

", unsafe_allow_html=True) + + col1, col2 = st.columns(2) + + with col1: + project_name = st.text_input("اسم المشروع", "مشروع سكني") + project_location = st.text_input("موقع المشروع", "الرياض - حي النرجس") + + with col2: + project_area = st.number_input("المساحة الإجمالية (م²)", min_value=1, value=500) + project_type = st.selectbox( + "نوع المشروع", + options=[ + "سكني", "تجاري", "صناعي", "إداري", "صحي", "تعليمي", + "بنية تحتية", "طرق", "جسور", "أخرى" + ] + ) + + # التبويبات الرئيسية للحاسبة + tabs = st.tabs([ + "المواد الخام", "المعدات", "العمالة", "المصاريف الإدارية", "هامش الربح", "التقرير النهائي" + ]) + + # تعريف المتغيرات العامة + if "materials_cost" not in st.session_state: + st.session_state.materials_cost = 0.0 + if "equipment_cost" not in st.session_state: + st.session_state.equipment_cost = 0.0 + if "labor_cost" not in st.session_state: + st.session_state.labor_cost = 0.0 + if "admin_cost" not in st.session_state: + st.session_state.admin_cost = 0.0 + if "profit_margin" not in st.session_state: + st.session_state.profit_margin = 10.0 + if "materials" not in st.session_state: + st.session_state.materials = [] + if "equipment" not in st.session_state: + st.session_state.equipment = [] + if "labor" not in st.session_state: + st.session_state.labor = [] + if "admin_expenses" not in st.session_state: + st.session_state.admin_expenses = [] + + # تبويب المواد الخام + with tabs[0]: + render_materials_tab() + + # تبويب المعدات + with tabs[1]: + render_equipment_tab() + + # تبويب العمالة + with tabs[2]: + render_labor_tab() + + # تبويب المصاريف الإدارية + with tabs[3]: + render_admin_tab() + + # تبويب هامش الربح + with tabs[4]: + render_profit_tab() + + # تبويب التقرير النهائي + with tabs[5]: + render_final_report(project_name, project_location, project_area, project_type) + + +def render_materials_tab(): + """ + عرض تبويب المواد الخام + """ + st.markdown("

تكاليف المواد الخام

", unsafe_allow_html=True) + + # إضافة مادة جديدة + st.markdown("

إضافة مادة جديدة

", unsafe_allow_html=True) + + col1, col2, col3, col4 = st.columns(4) + + with col1: + material_name = st.text_input("اسم المادة", key="new_material_name") + with col2: + material_quantity = st.number_input("الكمية", min_value=0.0, step=0.1, key="new_material_quantity") + with col3: + material_unit = st.selectbox( + "الوحدة", + options=["م²", "م³", "طن", "كجم", "لتر", "قطعة", "لفة", "كيس", "أخرى"], + key="new_material_unit" + ) + with col4: + material_price = st.number_input("السعر للوحدة (ريال)", min_value=0.0, step=0.01, key="new_material_price") + + if st.button("إضافة مادة", key="add_material_btn"): + total_price = material_quantity * material_price + new_material = { + "name": material_name, + "quantity": material_quantity, + "unit": material_unit, + "price": material_price, + "total": total_price + } + st.session_state.materials.append(new_material) + st.success(f"تمت إضافة {material_name} بنجاح!") + + # عرض قائمة المواد المضافة + if st.session_state.materials: + st.markdown("

قائمة المواد المضافة

", unsafe_allow_html=True) + + materials_df = pd.DataFrame(st.session_state.materials) + materials_df.columns = ["اسم المادة", "الكمية", "الوحدة", "السعر للوحدة", "التكلفة الإجمالية"] + st.dataframe(materials_df) + + total_materials_cost = sum(item["total"] for item in st.session_state.materials) + st.session_state.materials_cost = total_materials_cost + + st.markdown(f"

إجمالي تكلفة المواد: {total_materials_cost:,.2f} ريال

", unsafe_allow_html=True) + + # رسم بياني للمواد حسب التكلفة + if len(st.session_state.materials) > 1: + st.markdown("

توزيع تكاليف المواد

", unsafe_allow_html=True) + + fig = px.pie( + materials_df, + values="التكلفة الإجمالية", + names="اسم المادة", + title="توزيع تكاليف المواد", + color_discrete_sequence=px.colors.sequential.Teal, + hole=0.4 + ) + fig.update_layout( + font=dict(family="Almarai, Arial", size=14), + margin=dict(t=50, b=50, l=20, r=20) + ) + st.plotly_chart(fig, use_container_width=True) + + st.markdown("---") + + # استيراد بيانات المواد من ملف + st.markdown("

استيراد بيانات المواد من ملف

", unsafe_allow_html=True) + uploaded_file = st.file_uploader("اختر ملف Excel أو CSV", type=["xlsx", "csv"], key="materials_upload") + + if uploaded_file is not None: + if uploaded_file.name.endswith('.csv'): + df = pd.read_csv(uploaded_file) + else: + df = pd.read_excel(uploaded_file) + + st.success("تم استيراد البيانات بنجاح!") + st.dataframe(df) + + if st.button("إضافة المواد من الملف"): + try: + # تحويل أسماء الأعمدة للمطابقة مع النظام + column_mapping = { + "المادة": "name", + "اسم المادة": "name", + "الكمية": "quantity", + "الوحدة": "unit", + "السعر": "price", + "سعر الوحدة": "price" + } + + mapped_df = df.rename(columns=column_mapping) + + # حساب التكلفة الإجمالية لكل مادة + for _, row in mapped_df.iterrows(): + total_price = row["quantity"] * row["price"] + new_material = { + "name": row["name"], + "quantity": row["quantity"], + "unit": row["unit"], + "price": row["price"], + "total": total_price + } + st.session_state.materials.append(new_material) + + st.success("تمت إضافة جميع المواد من الملف بنجاح!") + + except Exception as e: + st.error(f"حدث خطأ: {str(e)}") + st.error("تأكد من أن الملف يحتوي على الأعمدة المطلوبة: اسم المادة، الكمية، الوحدة، السعر للوحدة") + + +def render_equipment_tab(): + """ + عرض تبويب المعدات + """ + st.markdown("

تكاليف المعدات

", unsafe_allow_html=True) + + # إضافة معدة جديدة + st.markdown("

إضافة معدة جديدة

", unsafe_allow_html=True) + + col1, col2, col3 = st.columns(3) + + with col1: + equipment_name = st.text_input("اسم المعدة", key="new_equipment_name") + with col2: + rental_type = st.selectbox( + "نوع الإيجار", + options=["يومي", "أسبوعي", "شهري", "سنوي", "مملوكة (استهلاك)"], + key="rental_type" + ) + with col3: + usage_period = st.number_input(f"مدة الاستخدام ({rental_type})", min_value=1, value=1, key="usage_period") + + col4, col5, col6 = st.columns(3) + + with col4: + equipment_rate = st.number_input(f"سعر الإيجار لكل ({rental_type}) (ريال)", min_value=0.0, step=0.01, key="equipment_rate") + with col5: + fuel_cost = st.number_input("تكلفة الوقود اليومية (ريال)", min_value=0.0, step=0.01, key="fuel_cost") + with col6: + operator_cost = st.number_input("تكلفة المشغل اليومية (ريال)", min_value=0.0, step=0.01, key="operator_cost") + + # حساب إجمالي التكلفة + rental_days = { + "يومي": 1, + "أسبوعي": 7, + "شهري": 30, + "سنوي": 365, + "مملوكة (استهلاك)": 1 + } + + total_days = usage_period * rental_days[rental_type] + total_equipment_cost = equipment_rate * usage_period + total_fuel_cost = fuel_cost * total_days + total_operator_cost = operator_cost * total_days + total_cost = total_equipment_cost + total_fuel_cost + total_operator_cost + + if st.button("إضافة معدة", key="add_equipment_btn"): + new_equipment = { + "name": equipment_name, + "rental_type": rental_type, + "usage_period": usage_period, + "equipment_rate": equipment_rate, + "fuel_cost": fuel_cost, + "operator_cost": operator_cost, + "total": total_cost + } + st.session_state.equipment.append(new_equipment) + st.success(f"تمت إضافة {equipment_name} بنجاح!") + + # عرض تفاصيل الحساب + st.markdown("
", unsafe_allow_html=True) + st.markdown(f"

عدد أيام الاستخدام الإجمالية: {total_days} يوم

", unsafe_allow_html=True) + st.markdown(f"

تكلفة إيجار المعدة: {total_equipment_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

تكلفة الوقود: {total_fuel_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

تكلفة المشغل: {total_operator_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

التكلفة الإجمالية للمعدة: {total_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # عرض قائمة المعدات المضافة + if st.session_state.equipment: + st.markdown("

قائمة المعدات المضافة

", unsafe_allow_html=True) + + equipment_data = [] + for item in st.session_state.equipment: + equipment_data.append({ + "اسم المعدة": item["name"], + "نوع الإيجار": item["rental_type"], + "مدة الاستخدام": item["usage_period"], + "إيجار الوحدة": item["equipment_rate"], + "تكلفة الوقود": item["fuel_cost"], + "تكلفة المشغل": item["operator_cost"], + "التكلفة الإجمالية": item["total"] + }) + + equipment_df = pd.DataFrame(equipment_data) + st.dataframe(equipment_df) + + total_equipment_cost = sum(item["total"] for item in st.session_state.equipment) + st.session_state.equipment_cost = total_equipment_cost + + st.markdown(f"

إجمالي تكلفة المعدات: {total_equipment_cost:,.2f} ريال

", unsafe_allow_html=True) + + # رسم بياني للمعدات حسب التكلفة + if len(st.session_state.equipment) > 1: + st.markdown("

توزيع تكاليف المعدات

", unsafe_allow_html=True) + + fig = go.Figure() + + fig.add_trace(go.Bar( + x=[item["اسم المعدة"] for item in equipment_data], + y=[item["التكلفة الإجمالية"] for item in equipment_data], + name="التكلفة الإجمالية", + marker_color="teal" + )) + + fig.update_layout( + title="تكاليف المعدات", + xaxis_title="المعدة", + yaxis_title="التكلفة (ريال)", + font=dict(family="Almarai, Arial", size=14), + margin=dict(t=50, b=50, l=20, r=20) + ) + + st.plotly_chart(fig, use_container_width=True) + + +def render_labor_tab(): + """ + عرض تبويب العمالة + """ + st.markdown("

تكاليف العمالة

", unsafe_allow_html=True) + + # إضافة عامل أو مجموعة عمال + st.markdown("

إضافة عمالة جديدة

", unsafe_allow_html=True) + + col1, col2, col3 = st.columns(3) + + with col1: + labor_type = st.text_input("نوع العمالة", key="new_labor_type") + with col2: + labor_count = st.number_input("العدد", min_value=1, value=1, key="new_labor_count") + with col3: + payment_type = st.selectbox( + "نوع الدفع", + options=["يومي", "أسبوعي", "شهري", "بالقطعة"], + key="new_payment_type" + ) + + col4, col5, col6 = st.columns(3) + + with col4: + wage_rate = st.number_input(f"الأجرة ({payment_type}) (ريال)", min_value=0.0, step=0.01, key="new_wage_rate") + with col5: + work_period = st.number_input(f"مدة العمل ({payment_type})", min_value=1, value=30, key="new_work_period") + with col6: + benefits_percent = st.slider("نسبة البدلات والتأمين (%)", min_value=0, max_value=50, value=15, key="new_benefits_percent") + + # حساب إجمالي التكلفة + days_factor = { + "يومي": 1, + "أسبوعي": 7, + "شهري": 30, + "بالقطعة": 1 + } + + monthly_days = work_period * days_factor[payment_type] / 30 # تحويل الأيام إلى شهور + + if payment_type == "بالقطعة": + total_labor_cost = labor_count * wage_rate * work_period + else: + # حساب الراتب الشهري + monthly_wage = wage_rate * 30 / days_factor[payment_type] + # حساب تكلفة البدلات والتأمين + benefits_cost = monthly_wage * (benefits_percent / 100) + # إجمالي التكلفة الشهرية + monthly_total_cost = monthly_wage + benefits_cost + # إجمالي التكلفة + total_labor_cost = labor_count * monthly_total_cost * monthly_days + + if st.button("إضافة عمالة"): + new_labor = { + "type": labor_type, + "count": labor_count, + "payment_type": payment_type, + "wage_rate": wage_rate, + "work_period": work_period, + "benefits_percent": benefits_percent, + "total": total_labor_cost + } + st.session_state.labor.append(new_labor) + st.success(f"تمت إضافة {labor_type} بنجاح!") + + # عرض تفاصيل الحساب + st.markdown("
", unsafe_allow_html=True) + if payment_type != "بالقطعة": + monthly_wage = wage_rate * 30 / days_factor[payment_type] + benefits_cost = monthly_wage * (benefits_percent / 100) + monthly_total_cost = monthly_wage + benefits_cost + + st.markdown(f"

الراتب الشهري للعامل: {monthly_wage:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

تكلفة البدلات والتأمين الشهرية: {benefits_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي التكلفة الشهرية للعامل: {monthly_total_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

مدة العمل بالشهور: {monthly_days:.2f} شهر

", unsafe_allow_html=True) + else: + st.markdown(f"

سعر القطعة: {wage_rate:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

عدد القطع: {work_period}

", unsafe_allow_html=True) + + st.markdown(f"

عدد العمال: {labor_count}

", unsafe_allow_html=True) + st.markdown(f"

التكلفة الإجمالية للعمالة: {total_labor_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # عرض قائمة العمالة المضافة + if st.session_state.labor: + st.markdown("

قائمة العمالة المضافة

", unsafe_allow_html=True) + + labor_data = [] + for item in st.session_state.labor: + labor_data.append({ + "نوع العمالة": item["type"], + "العدد": item["count"], + "نوع الدفع": item["payment_type"], + "معدل الأجرة": item["wage_rate"], + "مدة العمل": item["work_period"], + "نسبة البدلات": f"{item['benefits_percent']}%", + "التكلفة الإجمالية": item["total"] + }) + + labor_df = pd.DataFrame(labor_data) + st.dataframe(labor_df) + + total_labor_cost = sum(item["total"] for item in st.session_state.labor) + st.session_state.labor_cost = total_labor_cost + + st.markdown(f"

إجمالي تكلفة العمالة: {total_labor_cost:,.2f} ريال

", unsafe_allow_html=True) + + # رسم بياني للعمالة حسب التكلفة + if len(st.session_state.labor) > 1: + st.markdown("

توزيع تكاليف العمالة

", unsafe_allow_html=True) + + fig = px.bar( + labor_df, + x="نوع العمالة", + y="التكلفة الإجمالية", + color="العدد", + title="توزيع تكاليف العمالة", + color_continuous_scale=px.colors.sequential.Teal + ) + fig.update_layout( + font=dict(family="Almarai, Arial", size=14), + margin=dict(t=50, b=50, l=20, r=20) + ) + st.plotly_chart(fig, use_container_width=True) + + +def render_admin_tab(): + """ + عرض تبويب المصاريف الإدارية + """ + st.markdown("

المصاريف الإدارية والعمومية

", unsafe_allow_html=True) + + # إضافة مصروف جديد + st.markdown("

إضافة مصروف جديد

", unsafe_allow_html=True) + + col1, col2, col3 = st.columns(3) + + with col1: + expense_name = st.text_input("اسم المصروف", key="new_expense_name") + with col2: + expense_type = st.selectbox( + "نوع المصروف", + options=[ + "رواتب إدارية", "إيجارات", "مكتبية", "سفر", "تأمين", + "استشارات", "رسوم حكومية", "منافع", "أخرى" + ], + key="new_expense_type" + ) + with col3: + expense_amount = st.number_input("المبلغ (ريال)", min_value=0.0, step=100.0, key="new_expense_amount") + + if st.button("إضافة مصروف"): + new_expense = { + "name": expense_name, + "type": expense_type, + "amount": expense_amount + } + st.session_state.admin_expenses.append(new_expense) + st.success(f"تمت إضافة {expense_name} بنجاح!") + + # عرض قائمة المصاريف المضافة + if st.session_state.admin_expenses: + st.markdown("

قائمة المصاريف الإدارية

", unsafe_allow_html=True) + + admin_data = [] + for item in st.session_state.admin_expenses: + admin_data.append({ + "اسم المصروف": item["name"], + "نوع المصروف": item["type"], + "المبلغ": item["amount"] + }) + + admin_df = pd.DataFrame(admin_data) + st.dataframe(admin_df) + + total_admin_cost = sum(item["amount"] for item in st.session_state.admin_expenses) + st.session_state.admin_cost = total_admin_cost + + st.markdown(f"

إجمالي المصاريف الإدارية: {total_admin_cost:,.2f} ريال

", unsafe_allow_html=True) + + # رسم بياني للمصاريف حسب النوع + if len(st.session_state.admin_expenses) > 1: + st.markdown("

توزيع المصاريف الإدارية حسب النوع

", unsafe_allow_html=True) + + # تجميع المصاريف حسب النوع + expense_by_type = admin_df.groupby("نوع المصروف")["المبلغ"].sum().reset_index() + + fig = px.pie( + expense_by_type, + values="المبلغ", + names="نوع المصروف", + title="توزيع المصاريف الإدارية", + color_discrete_sequence=px.colors.sequential.Teal, + hole=0.4 + ) + fig.update_layout( + font=dict(family="Almarai, Arial", size=14), + margin=dict(t=50, b=50, l=20, r=20) + ) + st.plotly_chart(fig, use_container_width=True) + + # نسبة المصاريف الإدارية + st.markdown("

احتساب المصاريف الإدارية بالنسبة المئوية

", unsafe_allow_html=True) + + # حساب التكاليف المباشرة + direct_costs = st.session_state.materials_cost + st.session_state.equipment_cost + st.session_state.labor_cost + + col1, col2 = st.columns(2) + + with col1: + admin_percent = st.slider("نسبة المصاريف الإدارية من التكاليف المباشرة (%)", min_value=0, max_value=30, value=10, key="admin_percent") + + with col2: + calculated_admin_cost = direct_costs * (admin_percent / 100) + st.markdown(f"

المصاريف الإدارية بالنسبة: {calculated_admin_cost:,.2f} ريال

", unsafe_allow_html=True) + + if st.button("استخدام النسبة المئوية للمصاريف الإدارية"): + st.session_state.admin_cost = calculated_admin_cost + st.success("تم تحديث إجمالي المصاريف الإدارية بناء على النسبة المئوية!") + + +def render_profit_tab(): + """ + عرض تبويب هامش الربح + """ + st.markdown("

هامش الربح

", unsafe_allow_html=True) + + # حساب التكاليف المباشرة والإجمالية + direct_costs = st.session_state.materials_cost + st.session_state.equipment_cost + st.session_state.labor_cost + total_costs = direct_costs + st.session_state.admin_cost + + # عرض ملخص التكاليف + st.markdown("
", unsafe_allow_html=True) + st.markdown("

ملخص التكاليف

", unsafe_allow_html=True) + st.markdown(f"

إجمالي تكلفة المواد: {st.session_state.materials_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي تكلفة المعدات: {st.session_state.equipment_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي تكلفة العمالة: {st.session_state.labor_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي التكاليف المباشرة: {direct_costs:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي المصاريف الإدارية: {st.session_state.admin_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي التكاليف: {total_costs:,.2f} ريال

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # تحديد هامش الربح + st.markdown("

تحديد هامش الربح

", unsafe_allow_html=True) + + col1, col2 = st.columns(2) + + with col1: + profit_margin = st.slider("نسبة هامش الربح (%)", min_value=0, max_value=30, value=int(st.session_state.profit_margin), key="profit_margin_slider") + st.session_state.profit_margin = profit_margin + + with col2: + profit_amount = total_costs * (profit_margin / 100) + st.markdown(f"

قيمة هامش الربح: {profit_amount:,.2f} ريال

", unsafe_allow_html=True) + + # إجمالي قيمة العرض + total_price = total_costs + profit_amount + st.markdown("
", unsafe_allow_html=True) + st.markdown(f"

إجمالي قيمة العرض: {total_price:,.2f} ريال

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # تحليل الحساسية لهامش الربح + st.markdown("

تحليل حساسية هامش الربح

", unsafe_allow_html=True) + + sensitivity_data = [] + for margin in range(5, 31, 5): + profit = total_costs * (margin / 100) + total = total_costs + profit + sensitivity_data.append({ + "نسبة الربح": f"{margin}%", + "قيمة الربح": profit, + "إجمالي العرض": total + }) + + sensitivity_df = pd.DataFrame(sensitivity_data) + + # رسم بياني لتحليل الحساسية + fig = go.Figure() + + fig.add_trace(go.Bar( + x=[item["نسبة الربح"] for item in sensitivity_data], + y=[item["قيمة الربح"] for item in sensitivity_data], + name="قيمة الربح", + marker_color="rgba(14, 165, 165, 0.7)" + )) + + fig.add_trace(go.Scatter( + x=[item["نسبة الربح"] for item in sensitivity_data], + y=[item["إجمالي العرض"] for item in sensitivity_data], + name="إجمالي العرض", + mode="lines+markers", + marker=dict(size=8, color="rgba(255, 154, 60, 1.0)"), + line=dict(width=3, color="rgba(255, 154, 60, 0.7)") + )) + + fig.update_layout( + title="تحليل حساسية هامش الربح", + xaxis_title="نسبة الربح", + yaxis_title="القيمة (ريال)", + font=dict(family="Almarai, Arial", size=14), + margin=dict(t=50, b=50, l=20, r=20), + hovermode="x unified" + ) + + st.plotly_chart(fig, use_container_width=True) + + # جدول تحليل الحساسية + st.dataframe(sensitivity_df) + + +def render_final_report(project_name, project_location, project_area, project_type): + """ + عرض التقرير النهائي للتكاليف + """ + st.markdown("

التقرير النهائي لتكاليف المشروع

", unsafe_allow_html=True) + + # التأكد من وجود المتغيرات المطلوبة في حالة الجلسة وضمان أن لديهم قيم صحيحة + required_fields = { + 'materials_cost': 0.0, + 'equipment_cost': 0.0, + 'labor_cost': 0.0, + 'admin_cost': 0.0, + 'profit_margin': 15.0, + 'materials': [], + 'equipment': [], + 'labor': [], + 'admin_expenses': [] + } + + # مرور على كافة الحقول المطلوبة للتأكد من وجودها + for field, default_value in required_fields.items(): + if field not in st.session_state: + st.session_state[field] = default_value + + # التحقق من أن القيم العددية صالحة (غير None وليست NaN) + if field in ['materials_cost', 'equipment_cost', 'labor_cost', 'admin_cost', 'profit_margin']: + # إذا كانت القيمة None أو NaN، استخدم القيمة الافتراضية + if st.session_state[field] is None or pd.isna(st.session_state[field]): + st.session_state[field] = default_value + + # حساب التكاليف المباشرة والإجمالية + direct_costs = st.session_state.materials_cost + st.session_state.equipment_cost + st.session_state.labor_cost + total_costs = direct_costs + st.session_state.admin_cost + profit_amount = total_costs * (st.session_state.profit_margin / 100) + total_price = total_costs + profit_amount + + # معلومات المشروع + st.markdown("
", unsafe_allow_html=True) + st.markdown("

معلومات المشروع

", unsafe_allow_html=True) + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"

اسم المشروع: {project_name}

", unsafe_allow_html=True) + st.markdown(f"

نوع المشروع: {project_type}

", unsafe_allow_html=True) + + with col2: + st.markdown(f"

موقع المشروع: {project_location}

", unsafe_allow_html=True) + st.markdown(f"

المساحة الإجمالية: {project_area} م²

", unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # ملخص التكاليف + st.markdown("
", unsafe_allow_html=True) + st.markdown("

ملخص التكاليف

", unsafe_allow_html=True) + + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"

تكلفة المواد: {st.session_state.materials_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

تكلفة المعدات: {st.session_state.equipment_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

تكلفة العمالة: {st.session_state.labor_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي التكاليف المباشرة: {direct_costs:,.2f} ريال

", unsafe_allow_html=True) + + with col2: + st.markdown(f"

المصاريف الإدارية: {st.session_state.admin_cost:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي التكاليف: {total_costs:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

هامش الربح ({st.session_state.profit_margin}%): {profit_amount:,.2f} ريال

", unsafe_allow_html=True) + st.markdown(f"

إجمالي قيمة العرض: {total_price:,.2f} ريال

", unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # عرض التفاصيل بالمتر المربع + if project_area > 0: + per_sqm_cost = total_price / project_area + st.markdown("
", unsafe_allow_html=True) + st.markdown("

تكلفة المتر المربع

", unsafe_allow_html=True) + st.markdown(f"

تكلفة المتر المربع الإجمالية: {per_sqm_cost:,.2f} ريال/م²

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + else: + st.markdown("
", unsafe_allow_html=True) + st.markdown("

تكلفة المتر المربع

", unsafe_allow_html=True) + st.markdown("

يرجى إدخال مساحة صحيحة للمشروع لحساب تكلفة المتر المربع

", unsafe_allow_html=True) + st.markdown("
", unsafe_allow_html=True) + + # رسم بياني لتوزيع التكاليف + st.markdown("

توزيع التكاليف

", unsafe_allow_html=True) + + # تجنب القسمة على صفر + if total_price > 0: + cost_distribution = [ + {"النوع": "المواد", "القيمة": st.session_state.materials_cost, "النسبة": st.session_state.materials_cost / total_price * 100}, + {"النوع": "المعدات", "القيمة": st.session_state.equipment_cost, "النسبة": st.session_state.equipment_cost / total_price * 100}, + {"النوع": "العمالة", "القيمة": st.session_state.labor_cost, "النسبة": st.session_state.labor_cost / total_price * 100}, + {"النوع": "المصاريف الإدارية", "القيمة": st.session_state.admin_cost, "النسبة": st.session_state.admin_cost / total_price * 100}, + {"النوع": "هامش الربح", "القيمة": profit_amount, "النسبة": profit_amount / total_price * 100} + ] + else: + # إذا كان المجموع صفر، اجعل جميع النسب المئوية صفر + cost_distribution = [ + {"النوع": "المواد", "القيمة": st.session_state.materials_cost, "النسبة": 0}, + {"النوع": "المعدات", "القيمة": st.session_state.equipment_cost, "النسبة": 0}, + {"النوع": "العمالة", "القيمة": st.session_state.labor_cost, "النسبة": 0}, + {"النوع": "المصاريف الإدارية", "القيمة": st.session_state.admin_cost, "النسبة": 0}, + {"النوع": "هامش الربح", "القيمة": profit_amount, "النسبة": 0} + ] + + cost_df = pd.DataFrame(cost_distribution) + + fig = px.pie( + cost_df, + values="القيمة", + names="النوع", + title="توزيع التكاليف والأرباح", + color_discrete_sequence=px.colors.sequential.Teal, + hole=0.4 + ) + + fig.update_traces(textposition='inside', textinfo='percent+label') + + fig.update_layout( + annotations=[dict(text=f"{total_price:,.0f} ريال", x=0.5, y=0.5, font_size=14, showarrow=False)], + font=dict(family="Almarai, Arial", size=14), + margin=dict(t=50, b=50, l=20, r=20) + ) + + st.plotly_chart(fig, use_container_width=True) + + # جدول توزيع التكاليف + st.dataframe(cost_df) + + # زر لإنشاء تقرير PDF + col1, col2 = st.columns(2) + + with col1: + if st.button("تصدير التقرير إلى PDF"): + st.success("تم تصدير التقرير بنجاح!") + + with col2: + if st.button("حفظ التقرير في قاعدة البيانات"): + st.success("تم حفظ التقرير في قاعدة البيانات بنجاح!") \ No newline at end of file diff --git a/modules/pricing/exceptions.py b/modules/pricing/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..92784731deaa73438f8b193f68f2e9d20ddb0389 --- /dev/null +++ b/modules/pricing/exceptions.py @@ -0,0 +1,42 @@ +""" +استثناءات وحدة التسعير +""" + +class PricingError(Exception): + """استثناء أساسي لأخطاء التسعير""" + pass + + +class LocalContentCalculationError(PricingError): + """استثناء لأخطاء حساب المحتوى المحلي""" + pass + + +class PriceEstimationError(PricingError): + """استثناء لأخطاء تقدير الأسعار""" + pass + + +class ResourceNotFoundError(PricingError): + """استثناء لعدم وجود المورد المطلوب""" + pass + + +class InvalidInputError(PricingError): + """استثناء للمدخلات غير الصالحة""" + pass + + +class ModelLoadingError(PricingError): + """استثناء لأخطاء تحميل النموذج""" + pass + + +class DataProcessingError(PricingError): + """استثناء لأخطاء معالجة البيانات""" + pass + + +class UnbalancedPricingError(PricingError): + """استثناء لأخطاء التسعير غير المتزن""" + pass \ No newline at end of file diff --git a/modules/pricing/price_analysis_component.py b/modules/pricing/price_analysis_component.py new file mode 100644 index 0000000000000000000000000000000000000000..13c76fb38b3d616490b18d561f10c42bb39e2587 --- /dev/null +++ b/modules/pricing/price_analysis_component.py @@ -0,0 +1,932 @@ +import streamlit as st +import pandas as pd +import numpy as np +from datetime import datetime +import time + +class PriceAnalysisComponent: + """مكون تحليل الأسعار للبنود""" + + def __init__(self): + """تهيئة مكون تحليل الأسعار""" + # تهيئة قائمة الوحدات المتاحة + self.unit_options = ["م3", "م2", "طن", "متر طولي", "قطعة", "كجم", "لتر"] + + # تهيئة فئات التكاليف + self.cost_categories = [ + "مواد", + "عمالة", + "معدات", + "مقاولي الباطن", + "مصاريف عامة", + "أرباح" + ] + + # تهيئة قائمة البنود وتحليل أسعارها + if 'items_price_analysis' not in st.session_state: + st.session_state.items_price_analysis = {} + + def render(self): + """عرض واجهة تحليل الأسعار""" + st.markdown("

تحليل أسعار البنود

", unsafe_allow_html=True) + + # التحقق من وجود بنود في التسعير الحالي + if 'current_pricing' not in st.session_state or 'items' not in st.session_state.current_pricing: + st.warning("ليس هناك بنود للتحليل. يرجى إنشاء تسعير أولاً.") + return + + # الحصول على البنود من التسعير الحالي + items = st.session_state.current_pricing['items'].copy() + + # عرض قائمة البنود + st.markdown("### قائمة البنود") + st.dataframe(items[['رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'سعر الوحدة', 'الإجمالي']], + use_container_width=True, hide_index=True) + + # اختيار البند لتحليل السعر + selected_item_id = st.selectbox( + "اختر البند لتحليل السعر", + options=items['رقم البند'].tolist(), + format_func=lambda x: f"{x}: {items[items['رقم البند'] == x]['وصف البند'].values[0][:50]}..." + ) + + if selected_item_id: + # الحصول على البند المحدد + selected_item = items[items['رقم البند'] == selected_item_id].iloc[0] + + # عرض تفاصيل البند المختار + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("رقم البند", selected_item['رقم البند']) + + with col2: + st.metric("الكمية", f"{selected_item['الكمية']} {selected_item['الوحدة']}") + + with col3: + st.metric("سعر الوحدة", f"{selected_item['سعر الوحدة']:,.2f} ريال") + + st.markdown(f"**وصف البند**: {selected_item['وصف البند']}") + + # إنشاء أو تحديث تحليل السعر للبند المحدد + if selected_item_id not in st.session_state.items_price_analysis: + # إنشاء تحليل سعر افتراضي + self._create_default_price_analysis(selected_item_id, selected_item) + + # عرض وتحرير تحليل السعر + self._render_price_analysis_editor(selected_item_id, selected_item) + + def _create_default_price_analysis(self, item_id, item): + """إنشاء تحليل سعر افتراضي للبند""" + # إنشاء قائمة مكونات تحليل السعر + components = pd.DataFrame(columns=[ + 'نوع التكلفة', 'الوصف', 'الكمية', 'الوحدة', 'سعر الوحدة', 'الإجمالي' + ]) + + # إضافة مكونات افتراضية بناءً على نوع البند + is_concrete = 'خرسان' in item['وصف البند'] + is_steel = 'حديد' in item['وصف البند'] or 'تسليح' in item['وصف البند'] + is_bricks = 'بلوك' in item['وصف البند'] or 'طوب' in item['وصف البند'] + is_paint = 'دهان' in item['وصف البند'] or 'طلاء' in item['وصف البند'] + is_insulation = 'عزل' in item['وصف البند'] + + # إضافة المكونات بناءً على نوع البند + if is_concrete: + # مكونات الخرسانة + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['أسمنت', 'رمل', 'حصى', 'عمال وفنيين', 'خلاطات ومعدات صب', 'مصاريف عامة', 'أرباح'], + 'الكمية': [350, 0.4, 0.8, 8, 1, 1, 1], + 'الوحدة': ['كجم', 'م3', 'م3', 'ساعة', 'يوم', 'وحدة', 'وحدة'], + 'سعر الوحدة': [0.5, 100, 120, 50, 500, 100, 150], + 'الإجمالي': [175, 40, 96, 400, 500, 100, 150] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_steel: + # مكونات الحديد + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['حديد التسليح', 'عمال وفنيين', 'معدات ثني وتجهيز الحديد', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1000, 10, 1, 1, 1], + 'الوحدة': ['كجم', 'ساعة', 'يوم', 'وحدة', 'وحدة'], + 'سعر الوحدة': [4.5, 50, 300, 200, 300], + 'الإجمالي': [4500, 500, 300, 200, 300] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_bricks: + # مكونات البلوك + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['بلوك خرساني', 'مونة', 'عمالة بناء', 'مصاريف عامة', 'أرباح'], + 'الكمية': [12.5, 0.02, 1, 1, 1], + 'الوحدة': ['قطعة', 'م3', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [8, 500, 80, 15, 20], + 'الإجمالي': [100, 10, 80, 15, 20] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_paint: + # مكونات الدهانات + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['دهان', 'مواد تجهيز', 'عمالة دهان', 'مصاريف عامة', 'أرباح'], + 'الكمية': [0.4, 0.1, 1, 1, 1], + 'الوحدة': ['لتر', 'وحدة', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [80, 20, 35, 5, 10], + 'الإجمالي': [32, 2, 35, 5, 10] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_insulation: + # مكونات العزل + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['مواد عازلة', 'مواد لاصقة', 'عمالة تركيب', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1.1, 0.2, 1, 1, 1], + 'الوحدة': ['م2', 'كجم', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [60, 30, 25, 10, 15], + 'الإجمالي': [66, 6, 25, 10, 15] + }) + components = pd.concat([components, default_components], ignore_index=True) + + else: + # مكونات عامة افتراضية + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['مواد أساسية', 'عمالة', 'معدات ومعد مساعدة', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1, 1, 1, 1, 1], + 'الوحدة': [item['الوحدة'], 'وحدة', 'وحدة', 'وحدة', 'وحدة'], + 'سعر الوحدة': [ + item['سعر الوحدة'] * 0.6, + item['سعر الوحدة'] * 0.2, + item['سعر الوحدة'] * 0.1, + item['سعر الوحدة'] * 0.05, + item['سعر الوحدة'] * 0.05 + ], + 'الإجمالي': [ + item['سعر الوحدة'] * 0.6, + item['سعر الوحدة'] * 0.2, + item['سعر الوحدة'] * 0.1, + item['سعر الوحدة'] * 0.05, + item['سعر الوحدة'] * 0.05 + ] + }) + components = pd.concat([components, default_components], ignore_index=True) + + # حفظ تحليل السعر للبند + st.session_state.items_price_analysis[item_id] = components + + def _render_price_analysis_editor(self, item_id, item): + """عرض محرر تحليل السعر للبند""" + st.markdown("### تحليل السعر") + + # الحصول على مكونات تحليل السعر + components = st.session_state.items_price_analysis[item_id] + + # عرض تحليل السعر في محرر بيانات + st.markdown("#### مكونات السعر") + + edited_components = st.data_editor( + components, + use_container_width=True, + hide_index=True, + num_rows="dynamic", + column_config={ + 'نوع التكلفة': st.column_config.SelectboxColumn( + 'نوع التكلفة', + help='فئة التكلفة', + options=self.cost_categories + ), + 'الوحدة': st.column_config.SelectboxColumn( + 'الوحدة', + help='وحدة القياس', + options=self.unit_options + ["وحدة", "ساعة", "يوم"] + ), + 'الكمية': st.column_config.NumberColumn( + 'الكمية', + help='الكمية', + min_value=0.0, + format="%.2f" + ), + 'سعر الوحدة': st.column_config.NumberColumn( + 'سعر الوحدة', + help='سعر الوحدة', + min_value=0.0, + format="%.2f" + ), + 'الإجمالي': st.column_config.NumberColumn( + 'الإجمالي', + help='الإجمالي', + min_value=0.0, + format="%.2f" + ) + } + ) + + # إعادة حساب الإجمالي لكل مكون + edited_components['الإجمالي'] = edited_components['الكمية'] * edited_components['سعر الوحدة'] + + # حفظ التعديلات + st.session_state.items_price_analysis[item_id] = edited_components + + # حساب إجمالي تحليل السعر + total_analysis_price = edited_components['الإجمالي'].sum() + unit_price_from_analysis = total_analysis_price / item['الكمية'] if item['الكمية'] > 0 else 0 + + # عرض ملخص تحليل السعر + st.markdown("#### ملخص تحليل السعر") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي تكلفة البند من التحليل", f"{total_analysis_price:,.2f} ريال") + + with col2: + st.metric("سعر الوحدة من التحليل", f"{unit_price_from_analysis:,.2f} ريال") + + with col3: + # المقارنة مع السعر الأصلي + diff = unit_price_from_analysis - item['سعر الوحدة'] + st.metric( + "الفرق عن السعر الأصلي", + f"{diff:,.2f} ريال", + delta=f"{(diff/item['سعر الوحدة']*100) if item['سعر الوحدة'] > 0 else 0:.1f}%" + ) + + # تحليل توزيع التكاليف حسب الفئة + cost_by_category = edited_components.groupby('نوع التكلفة')['الإجمالي'].sum().reset_index() + + # عرض مخطط توزيع التكاليف + st.markdown("#### توزيع التكاليف حسب الفئة") + + # عرض توزيع التكاليف في جدول + distribution_df = pd.DataFrame({ + 'نوع التكلفة': cost_by_category['نوع التكلفة'], + 'القيمة': cost_by_category['الإجمالي'], + 'النسبة المئوية': (cost_by_category['الإجمالي'] / total_analysis_price * 100).round(2) + }) + + st.dataframe( + distribution_df, + use_container_width=True, + hide_index=True, + column_config={ + 'القيمة': st.column_config.NumberColumn( + 'القيمة', + help='القيمة', + format="%.2f" + ), + 'النسبة المئوية': st.column_config.ProgressColumn( + 'النسبة المئوية', + help='النسبة المئوية', + format="%.2f%%", + min_value=0, + max_value=100 + ) + } + ) + + # أزرار الإجراءات + col1, col2, col3 = st.columns(3) + + with col1: + if st.button("تحديث سعر البند", use_container_width=True): + # تحديث سعر البند بناءً على تحليل السعر + items = st.session_state.current_pricing['items'].copy() + item_index = items[items['رقم البند'] == item_id].index[0] + + # تحديث سعر الوحدة والإجمالي + items.at[item_index, 'سعر الوحدة'] = unit_price_from_analysis + items.at[item_index, 'الإجمالي'] = unit_price_from_analysis * items.at[item_index, 'الكمية'] + + # حفظ التعديلات في التسعير الحالي + st.session_state.current_pricing['items'] = items + + st.success(f"تم تحديث سعر البند بناءً على تحليل السعر: {unit_price_from_analysis:,.2f} ريال") + time.sleep(0.5) + st.rerun() + + with col2: + if st.button("تصدير تحليل السعر", use_container_width=True): + st.success("تم إرسال تحليل السعر للتصدير بنجاح!") + + with col3: + if st.button("مسح تحليل السعر", use_container_width=True): + # حذف تحليل السعر للبند + if item_id in st.session_state.items_price_analysis: + del st.session_state.items_price_analysis[item_id] + + st.warning("تم مسح تحليل السعر للبند") + time.sleep(0.5) + st.rerun() + + def add_to_pricing_app(self, pricing_app): + """إضافة مكون تحليل الأسعار إلى تطبيق التسعير""" + # إضافة تبويب جديد + if not hasattr(pricing_app, 'tabs'): + pricing_app.tabs = [] + + if len(pricing_app.tabs) == 4: # إذا كان هناك 4 تبويبات فقط + pricing_app.tabs.append("تحليل أسعار البنود") + + # إضافة دالة العرض + pricing_app._render_price_analysis_tab = self.render + + +def render_integrated_item_input(): + """عرض واجهة إدخال البنود مع تحليل السعر المتكامل""" + + # ضبط CSS لتحسين ظهور الواجهة العربية + st.markdown(""" + + """, unsafe_allow_html=True) + + # تهيئة قائمة الوحدات المتاحة + unit_options = ["م3", "م2", "طن", "متر طولي", "قطعة", "كجم", "لتر"] + + # تهيئة فئات التكاليف + cost_categories = [ + "مواد", + "عمالة", + "معدات", + "مقاولي الباطن", + "مصاريف عامة", + "أرباح" + ] + + # إنشاء جدول البنود اذا لم يكن موجوداً + if 'manual_items' not in st.session_state: + manual_items = pd.DataFrame(columns=[ + 'رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'سعر الوحدة', 'الإجمالي' + ]) + + # إضافة بضعة صفوف افتراضية + default_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0] + }) + + manual_items = pd.concat([manual_items, default_items]) + st.session_state.manual_items = manual_items + + # إنشاء جدول تحليل الأسعار اذا لم يكن موجوداً + if 'items_price_analysis' not in st.session_state: + st.session_state.items_price_analysis = {} + + # عرض واجهة إدخال البنود + st.markdown("### إدخال تفاصيل البنود مع تحليل الأسعار") + + # عرض البنود الحالية كجدول للعرض + st.markdown("### جدول البنود الحالية") + st.dataframe(st.session_state.manual_items, use_container_width=True, hide_index=True) + + # التبويبات لإضافة بند جديد أو تعديل بند + tabs = st.tabs(["إضافة بند جديد", "تعديل بند حالي"]) + + with tabs[0]: # إضافة بند جديد + st.markdown("### إضافة بند جديد مع تحليل السعر") + + col1, col2 = st.columns(2) + + with col1: + new_id = st.text_input("رقم البند", value=f"A{len(st.session_state.manual_items)+1}", key="new_id") + new_desc = st.text_area("وصف البند", value="", key="new_desc") + + with col2: + new_unit = st.selectbox("الوحدة", options=unit_options, key="new_unit") + new_qty = st.number_input("الكمية", value=0.0, min_value=0.0, format="%.2f", key="new_qty") + + # إنشاء تحليل السعر للبند الجديد + st.markdown('
', unsafe_allow_html=True) + st.markdown("#### تحليل سعر البند") + + # التعرف التلقائي على نوع البند من الوصف + is_concrete = False + is_steel = False + is_bricks = False + is_paint = False + is_insulation = False + + if new_desc: + is_concrete = 'خرسان' in new_desc + is_steel = 'حديد' in new_desc or 'تسليح' in new_desc + is_bricks = 'بلوك' in new_desc or 'طوب' in new_desc + is_paint = 'دهان' in new_desc or 'طلاء' in new_desc + is_insulation = 'عزل' in new_desc + + # تلميح للمستخدم عن التعرف التلقائي + if any([is_concrete, is_steel, is_bricks, is_paint, is_insulation]): + detected_type = "" + if is_concrete: + detected_type = "أعمال خرسانة" + elif is_steel: + detected_type = "أعمال حديد" + elif is_bricks: + detected_type = "أعمال بلوك" + elif is_paint: + detected_type = "أعمال دهانات" + elif is_insulation: + detected_type = "أعمال عزل" + + st.info(f"تم التعرف تلقائياً على نوع البند: {detected_type}") + + # إنشاء مصفوفة فارغة لمكونات البند + if 'new_components' not in st.session_state: + # إنشاء DataFrame فارغ + new_components = pd.DataFrame(columns=[ + 'نوع التكلفة', 'الوصف', 'الكمية', 'الوحدة', 'سعر الوحدة', 'الإجمالي' + ]) + + # إضافة مكونات افتراضية بناءً على نوع البند + if is_concrete: + # مكونات الخرسانة + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['أسمنت', 'رمل', 'حصى', 'عمال وفنيين', 'خلاطات ومعدات صب', 'مصاريف عامة', 'أرباح'], + 'الكمية': [350, 0.4, 0.8, 8, 1, 1, 1], + 'الوحدة': ['كجم', 'م3', 'م3', 'ساعة', 'يوم', 'وحدة', 'وحدة'], + 'سعر الوحدة': [0.5, 100, 120, 50, 500, 100, 150], + 'الإجمالي': [175, 40, 96, 400, 500, 100, 150] + }) + new_components = pd.concat([new_components, default_components], ignore_index=True) + + elif is_steel: + # مكونات الحديد + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['حديد التسليح', 'عمال وفنيين', 'معدات ثني وتجهيز الحديد', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1000, 10, 1, 1, 1], + 'الوحدة': ['كجم', 'ساعة', 'يوم', 'وحدة', 'وحدة'], + 'سعر الوحدة': [4.5, 50, 300, 200, 300], + 'الإجمالي': [4500, 500, 300, 200, 300] + }) + new_components = pd.concat([new_components, default_components], ignore_index=True) + + elif is_bricks: + # مكونات البلوك + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['بلوك خرساني', 'مونة', 'عمالة بناء', 'مصاريف عامة', 'أرباح'], + 'الكمية': [12.5, 0.02, 1, 1, 1], + 'الوحدة': ['قطعة', 'م3', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [8, 500, 80, 15, 20], + 'الإجمالي': [100, 10, 80, 15, 20] + }) + new_components = pd.concat([new_components, default_components], ignore_index=True) + + elif is_paint: + # مكونات الدهانات + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['دهان', 'مواد تجهيز', 'عمالة دهان', 'مصاريف عامة', 'أرباح'], + 'الكمية': [0.4, 0.1, 1, 1, 1], + 'الوحدة': ['لتر', 'وحدة', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [80, 20, 35, 5, 10], + 'الإجمالي': [32, 2, 35, 5, 10] + }) + new_components = pd.concat([new_components, default_components], ignore_index=True) + + elif is_insulation: + # مكونات العزل + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['مواد عازلة', 'مواد لاصقة', 'عمالة تركيب', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1.1, 0.2, 1, 1, 1], + 'الوحدة': ['م2', 'كجم', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [60, 30, 25, 10, 15], + 'الإجمالي': [66, 6, 25, 10, 15] + }) + new_components = pd.concat([new_components, default_components], ignore_index=True) + + else: + # مكونات عامة افتراضية + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['مواد أساسية', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1, 1, 1, 1, 1], + 'الوحدة': [new_unit if new_unit else 'وحدة', 'وحدة', 'وحدة', 'وحدة', 'وحدة'], + 'سعر الوحدة': [100, 50, 30, 20, 20], + 'الإجمالي': [100, 50, 30, 20, 20] + }) + new_components = pd.concat([new_components, default_components], ignore_index=True) + + st.session_state.new_components = new_components + + # عرض وتحرير مكونات تحليل السعر + edited_components = st.data_editor( + st.session_state.new_components, + use_container_width=True, + hide_index=True, + num_rows="dynamic", + column_config={ + 'نوع التكلفة': st.column_config.SelectboxColumn( + 'نوع التكلفة', + help='فئة التكلفة', + options=cost_categories + ), + 'الوحدة': st.column_config.SelectboxColumn( + 'الوحدة', + help='وحدة القياس', + options=unit_options + ["وحدة", "ساعة", "يوم"] + ), + 'الكمية': st.column_config.NumberColumn( + 'الكمية', + help='الكمية', + min_value=0.0, + format="%.2f" + ), + 'سعر الوحدة': st.column_config.NumberColumn( + 'سعر الوحدة', + help='سعر الوحدة', + min_value=0.0, + format="%.2f" + ), + 'الإجمالي': st.column_config.NumberColumn( + 'الإجمالي', + help='الإجمالي', + min_value=0.0, + format="%.2f" + ) + } + ) + + # إعادة حساب الإجمالي لكل مكون + edited_components['الإجمالي'] = edited_components['الكمية'] * edited_components['سعر الوحدة'] + + # حفظ التعديلات + st.session_state.new_components = edited_components + + # حساب إجمالي تحليل السعر + total_analysis_price = edited_components['الإجمالي'].sum() + unit_price_from_analysis = total_analysis_price / new_qty if new_qty > 0 else 0 + + # عرض ملخص تحليل السعر + st.markdown("#### ملخص تحليل السعر") + + col1, col2 = st.columns(2) + + with col1: + st.metric("إجمالي تكلفة البند من التحليل", f"{total_analysis_price:,.2f} ريال") + + with col2: + st.metric("سعر الوحدة المحسوب", f"{unit_price_from_analysis:,.2f} ريال") + + st.markdown('
', unsafe_allow_html=True) + + # استخدام السعر المحسوب + use_calculated_price = st.checkbox("استخدام السعر المحسوب من التحليل", value=True) + + # تحديد سعر الوحدة النهائي + if use_calculated_price and new_qty > 0: + new_price = unit_price_from_analysis + else: + new_price = st.number_input("سعر الوحدة", value=unit_price_from_analysis if new_qty > 0 else 0.0, min_value=0.0, format="%.2f", key="new_price") + + # حساب الإجمالي + new_total = new_qty * new_price + st.info(f"إجمالي البند الجديد: {new_total:,.2f} ريال") + + # مقارنة السعر المدخل مع السعر المحسوب + if not use_calculated_price and new_qty > 0 and unit_price_from_analysis > 0: + price_diff = new_price - unit_price_from_analysis + diff_percentage = (price_diff / unit_price_from_analysis) * 100 + + if abs(diff_percentage) > 5: # إذا كان الفرق أكبر من 5% + if diff_percentage > 0: + st.warning(f"السعر المدخل أعلى من السعر المحسوب بنسبة {diff_percentage:.2f}%") + else: + st.warning(f"السعر المدخل أقل من السعر المحسوب بنسبة {abs(diff_percentage):.2f}%") + + # زر إضافة البند + if st.button("إضافة البند"): + # التحقق من صحة البيانات + if new_id and new_desc and new_qty > 0: + # إنشاء صف جديد + new_row = pd.DataFrame({ + 'رقم البند': [new_id], + 'وصف البند': [new_desc], + 'الوحدة': [new_unit], + 'الكمية': [float(new_qty)], + 'سعر الوحدة': [float(new_price)], + 'الإجمالي': [float(new_total)] + }) + + # إضافة الصف إلى DataFrame + st.session_state.manual_items = pd.concat([st.session_state.manual_items, new_row], ignore_index=True) + + # حفظ تحليل سعر البند + st.session_state.items_price_analysis[new_id] = st.session_state.new_components.copy() + + # إعادة تهيئة مكونات البند الجديد + if 'new_components' in st.session_state: + del st.session_state.new_components + + st.success("تم إضافة البند وتحليل السعر بنجاح!") + time.sleep(0.5) + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة: رقم البند، الوصف، والكمية يجب أن تكون أكبر من صفر.") + + with tabs[1]: # تعديل بند حالي + st.markdown("### تعديل بند حالي مع تحليل السعر") + + # اختيار البند للتعديل + edit_item_id = st.selectbox( + "اختر البند للتعديل", + options=st.session_state.manual_items['رقم البند'].tolist(), + format_func=lambda x: f"{x}: {st.session_state.manual_items[st.session_state.manual_items['رقم البند'] == x]['وصف البند'].values[0][:30]}..." + ) + + if edit_item_id: + # الحصول على مؤشر الصف للبند المحدد + idx = st.session_state.manual_items[st.session_state.manual_items['رقم البند'] == edit_item_id].index[0] + row = st.session_state.manual_items.loc[idx] + + # إنشاء نموذج تعديل البند + col1, col2 = st.columns(2) + + with col1: + edited_id = st.text_input("رقم البند (تعديل)", value=row['رقم البند'], key="edit_id") + edited_desc = st.text_area("وصف البند (تعديل)", value=row['وصف البند'], key="edit_desc") + + with col2: + edited_unit = st.selectbox( + "الوحدة (تعديل)", + options=unit_options, + index=unit_options.index(row['الوحدة']) if row['الوحدة'] in unit_options else 0, + key="edit_unit" + ) + edited_qty = st.number_input("الكمية (تعديل)", value=float(row['الكمية']), min_value=0.0, format="%.2f", key="edit_qty") + + # إنشاء أو تحرير تحليل السعر للبند + st.markdown('
', unsafe_allow_html=True) + st.markdown("#### تحليل سعر البند") + + # التحقق مما إذا كان البند له تحليل سعر محفوظ + if edit_item_id in st.session_state.items_price_analysis: + # استخدام تحليل السعر المحفوظ + components = st.session_state.items_price_analysis[edit_item_id] + else: + # إنشاء تحليل سعر افتراضي + components = pd.DataFrame(columns=[ + 'نوع التكلفة', 'الوصف', 'الكمية', 'الوحدة', 'سعر الوحدة', 'الإجمالي' + ]) + + # فحص نوع البند من الوصف + is_concrete = 'خرسان' in row['وصف البند'] + is_steel = 'حديد' in row['وصف البند'] or 'تسليح' in row['وصف البند'] + is_bricks = 'بلوك' in row['وصف البند'] or 'طوب' in row['وصف البند'] + is_paint = 'دهان' in row['وصف البند'] or 'طلاء' in row['وصف البند'] + is_insulation = 'عزل' in row['وصف البند'] + + # إضافة مكونات افتراضية بناءً على نوع البند + if is_concrete: + # مكونات الخرسانة + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['أسمنت', 'رمل', 'حصى', 'عمال وفنيين', 'خلاطات ومعدات صب', 'مصاريف عامة', 'أرباح'], + 'الكمية': [350, 0.4, 0.8, 8, 1, 1, 1], + 'الوحدة': ['كجم', 'م3', 'م3', 'ساعة', 'يوم', 'وحدة', 'وحدة'], + 'سعر الوحدة': [0.5, 100, 120, 50, 500, 100, 150], + 'الإجمالي': [175, 40, 96, 400, 500, 100, 150] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_steel: + # مكونات الحديد + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['حديد التسليح', 'عمال وفنيين', 'معدات ثني وتجهيز الحديد', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1000, 10, 1, 1, 1], + 'الوحدة': ['كجم', 'ساعة', 'يوم', 'وحدة', 'وحدة'], + 'سعر الوحدة': [4.5, 50, 300, 200, 300], + 'الإجمالي': [4500, 500, 300, 200, 300] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_bricks: + # مكونات البلوك + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['بلوك خرساني', 'مونة', 'عمالة بناء', 'مصاريف عامة', 'أرباح'], + 'الكمية': [12.5, 0.02, 1, 1, 1], + 'الوحدة': ['قطعة', 'م3', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [8, 500, 80, 15, 20], + 'الإجمالي': [100, 10, 80, 15, 20] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_paint: + # مكونات الدهانات + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['دهان', 'مواد تجهيز', 'عمالة دهان', 'مصاريف عامة', 'أرباح'], + 'الكمية': [0.4, 0.1, 1, 1, 1], + 'الوحدة': ['لتر', 'وحدة', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [80, 20, 35, 5, 10], + 'الإجمالي': [32, 2, 35, 5, 10] + }) + components = pd.concat([components, default_components], ignore_index=True) + + elif is_insulation: + # مكونات العزل + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'مواد', 'عمالة', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['مواد عازلة', 'مواد لاصقة', 'عمالة تركيب', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1.1, 0.2, 1, 1, 1], + 'الوحدة': ['م2', 'كجم', 'م2', 'وحدة', 'وحدة'], + 'سعر الوحدة': [60, 30, 25, 10, 15], + 'الإجمالي': [66, 6, 25, 10, 15] + }) + components = pd.concat([components, default_components], ignore_index=True) + + else: + # مكونات عامة افتراضية + default_components = pd.DataFrame({ + 'نوع التكلفة': ['مواد', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الوصف': ['مواد أساسية', 'عمالة', 'معدات', 'مصاريف عامة', 'أرباح'], + 'الكمية': [1, 1, 1, 1, 1], + 'الوحدة': [row['الوحدة'], 'وحدة', 'وحدة', 'وحدة', 'وحدة'], + 'سعر الوحدة': [ + row['سعر الوحدة'] * 0.6, + row['سعر الوحدة'] * 0.2, + row['سعر الوحدة'] * 0.1, + row['سعر الوحدة'] * 0.05, + row['سعر الوحدة'] * 0.05 + ], + 'الإجمالي': [ + row['سعر الوحدة'] * 0.6, + row['سعر الوحدة'] * 0.2, + row['سعر الوحدة'] * 0.1, + row['سعر الوحدة'] * 0.05, + row['سعر الوحدة'] * 0.05 + ] + }) + components = pd.concat([components, default_components], ignore_index=True) + + # حفظ تحليل السعر + st.session_state.items_price_analysis[edit_item_id] = components + + # عرض وتحرير مكونات تحليل السعر + edited_components = st.data_editor( + components, + use_container_width=True, + hide_index=True, + num_rows="dynamic", + column_config={ + 'نوع التكلفة': st.column_config.SelectboxColumn( + 'نوع التكلفة', + help='فئة التكلفة', + options=cost_categories + ), + 'الوحدة': st.column_config.SelectboxColumn( + 'الوحدة', + help='وحدة القياس', + options=unit_options + ["وحدة", "ساعة", "يوم"] + ), + 'الكمية': st.column_config.NumberColumn( + 'الكمية', + help='الكمية', + min_value=0.0, + format="%.2f" + ), + 'سعر الوحدة': st.column_config.NumberColumn( + 'سعر الوحدة', + help='سعر الوحدة', + min_value=0.0, + format="%.2f" + ), + 'الإجمالي': st.column_config.NumberColumn( + 'الإجمالي', + help='الإجمالي', + min_value=0.0, + format="%.2f" + ) + } + ) + + # إعادة حساب الإجمالي لكل مكون + edited_components['الإجمالي'] = edited_components['الكمية'] * edited_components['سعر الوحدة'] + + # حفظ التعديلات + st.session_state.items_price_analysis[edit_item_id] = edited_components + + # حساب إجمالي تحليل السعر + total_analysis_price = edited_components['الإجمالي'].sum() + unit_price_from_analysis = total_analysis_price / edited_qty if edited_qty > 0 else 0 + + # عرض ملخص تحليل السعر + st.markdown("#### ملخص تحليل السعر") + + col1, col2 = st.columns(2) + + with col1: + st.metric("إجمالي تكلفة البند من التحليل", f"{total_analysis_price:,.2f} ريال") + + with col2: + st.metric("سعر الوحدة المحسوب", f"{unit_price_from_analysis:,.2f} ريال") + + st.markdown('
', unsafe_allow_html=True) + + # استخدام السعر المحسوب + use_calculated_price = st.checkbox("استخدام السعر المحسوب من التحليل", value=True, key="use_calc_edit") + + # تحديد سعر الوحدة النهائي + if use_calculated_price and edited_qty > 0: + edited_price = unit_price_from_analysis + else: + edited_price = st.number_input( + "سعر الوحدة (تعديل)", + value=unit_price_from_analysis if edited_qty > 0 and unit_price_from_analysis > 0 else float(row['سعر الوحدة']), + min_value=0.0, + format="%.2f", + key="edit_price" + ) + + # حساب الإجمالي + edited_total = edited_qty * edited_price + st.info(f"إجمالي البند بعد التعديل: {edited_total:,.2f} ريال") + + # مقارنة السعر المدخل مع السعر المحسوب + if not use_calculated_price and edited_qty > 0 and unit_price_from_analysis > 0: + price_diff = edited_price - unit_price_from_analysis + diff_percentage = (price_diff / unit_price_from_analysis) * 100 + + if abs(diff_percentage) > 5: # إذا كان الفرق أكبر من 5% + if diff_percentage > 0: + st.warning(f"السعر المدخل أعلى من السعر المحسوب بنسبة {diff_percentage:.2f}%") + else: + st.warning(f"السعر المدخل أقل من السعر المحسوب بنسبة {abs(diff_percentage):.2f}%") + + # أزرار الإجراءات + col1, col2, col3 = st.columns(3) + + with col1: + if st.button("حفظ التعديلات", use_container_width=True): + # التحقق من صحة البيانات + if edited_id and edited_desc and edited_qty > 0: + # التحقق من تغيير رقم البند + if edited_id != edit_item_id: + # نقل تحليل السعر إلى الرقم الجديد + st.session_state.items_price_analysis[edited_id] = st.session_state.items_price_analysis.pop(edit_item_id) + + # تحديث البند + st.session_state.manual_items.at[idx, 'رقم البند'] = edited_id + st.session_state.manual_items.at[idx, 'وصف البند'] = edited_desc + st.session_state.manual_items.at[idx, 'الوحدة'] = edited_unit + st.session_state.manual_items.at[idx, 'الكمية'] = edited_qty + st.session_state.manual_items.at[idx, 'سعر الوحدة'] = edited_price + st.session_state.manual_items.at[idx, 'الإجمالي'] = edited_total + + st.success("تم تحديث البند وتحليل السعر بنجاح!") + time.sleep(0.5) + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة: رقم البند، الوصف، والكمية يجب أن تكون أكبر من صفر.") + + with col2: + if st.button("استعادة القيم الأصلية", use_container_width=True): + # إعادة تحميل الصفحة لاستعادة القيم الأصلية + st.rerun() + + with col3: + if st.button("حذف هذا البند", use_container_width=True): + # حذف تحليل السعر للبند + if edit_item_id in st.session_state.items_price_analysis: + del st.session_state.items_price_analysis[edit_item_id] + + # حذف البند + st.session_state.manual_items = st.session_state.manual_items.drop(idx).reset_index(drop=True) + + st.warning("تم حذف البند وتحليل السعر!") + time.sleep(0.5) + st.rerun() diff --git a/modules/pricing/pricing_app.py b/modules/pricing/pricing_app.py index ffe12ba5e30531ec4c4ebf28baf2089167aafaeb..b2d598c7d72429103bfbd7a5906634951498a2ab 100644 --- a/modules/pricing/pricing_app.py +++ b/modules/pricing/pricing_app.py @@ -1,5 +1,5 @@ """ -وحدة التسعير - التطبيق الرئيسي +تطبيق وحدة التسعير المتكاملة """ import streamlit as st @@ -9,1752 +9,4350 @@ import matplotlib.pyplot as plt import plotly.express as px import plotly.graph_objects as go from datetime import datetime +import random +import os import time import io -import os -import json -import base64 -from pathlib import Path + +# ملاحظة: نحن لا نستخدم st.set_page_config هنا لأنه يجب أن يكون في ملف app.py الرئيسي فقط + +# تحسين المظهر العام باستخدام CSS +st.markdown(""" + +""", unsafe_allow_html=True) + +# تحسين شكل الأزرار بشكل متقدم +st.markdown(""" + +""", unsafe_allow_html=True) + +# وظيفة مساعدة لإنشاء أزرار بتنسيقات مختلفة +def styled_button(label, key, type="primary", on_click=None, args=None, full_width=False, icon=None): + """ + إنشاء زر بتنسيق معين + :param label: نص الزر + :param key: مفتاح الزر الفريد + :param type: نوع التنسيق ('primary', 'secondary', 'success', 'warning', 'danger', 'info', 'glass', 'flat') + :param on_click: الدالة التي سيتم تنفيذها عند النقر + :param args: معاملات الدالة + :param full_width: هل يأخذ الزر العرض كاملاً + :param icon: أيقونة لعرضها قبل النص (emoji) + :return: زر مُنسّق + """ + # استخدام مكونات Streamlit فقط بدون HTML + with st.container(): + # إنشاء مساحة تعرض الزر فقط + col1 = st.columns([1]) + + # إضافة الأيقونة للنص إذا تم تزويدها + display_label = f"{icon} {label}" if icon else label + + # إنشاء الزر مباشرة باستخدام Streamlit + clicked = col1[0].button( + display_label, + key=key, + on_click=on_click, + args=args, + use_container_width=full_width + ) + + return clicked + +# وظيفة لإنشاء أزرار أيقونات صغيرة +def icon_button(icon, key, type="primary", on_click=None, args=None, tooltip=""): + """ + إنشاء زر أيقونة صغير + :param icon: الأيقونة (emoji) + :param key: مفتاح الزر الفريد + :param type: نوع التنسيق + :param on_click: الدالة التي سيتم تنفيذها عند النقر + :param args: معاملات الدالة + :param tooltip: تلميح عند تمرير المؤشر فوق الزر + :return: زر أيقونة + """ + # استخدام مكونات Streamlit فقط + with st.container(): + # إضافة تلميح باستخدام Streamlit + if tooltip: + st.caption(tooltip) + + # إنشاء زر الأيقونة + clicked = st.button( + icon, + key=key, + on_click=on_click, + args=args, + help=tooltip # استخدام خاصية help لعرض التلميح + ) + + return clicked + +from modules.pricing.services.standard_pricing import StandardPricing +from modules.pricing.services.unbalanced_pricing import UnbalancedPricing +from modules.pricing.services.local_content_calculator import LocalContentCalculator +from modules.pricing.services.price_prediction import PricePrediction +from modules.pricing.services.construction_cost_calculator import ConstructionCostCalculator +from modules.pricing.services.construction_templates import ConstructionTemplates +from modules.pricing.services.templates_catalog.templates_catalog import TemplatesCatalog +from utils.excel_handler import export_to_excel +from utils.pdf_handler import export_pricing_to_pdf, export_pricing_with_analysis_to_pdf +from utils.helpers import format_number, format_currency, create_directory_if_not_exists + class PricingApp: - """وحدة التسعير""" + """وحدة التسعير المتكاملة""" def __init__(self): - """تهيئة وحدة التسعير""" - - # تهيئة حالة الجلسة - if 'bill_of_quantities' not in st.session_state: - st.session_state.bill_of_quantities = [ - { - 'id': 1, - 'code': 'A-001', - 'description': 'أعمال الحفر والردم', - 'unit': 'م3', - 'quantity': 1500, - 'unit_price': 45, - 'total_price': 67500, - 'category': 'أعمال ترابية' - }, - { - 'id': 2, - 'code': 'A-002', - 'description': 'توريد وصب خرسانة عادية', - 'unit': 'م3', - 'quantity': 250, - 'unit_price': 350, - 'total_price': 87500, - 'category': 'أعمال خرسانية' - }, - { - 'id': 3, - 'code': 'A-003', - 'description': 'توريد وصب خرسانة مسلحة للأساسات', - 'unit': 'م3', - 'quantity': 180, - 'unit_price': 450, - 'total_price': 81000, - 'category': 'أعمال خرسانية' - }, - { - 'id': 4, - 'code': 'B-001', - 'description': 'توريد وتركيب حديد تسليح', - 'unit': 'طن', - 'quantity': 15, - 'unit_price': 3500, - 'total_price': 52500, - 'category': 'أعمال حديد' - }, - { - 'id': 5, - 'code': 'C-001', - 'description': 'توريد وبناء طابوق', - 'unit': 'م2', - 'quantity': 450, - 'unit_price': 120, - 'total_price': 54000, - 'category': 'أعمال بناء' - } - ] - - if 'cost_analysis' not in st.session_state: - st.session_state.cost_analysis = [ - { - 'id': 1, - 'category': 'تكاليف مباشرة', - 'subcategory': 'مواد', - 'description': 'خرسانة', - 'amount': 168500, - 'percentage': 25.2 - }, - { - 'id': 2, - 'category': 'تكاليف مباشرة', - 'subcategory': 'مواد', - 'description': 'حديد تسليح', - 'amount': 52500, - 'percentage': 7.8 - }, - { - 'id': 3, - 'category': 'تكاليف مباشرة', - 'subcategory': 'مواد', - 'description': 'طابوق', - 'amount': 54000, - 'percentage': 8.1 - }, - { - 'id': 4, - 'category': 'تكاليف مباشرة', - 'subcategory': 'عمالة', - 'description': 'عمالة تنفيذ', - 'amount': 120000, - 'percentage': 17.9 - }, - { - 'id': 5, - 'category': 'تكاليف مباشرة', - 'subcategory': 'معدات', - 'description': 'معدات إنشائية', - 'amount': 85000, - 'percentage': 12.7 - }, - { - 'id': 6, - 'category': 'تكاليف غير مباشرة', - 'subcategory': 'إدارة', - 'description': 'إدارة المشروع', - 'amount': 45000, - 'percentage': 6.7 - }, - { - 'id': 7, - 'category': 'تكاليف غير مباشرة', - 'subcategory': 'إدارة', - 'description': 'إشراف هندسي', - 'amount': 35000, - 'percentage': 5.2 - }, - { - 'id': 8, - 'category': 'تكاليف غير مباشرة', - 'subcategory': 'عامة', - 'description': 'تأمينات وضمانات', - 'amount': 25000, - 'percentage': 3.7 - }, - { - 'id': 9, - 'category': 'تكاليف غير مباشرة', - 'subcategory': 'عامة', - 'description': 'مصاريف إدارية', - 'amount': 30000, - 'percentage': 4.5 - }, - { - 'id': 10, - 'category': 'أرباح', - 'subcategory': 'أرباح', - 'description': 'هامش الربح', - 'amount': 55000, - 'percentage': 8.2 - } - ] + """تهيئة وحدة التسعير المتكاملة""" + self.pricing_methods = [ + "التسعير القياسي", + "التسعير غير المتزن", + "التسعير التنافسي", + "التسعير الموجه بالربحية" + ] - if 'price_scenarios' not in st.session_state: - st.session_state.price_scenarios = [ - { - 'id': 1, - 'name': 'السيناريو الأساسي', - 'description': 'التسعير الأساسي مع هامش ربح 8%', - 'total_cost': 615000, - 'profit_margin': 8.2, - 'total_price': 670000, - 'is_active': True - }, - { - 'id': 2, - 'name': 'سيناريو تنافسي', - 'description': 'تخفيض هامش الربح للمنافسة', - 'total_cost': 615000, - 'profit_margin': 5.0, - 'total_price': 650000, - 'is_active': False - }, - { - 'id': 3, - 'name': 'سيناريو مرتفع', - 'description': 'زيادة هامش الربح للمشاريع ذات المخاطر العالية', - 'total_cost': 615000, - 'profit_margin': 12.0, - 'total_price': 700000, - 'is_active': False - } - ] + # تهيئة خدمات التسعير + self.standard_pricing = StandardPricing() + self.unbalanced_pricing = UnbalancedPricing() + self.local_content = LocalContentCalculator() + self.price_prediction = PricePrediction() + self.construction_calculator = ConstructionCostCalculator() + self.construction_templates = ConstructionTemplates() + self.templates_catalog = TemplatesCatalog(self.construction_templates) def render(self): """عرض واجهة وحدة التسعير""" - st.markdown("

وحدة التسعير

", unsafe_allow_html=True) + # استخدام مكونات Streamlit مباشرة بدلاً من HTML + st.title("وحدة التسعير المتكاملة") tabs = st.tabs([ - "لوحة التحكم", - "جدول الكميات", - "تحليل التكاليف", - "سيناريوهات التسعير", - "المقارنة التنافسية", - "التقارير" - ]) + "إنشاء تسعير جديد", + "تحليل سعر البند", + "نموذج التسعير الشامل", + "التسعير غير المتزن", + "المحتوى المحلي", + "حاسبة تكاليف البناء", + "كتالوج البنود النموذجية", + "الأدوات المساعدة" + ]) with tabs[0]: - self._render_dashboard_tab() + self._render_new_pricing_tab() with tabs[1]: - self._render_bill_of_quantities_tab() + self._render_item_analysis_tab() with tabs[2]: - self._render_cost_analysis_tab() + self._render_comprehensive_pricing_tab() with tabs[3]: - self._render_pricing_scenarios_tab() + self._render_unbalanced_pricing_tab() with tabs[4]: - self._render_competitive_analysis_tab() - + self._render_local_content_tab() + with tabs[5]: - self._render_reports_tab() + self._render_construction_calculator_tab() + + with tabs[6]: + self._render_templates_catalog_tab() + + with tabs[7]: + self._render_utilities_tab() + + def _render_templates_catalog_tab(self): + """عرض تبويب كتالوج البنود النموذجية""" + + st.markdown("### كتالوج البنود النموذجية") + + # شرح كتالوج البنود النموذجية + with st.expander("دليل استخدام كتالوج البنود النموذجية", expanded=False): + st.markdown(""" + **كتالوج البنود النموذجية** هو مكتبة شاملة من البنود الجاهزة لمختلف أنواع الأعمال الإنشائية (خرسانة، حديد، عزل، تشطيبات، إلخ). + + ### مميزات الكتالوج: + - تفاصيل دقيقة للمواد والعمالة والمعدات المطلوبة لكل بند. + - تحليل تكلفة تفصيلي يمكن استخدامه مباشرة في عروض الأسعار. + - ربط مباشر مع حاسبة تكاليف البناء وحاسبة الأسعار. + + ### كيفية الاستخدام: + - استخدام البنود النموذجية مباشرة في مشاريعك. + - تعديل البنود النموذجية لتناسب متطلبات المشروع. + - إضافة بنود جديدة إلى الكتالوج للاستخدام المستقبلي. + """) + + # عرض الكتالوج باستخدام مكون TemplatesCatalog + self.templates_catalog.render() - def _render_dashboard_tab(self): - """عرض تبويب لوحة التحكم""" + def _render_item_analysis_tab(self): + """عرض تبويب تحليل سعر البند""" - st.markdown("### لوحة تحكم التسعير") + st.markdown("### تحليل سعر البند") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # اختيار البند للتحليل + if 'current_pricing' in st.session_state and st.session_state.current_pricing is not None: + items = st.session_state.current_pricing['items'] + item_options = items['رقم البند'].tolist() + selected_item = st.selectbox("اختر البند للتحليل", item_options, key="item_analysis_selector") + + if selected_item: + item_data = items[items['رقم البند'] == selected_item].iloc[0] + + st.markdown(f"### تحليل البند: {selected_item}") + st.markdown(f"**وصف البند**: {item_data['وصف البند']}") + st.markdown(f"**الوحدة**: {item_data['الوحدة']}") + st.markdown(f"**الكمية**: {item_data['الكمية']}") + st.markdown(f"**سعر الوحدة**: {item_data['سعر الوحدة']:,.2f} ريال") + + # تحليل مكونات السعر + st.markdown("### تحليل مكونات السعر") + + # عناصر التكلفة الافتراضية + cost_components = { + 'المواد': 0.6, # 60% من التكلفة + 'العمالة': 0.25, # 25% من التكلفة + 'المعدات': 0.1, # 10% من التكلفة + 'نفقات عامة': 0.05 # 5% من التكلفة + } + + # حساب تكلفة كل عنصر + unit_price = item_data['سعر الوحدة'] + component_values = {k: v * unit_price for k, v in cost_components.items()} + + # عرض مكونات التكلفة في جدول + components_df = pd.DataFrame({ + 'العنصر': component_values.keys(), + 'نسبة من التكلفة': [f"{v*100:.1f}%" for v in cost_components.values()], + 'القيمة (ريال)': [f"{v:,.2f}" for v in component_values.values()] + }) + + st.table(components_df) + + # رسم بياني لمكونات التكلفة + fig = px.pie( + names=list(component_values.keys()), + values=list(component_values.values()), + title='توزيع مكونات التكلفة' + ) + + st.plotly_chart(fig) + + # تحليل تاريخي للأسعار + st.markdown("### تحليل تاريخي للأسعار") + + # بيانات تاريخية افتراضية + historical_data = { + 'التاريخ': ['2020-01', '2020-07', '2021-01', '2021-07', '2022-01', '2022-07', '2023-01', '2023-07'], + 'السعر': [ + unit_price * 0.7, + unit_price * 0.75, + unit_price * 0.8, + unit_price * 0.85, + unit_price * 0.9, + unit_price * 0.95, + unit_price, + unit_price * 1.05 + ] + } + + hist_df = pd.DataFrame(historical_data) + + # رسم بياني للتحليل التاريخي + fig = px.line( + hist_df, + x='التاريخ', + y='السعر', + title='تطور سعر الوحدة عبر الزمن', + markers=True + ) + + st.plotly_chart(fig) + + # المقارنة مع الأسعار المرجعية + st.markdown("### المقارنة مع الأسعار المرجعية") + + # بيانات مرجعية افتراضية + reference_data = { + 'المصدر': ['قاعدة البيانات الداخلية', 'دليل الأسعار الاسترشادي', 'متوسط أسعار السوق', 'أسعار المشاريع المماثلة'], + 'السعر المرجعي': [ + unit_price * 0.95, + unit_price * 1.05, + unit_price * 1.1, + unit_price * 0.9 + ] + } + + ref_df = pd.DataFrame(reference_data) + ref_df['الفرق عن السعر الحالي'] = ref_df['السعر المرجعي'] - unit_price + ref_df['نسبة الفرق'] = (ref_df['الفرق عن السعر الحالي'] / unit_price * 100).round(2).astype(str) + '%' + + st.table(ref_df) + + def _render_new_pricing_tab(self): + """عرض تبويب إنشاء تسعير جديد""" - # عرض ملخص التسعير - col1, col2, col3, col4 = st.columns(4) + st.markdown("### إنشاء تسعير جديد") - # حساب إجمالي التكاليف - total_direct_cost = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'تكاليف مباشرة') - total_indirect_cost = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'تكاليف غير مباشرة') - total_profit = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'أرباح') - total_cost = total_direct_cost + total_indirect_cost - total_price = total_cost + total_profit + col1, col2 = st.columns(2) with col1: - st.metric("إجمالي التكاليف المباشرة", f"{total_direct_cost:,.0f} ريال") + tender_name = st.text_input("اسم المناقصة", key="tender_name_input") + client = st.text_input("الجهة المالكة", key="client_input") + pricing_method = st.selectbox("طريقة التسعير", self.pricing_methods, key="pricing_method_selector") with col2: - st.metric("إجمالي التكاليف غير المباشرة", f"{total_indirect_cost:,.0f} ريال") + tender_number = st.text_input("رقم المناقصة", key="tender_number_input") + location = st.text_input("الموقع", key="location_input") + submission_date = st.date_input("تاريخ التقديم", key="submission_date_input") - with col3: - st.metric("إجمالي التكاليف", f"{total_cost:,.0f} ريال") - - with col4: - st.metric("السعر الإجمالي", f"{total_price:,.0f} ريال") + # خيارات بيانات البنود + st.markdown("### بيانات البنود") - # عرض توزيع التكاليف - st.markdown("### توزيع التكاليف") + data_source = st.radio( + "مصدر بيانات البنود", + ["إدخال يدوي", "استيراد من Excel", "استيراد من وحدة تحليل المستندات", "استيراد من وحدة المشاريع"], + key="data_source_radio" + ) - # تجميع البيانات حسب الفئة - cost_categories = {} + if data_source == "إدخال يدوي": + # ضبط CSS لتحسين ظهور الواجهة العربية + st.markdown(""" + + """, unsafe_allow_html=True) + + # تهيئة قائمة الوحدات المتاحة + unit_options = ["م3", "م2", "طن", "متر طولي", "قطعة", "كجم", "لتر"] + + # إنشاء بيانات افتراضية إذا لم تكن موجودة + if 'manual_items' not in st.session_state: + # إنشاء DataFrame فارغ + manual_items = pd.DataFrame(columns=[ + 'رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'سعر الوحدة', 'الإجمالي' + ]) + + # إضافة بضعة صفوف افتراضية + default_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0] + }) + + manual_items = pd.concat([manual_items, default_items]) + st.session_state.manual_items = manual_items + + # عرض واجهة إدخال البنود + st.markdown("### إدخال تفاصيل البنود") + + # التحقق من استخدام طريقة الإدخال البسيطة + use_simple_input = st.checkbox("استخدام طريقة الإدخال البسيطة", value=True, key="use_simple_input_checkbox") + + if use_simple_input: + # عرض البنود الحالية كجدول للعرض فقط + st.markdown("### جدول البنود الحالية") + st.dataframe(st.session_state.manual_items, use_container_width=True, hide_index=True) + + # إضافة بند جديد + st.markdown("### إضافة بند جديد") + col1, col2 = st.columns(2) + + with col1: + new_id = st.text_input("رقم البند", value=f"A{len(st.session_state.manual_items)+1}", key="new_item_id") + new_desc = st.text_area("وصف البند", value="", key="new_item_description") + + with col2: + new_unit = st.selectbox("الوحدة", options=unit_options, key="new_item_unit") + new_qty = st.number_input("الكمية", value=0.0, min_value=0.0, format="%.2f", key="new_item_qty") + new_price = st.number_input("سعر الوحدة", value=0.0, min_value=0.0, format="%.2f", key="new_item_price") + + new_total = new_qty * new_price + st.info(f"إجمالي البند الجديد: {new_total:,.2f} ريال") + + if st.button("إضافة البند", key="add_item_button"): + # التحقق من صحة البيانات + if new_id and new_desc and new_qty > 0: + # إنشاء صف جديد + new_row = pd.DataFrame({ + 'رقم البند': [new_id], + 'وصف البند': [new_desc], + 'الوحدة': [new_unit], + 'الكمية': [float(new_qty)], + 'سعر الوحدة': [float(new_price)], + 'الإجمالي': [float(new_total)] + }) + + # إضافة الصف إلى DataFrame + st.session_state.manual_items = pd.concat([st.session_state.manual_items, new_row], ignore_index=True) + st.success("تم إضافة البند بنجاح!") + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة: رقم البند، الوصف، والكمية يجب أن تكون أكبر من صفر.") + + # تعديل البنود الحالية + st.markdown("### تعديل البنود الحالية") + + # تحديد البند المراد تعديله + item_to_edit = st.selectbox( + "اختر البند للتعديل", + options=st.session_state.manual_items['رقم البند'].tolist(), + format_func=lambda x: f"{x}: {st.session_state.manual_items[st.session_state.manual_items['رقم البند'] == x]['وصف البند'].values[0][:30]}...", + key="item_to_edit_selector" + ) + + if item_to_edit: + # الحصول على مؤشر الصف للبند المحدد + idx = st.session_state.manual_items[st.session_state.manual_items['رقم البند'] == item_to_edit].index[0] + row = st.session_state.manual_items.loc[idx] + + # إنشاء نموذج تعديل + col1, col2 = st.columns(2) + + with col1: + edited_id = st.text_input("رقم البند (تعديل)", value=row['رقم البند'], key="edit_id") + edited_desc = st.text_area("وصف البند (تعديل)", value=row['وصف البند'], key="edit_desc") + + with col2: + edited_unit = st.selectbox( + "الوحدة (تعديل)", + options=unit_options, + index=unit_options.index(row['الوحدة']) if row['الوحدة'] in unit_options else 0, + key="edit_unit" + ) + edited_qty = st.number_input("الكمية (تعديل)", value=float(row['الكمية']), min_value=0.0, format="%.2f", key="edit_qty") + edited_price = st.number_input("سعر الوحدة (تعديل)", value=float(row['سعر الوحدة']), min_value=0.0, format="%.2f", key="edit_price") + + edited_total = edited_qty * edited_price + st.info(f"إجمالي البند بعد التعديل: {edited_total:,.2f} ريال") + + col1, col2 = st.columns(2) + with col1: + if st.button("حفظ التعديلات", key="save_edit_button"): + # تحديث البند + st.session_state.manual_items.at[idx, 'رقم البند'] = edited_id + st.session_state.manual_items.at[idx, 'وصف البند'] = edited_desc + st.session_state.manual_items.at[idx, 'الوحدة'] = edited_unit + st.session_state.manual_items.at[idx, 'الكمية'] = edited_qty + st.session_state.manual_items.at[idx, 'سعر الوحدة'] = edited_price + st.session_state.manual_items.at[idx, 'الإجمالي'] = edited_total + + st.success("تم تحديث البند بنجاح!") + st.rerun() + + with col2: + if st.button("حذف هذا البند", key="delete_item_button"): + st.session_state.manual_items = st.session_state.manual_items.drop(idx).reset_index(drop=True) + st.warning("تم حذف البند!") + st.rerun() + + # المجموع الكلي + total = st.session_state.manual_items['الإجمالي'].sum() + st.metric("المجموع الكلي", f"{total:,.2f} ريال") + + # جعل هذه البيانات متاحة للاستخدام في الخطوات التالية + edited_items = st.session_state.manual_items.copy() + + else: + # عرض رسالة توضح أن طريقة الإدخال البسيطة هي الأفضل + st.warning("لتجنب مشاكل عدم التوافق في أنواع البيانات، يُفضل استخدام طريقة الإدخال البسيطة.") + + # محاولة استخدام المحرر القياسي مع معالجة الأخطاء + try: + # تحويل البيانات إلى الأنواع المناسبة + for col in st.session_state.manual_items.columns: + if col in ['رقم البند', 'وصف البند', 'الوحدة']: + st.session_state.manual_items[col] = st.session_state.manual_items[col].astype(str) + + # عرض المحرر (للقراءة فقط) + st.dataframe(st.session_state.manual_items, use_container_width=True, hide_index=True) + + # إنشاء نظام تعديل منفصل + st.markdown("### تعديل أسعار الوحدات") + + for idx, row in st.session_state.manual_items.iterrows(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.text(f"{row['رقم البند']}: {row['وصف البند'][:50]}") + + with col2: + price = st.number_input( + f"سعر الوحدة ({row['الوحدة']})", + value=float(row['سعر الوحدة']), + min_value=0.0, + key=f"price_{idx}" + ) + + # تحديث السعر والإجمالي + st.session_state.manual_items.at[idx, 'سعر الوحدة'] = price + st.session_state.manual_items.at[idx, 'الإجمالي'] = price * row['الكمية'] + + # المجموع الكلي + total = st.session_state.manual_items['الإجمالي'].sum() + st.metric("المجموع الكلي", f"{total:,.2f} ريال") + + # جعل هذه البيانات متاحة للاستخدام في الخطوات التالية + edited_items = st.session_state.manual_items.copy() + + except Exception as e: + st.error(f"حدث خطأ: {str(e)}") + st.info("يرجى استخدام طريقة الإدخال البسيطة لتجنب هذه المشكلة.") - for item in st.session_state.cost_analysis: - category = item['category'] - if category in cost_categories: - cost_categories[category] += item['amount'] + elif data_source == "استيراد من Excel": + uploaded_file = st.file_uploader("رفع ملف Excel", type=["xlsx", "xls"]) + + if uploaded_file is not None: + st.success("تم رفع الملف بنجاح") + # محاكاة قراءة الملف + st.markdown("### معاينة البيانات المستوردة") + + # إنشاء بيانات افتراضية + import_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5", "A6", "A7"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة", + "توريد وتركيب حديد التسليح للأعمدة", + "أعمال البلوك للجدران" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3", "طن", "م2"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0, 10.0, 400.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }) + + st.dataframe(import_items) + + if st.button("استيراد البيانات", key="import_excel_button"): + st.session_state.manual_items = import_items.copy() + st.session_state.manual_items_modified = True + st.success("تم استيراد البيانات بنجاح!") + st.rerun() + + elif data_source == "استيراد من وحدة تحليل المستندات": + available_documents = [ + "كراسة شروط مشروع توسعة مستشفى الملك فهد", + "جدول كميات صيانة محطات المياه", + "مخططات إنشاء مدرسة ثانوية" + ] + + selected_doc = st.selectbox("اختر المستند", available_documents, key="document_selector") + + if styled_button("استيراد البيانات من تحليل المستند", key="import_doc_analysis_button", type="info", icon="📄", full_width=True): + # محاكاة استيراد البيانات + with st.spinner("جاري استيراد البيانات..."): + time.sleep(2) + + # إنشاء بيانات افتراضية + doc_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5", "A6", "A7"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة", + "توريد وتركيب حديد التسليح للأعمدة", + "أعمال البلوك للجدران" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3", "طن", "م2"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0, 10.0, 400.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }) + + st.session_state.manual_items = doc_items.copy() + st.success("تم استيراد البيانات من تحليل المستند بنجاح!") + st.dataframe(doc_items) + + elif data_source == "استيراد من وحدة المشاريع": + # قائمة المشاريع المتاحة للاستيراد منها + available_projects = [ + "مشروع تطوير طريق الملك عبدالعزيز", + "مشروع إنشاء محطة تحلية المياه بالجبيل", + "مشروع توسعة مستشفى الملك فهد", + "مشروع إنشاء مجمع سكني بالرياض" + ] + + selected_project = st.selectbox("اختر المشروع", available_projects, key="project_selector") + + if styled_button("استيراد البيانات من المشروع", key="import_project_data_button", type="primary", icon="🏗️", full_width=True): + # محاكاة استيراد البيانات من المشروع + with st.spinner("جاري استيراد بيانات المشروع..."): + time.sleep(1.5) + + # إنشاء بيانات مشروع افتراضية بناءً على المشروع المختار + if selected_project == "مشروع تطوير طريق الملك عبدالعزيز": + project_items = pd.DataFrame({ + 'رقم البند': ["R1", "R2", "R3", "R4", "R5"], + 'وصف البند': [ + "أعمال الحفر والردم للطريق", + "توريد وتنفيذ طبقة الأساس", + "طبقة الأسفلت الأولى", + "طبقة الأسفلت النهائية", + "أعمال الدهانات والعلامات" + ], + 'الوحدة': ["م3", "م2", "م2", "م2", "م.ط"], + 'الكمية': [5000.0, 8000.0, 8000.0, 8000.0, 4000.0], + 'سعر الوحدة': [45.0, 120.0, 85.0, 95.0, 25.0], + 'الإجمالي': [225000.0, 960000.0, 680000.0, 760000.0, 100000.0] + }) + elif selected_project == "مشروع إنشاء محطة تحلية المياه بالجبيل": + project_items = pd.DataFrame({ + 'رقم البند': ["W1", "W2", "W3", "W4", "W5"], + 'وصف البند': [ + "أعمال الخرسانة المسلحة للخزانات", + "توريد وتركيب معدات التحلية", + "أعمال التمديدات والأنابيب", + "تشطيبات المباني الإدارية", + "أعمال الكهرباء والتحكم" + ], + 'الوحدة': ["م3", "قطعة", "م.ط", "م2", "مقطوعية"], + 'الكمية': [1800.0, 12.0, 5000.0, 1200.0, 1.0], + 'سعر الوحدة': [1200.0, 250000.0, 850.0, 750.0, 1500000.0], + 'الإجمالي': [2160000.0, 3000000.0, 4250000.0, 900000.0, 1500000.0] + }) + elif selected_project == "مشروع توسعة مستشفى الملك فهد": + project_items = pd.DataFrame({ + 'رقم البند': ["H1", "H2", "H3", "H4", "H5", "H6"], + 'وصف البند': [ + "أعمال الهيكل الخرساني", + "أعمال البناء والجدران", + "التشطيبات الداخلية", + "الأنظمة الكهربائية والميكانيكية", + "تجهيزات طبية", + "أعمال الموقع العام" + ], + 'الوحدة': ["م3", "م2", "م2", "غرفة", "قطعة", "م2"], + 'الكمية': [2800.0, 4500.0, 7500.0, 120.0, 45.0, 3000.0], + 'سعر الوحدة': [1500.0, 650.0, 1200.0, 35000.0, 80000.0, 350.0], + 'الإجمالي': [4200000.0, 2925000.0, 9000000.0, 4200000.0, 3600000.0, 1050000.0] + }) + else: # مشروع إنشاء مجمع سكني بالرياض + project_items = pd.DataFrame({ + 'رقم البند': ["B1", "B2", "B3", "B4", "B5", "B6", "B7"], + 'وصف البند': [ + "الهياكل الخرسانية للفلل", + "الجدران والقواطع الداخلية", + "التشطيبات الخارجية", + "التشطيبات الداخلية", + "أعمال الكهرباء والسباكة", + "الملحقات والحدائق", + "البنية التحتية للموقع" + ], + 'الوحدة': ["م3", "م2", "م2", "م2", "فيلا", "م2", "مقطوعية"], + 'الكمية': [3500.0, 12000.0, 8000.0, 18000.0, 25.0, 5000.0, 1.0], + 'سعر الوحدة': [1350.0, 450.0, 380.0, 750.0, 85000.0, 280.0, 2500000.0], + 'الإجمالي': [4725000.0, 5400000.0, 3040000.0, 13500000.0, 2125000.0, 1400000.0, 2500000.0] + }) + + st.session_state.manual_items = project_items.copy() + st.success(f"تم استيراد بيانات المشروع '{selected_project}' بنجاح!") + st.dataframe(project_items) + + # زر بدء التسعير + if styled_button("بدء التسعير", key="start_pricing_button", type="success", icon="✅", full_width=True): + # تحقق من صحة البيانات + if 'manual_items' in st.session_state and not st.session_state.manual_items.empty: + # التأكد من حساب الإجمالي قبل الحفظ + st.session_state.manual_items['الإجمالي'] = st.session_state.manual_items['الكمية'] * st.session_state.manual_items['سعر الوحدة'] + + # حفظ بيانات التسعير الحالي + st.session_state.current_pricing = { + 'name': tender_name, + 'number': tender_number, + 'client': client, + 'location': location, + 'method': pricing_method, + 'submission_date': submission_date, + 'items': st.session_state.manual_items.copy(), + 'status': 'جديد', + 'created_at': datetime.now() + } + + # الانتقال إلى تبويب نموذج التسعير الشامل + st.success("تم إنشاء التسعير بنجاح! يمكنك الانتقال إلى نموذج التسعير الشامل.") else: - cost_categories[category] = item['amount'] + st.error("يرجى إدخال بيانات البنود أولاً.") + + def _render_comprehensive_pricing_tab(self): + """عرض تبويب نموذج التسعير الشامل""" - # إنشاء DataFrame للرسم البياني - cost_df = pd.DataFrame({ - 'الفئة': list(cost_categories.keys()), - 'المبلغ': list(cost_categories.values()) - }) + st.markdown("### نموذج التسعير الشامل") - # إنشاء رسم بياني دائري - fig = px.pie( - cost_df, - values='المبلغ', - names='الفئة', - title='توزيع التكاليف حسب الفئة', - color_discrete_sequence=px.colors.qualitative.Set3 - ) + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return - st.plotly_chart(fig, use_container_width=True) + # عرض معلومات التسعير الحالي + pricing = st.session_state.current_pricing - # عرض توزيع التكاليف المباشرة - st.markdown("### توزيع التكاليف المباشرة") + col1, col2, col3 = st.columns(3) - # تجميع البيانات حسب الفئة الفرعية للتكاليف المباشرة - direct_cost_subcategories = {} + with col1: + st.metric("اسم المناقصة", pricing['name']) + st.metric("الجهة المالكة", pricing['client']) - for item in st.session_state.cost_analysis: - if item['category'] == 'تكاليف مباشرة': - subcategory = item['subcategory'] - if subcategory in direct_cost_subcategories: - direct_cost_subcategories[subcategory] += item['amount'] - else: - direct_cost_subcategories[subcategory] = item['amount'] + with col2: + st.metric("رقم المناقصة", pricing['number']) + st.metric("تاريخ التقديم", pricing['submission_date'].strftime("%Y-%m-%d")) - # إنشاء DataFrame للرسم البياني - direct_cost_df = pd.DataFrame({ - 'الفئة الفرعية': list(direct_cost_subcategories.keys()), - 'المبلغ': list(direct_cost_subcategories.values()) - }) + with col3: + st.metric("طريقة التسعير", pricing['method']) + st.metric("الموقع", pricing['location']) + + # عرض البنود والتسعير + st.markdown("### بنود التسعير") + + items = pricing['items'].copy() + + # إضافة أسعار الوحدة للمحاكاة + if 'سعر الوحدة' in items.columns and (items['سعر الوحدة'] == 0).all(): + items['سعر الوحدة'] = [ + round(random.uniform(1000, 3000), 2), # الخرسانة + round(random.uniform(5000, 7000), 2), # الحديد + round(random.uniform(100, 200), 2), # العزل + round(random.uniform(50, 100), 2), # الردم + round(random.uniform(1200, 3500), 2), # الخرسانة للأعمدة + ] + + if len(items) > 5: + for i in range(5, len(items)): + items.at[i, 'سعر الوحدة'] = round(random.uniform(500, 5000), 2) - # إنشاء رسم بياني شريطي - fig = px.bar( - direct_cost_df, - x='الفئة الفرعية', - y='المبلغ', - title='توزيع التكاليف المباشرة', - color='الفئة الفرعية', - text_auto='.2s' - ) + # حساب الإجمالي + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] - st.plotly_chart(fig, use_container_width=True) + # عرض البنود + st.dataframe(items, use_container_width=True, hide_index=True) - # عرض مقارنة سيناريوهات التسعير - st.markdown("### مقارنة سيناريوهات التسعير") - # إنشاء DataFrame للرسم البياني - scenarios_df = pd.DataFrame({ - 'السيناريو': [item['name'] for item in st.session_state.price_scenarios], - 'التكلفة الإجمالية': [item['total_cost'] for item in st.session_state.price_scenarios], - 'هامش الربح (%)': [item['profit_margin'] for item in st.session_state.price_scenarios], - 'السعر الإجمالي': [item['total_price'] for item in st.session_state.price_scenarios] - }) + # ✅ التوصية الذكية باستخدام OpenAI + with st.expander("🔍 توليد توصية ذكية باستخدام AI"): + if styled_button("توليد توصية ذكية باستخدام AI", key="gen_ai_recommendation_btn", type="secondary", icon="🤖", full_width=True): + import openai + import os + + # تهيئة عميل OpenAI - استخدام واجهة الإصدار الجديد فقط (1.0.0 وما فوق) + api_key = os.environ.get("ai") + client = openai.OpenAI(api_key=api_key) + + items_df = items.copy() + prompt = f"""قم بتحليل الجدول التالي للبنود في مشروع إنشاء، وقدم توصية ذكية لتحسين التسعير وضمان التوازن المالي. الجدول يحتوي على البنود، الكميات، الأسعار، والإجماليات:\n\n{items_df.to_string(index=False)}\n\nالتوصية:\n""" + + try: + with st.spinner("جاري توليد التوصية..."): + # استخدام واجهة OpenAI الجديدة + response = client.chat.completions.create( + model="gpt-4o", # استخدام أحدث نموذج من OpenAI GPT-4o + messages=[ + {"role": "system", "content": "أنت خبير في تسعير مشاريع البناء والبنية التحتية."}, + {"role": "user", "content": prompt} + ], + temperature=0.4, + max_tokens=500 + ) + + # استخراج محتوى الرسالة من واجهة OpenAI الجديدة + recommendation = response.choices[0].message.content + + st.success("تم توليد التوصية بنجاح!") + st.markdown("#### التوصية الذكية:") + st.info(recommendation) + + except Exception as e: + st.error(f"حدث خطأ أثناء الاتصال بنموذج OpenAI: {e}") + st.info("يجب التأكد من تثبيت أحدث إصدار من مكتبة OpenAI: `pip install openai --upgrade`") + + # واجهة تعديل أسعار الوحدات + st.markdown("### تعديل أسعار الوحدات") + + # تقسيم البنود إلى مجموعتين للعرض + col1, col2 = st.columns(2) + half = len(items) // 2 + len(items) % 2 - # إنشاء رسم بياني شريطي مزدوج - fig = go.Figure() - - # إضافة شريط للتكلفة الإجمالية - fig.add_trace(go.Bar( - x=scenarios_df['السيناريو'], - y=scenarios_df['التكلفة الإجمالية'], - name='التكلفة الإجمالية', - marker_color='indianred' - )) - - # إضافة شريط للسعر الإجمالي - fig.add_trace(go.Bar( - x=scenarios_df['السيناريو'], - y=scenarios_df['السعر الإجمالي'], - name='السعر الإجمالي', - marker_color='lightsalmon' - )) - - # إضافة خط لهامش الربح - fig.add_trace(go.Scatter( - x=scenarios_df['السيناريو'], - y=scenarios_df['هامش الربح (%)'] * 10000, # تكبير القيم لتظهر على الرسم البياني - name='هامش الربح (%)', - yaxis='y2', - line=dict(color='royalblue', width=4) - )) - - # تعديل تخطيط الرسم البياني - fig.update_layout( - title='مقارنة سيناريوهات التسعير', - xaxis_title='السيناريو', - yaxis_title='المبلغ (ريال)', - yaxis2=dict( - title='هامش الربح (%)', - titlefont=dict(color='royalblue'), - tickfont=dict(color='royalblue'), - overlaying='y', - side='right', - range=[0, 20] - ), - barmode='group', - legend=dict( - x=0, - y=1.2, - orientation='h' - ) - ) + with col1: + for idx in range(half): + if idx < len(items): + row = items.iloc[idx] + price = st.number_input( + f"{row['رقم البند']}: {row['وصف البند'][:30]}... ({row['الوحدة']})", + value=float(row['سعر الوحدة']), + min_value=0.0, + key=f"price1_{idx}" + ) + items.at[idx, 'سعر الوحدة'] = price + items.at[idx, 'الإجمالي'] = price * items.at[idx, 'الكمية'] - # تعديل النص على الأشرطة - fig.update_traces( - texttemplate='%{y:,.0f}', - textposition='outside' - ) + with col2: + for idx in range(half, len(items)): + row = items.iloc[idx] + price = st.number_input( + f"{row['رقم البند']}: {row['وصف البند'][:30]}... ({row['الوحدة']})", + value=float(row['سعر الوحدة']), + min_value=0.0, + key=f"price2_{idx}" + ) + items.at[idx, 'سعر الوحدة'] = price + items.at[idx, 'الإجمالي'] = price * items.at[idx, 'الكمية'] - st.plotly_chart(fig, use_container_width=True) + # حساب وعرض إجماليات التسعير + total_price = items['الإجمالي'].sum() - # عرض مؤشرات الأداء الرئيسية - st.markdown("### مؤشرات الأداء الرئيسية") + st.markdown("### إجماليات التسعير") col1, col2, col3 = st.columns(3) with col1: - # حساب نسبة التكاليف المباشرة من إجمالي التكاليف - direct_cost_percentage = (total_direct_cost / total_cost) * 100 - st.metric("نسبة التكاليف المباشرة", f"{direct_cost_percentage:.1f}%") + st.metric("إجمالي التكاليف المباشرة", f"{total_price:,.2f} ريال") with col2: - # حساب نسبة التكاليف غير المباشرة من إجمالي التكاليف - indirect_cost_percentage = (total_indirect_cost / total_cost) * 100 - st.metric("نسبة التكاليف غير المباشرة", f"{indirect_cost_percentage:.1f}%") + overhead_percentage = st.slider("نسبة المصاريف العامة والأرباح (%)", 5, 30, 15) + overhead_value = total_price * overhead_percentage / 100 + st.metric("المصاريف العامة والأرباح", f"{overhead_value:,.2f} ريال") with col3: - # حساب نسبة هامش الربح من السعر الإجمالي - profit_margin = (total_profit / total_price) * 100 - st.metric("هامش الربح", f"{profit_margin:.1f}%") - - def _render_bill_of_quantities_tab(self): - """عرض تبويب جدول الكميات""" + grand_total = total_price + overhead_value + st.metric("الإجمالي النهائي", f"{grand_total:,.2f} ريال") - st.markdown("### جدول الكميات") - - # عرض جدول الكميات الحالي - st.markdown("#### قائمة البنود") + # رسم بياني لتوزيع التكاليف + st.markdown("### تحليل التكاليف") - # تحويل قائمة البنود إلى DataFrame - boq_df = pd.DataFrame(st.session_state.bill_of_quantities) + # حساب النسب المئوية لكل بند + pie_data = items.copy() + pie_data['نسبة من إجمالي التكاليف'] = pie_data['الإجمالي'] / total_price * 100 - # عرض البنود كجدول قابل للتعديل - edited_df = st.data_editor( - boq_df, - column_config={ - "id": st.column_config.NumberColumn("الرقم", disabled=True), - "code": st.column_config.TextColumn("الكود"), - "description": st.column_config.TextColumn("الوصف"), - "unit": st.column_config.SelectboxColumn( - "الوحدة", - options=["م3", "م2", "طن", "كجم", "عدد", "لتر", "متر"] - ), - "quantity": st.column_config.NumberColumn("الكمية", min_value=0), - "unit_price": st.column_config.NumberColumn("سعر الوحدة (ريال)", min_value=0, format="%.2f"), - "total_price": st.column_config.NumberColumn("السعر الإجمالي (ريال)", min_value=0, format="%.2f", disabled=True), - "category": st.column_config.SelectboxColumn( - "الفئة", - options=["أعمال ترابية", "أعمال خرسانية", "أعمال حديد", "أعمال بناء", "أعمال تشطيب", "أعمال كهربائية", "أعمال ميكانيكية", "أعمال صحية", "أخرى"] - ) - }, - use_container_width=True, - hide_index=True, - num_rows="dynamic" + fig = px.pie( + pie_data, + values='نسبة من إجمالي التكاليف', + names='وصف البند', + title='توزيع التكاليف حسب البنود', + hole=0.4 ) - # تحديث السعر الإجمالي لكل بند - for i, row in edited_df.iterrows(): - edited_df.at[i, 'total_price'] = row['quantity'] * row['unit_price'] - - # تحديث قائمة البنود - if not edited_df.equals(boq_df): - st.session_state.bill_of_quantities = edited_df.to_dict('records') - st.success("تم تحديث جدول الكميات بنجاح!") + st.plotly_chart(fig, use_container_width=True) - # عرض إجمالي جدول الكميات - total_boq = sum(item['total_price'] for item in st.session_state.bill_of_quantities) - st.metric("إجمالي جدول الكميات", f"{total_boq:,.2f} ريال") + # أزرار العمليات + col1, col2, col3 = st.columns(3) - # إضافة بند جديد - st.markdown("#### إضافة بند جديد") + with col1: + if styled_button("حفظ التسعير", key="save_comprehensive_pricing_button", type="primary", icon="💾"): + # تحديث بيانات التسعير الحالي + st.session_state.current_pricing['items'] = items.copy() + st.success("تم حفظ التسعير بنجاح!") - with st.form(key="add_boq_item_form"): - col1, col2 = st.columns(2) - - with col1: - new_code = st.text_input("الكود", key="new_boq_code") - new_description = st.text_area("الوصف", key="new_boq_description") - new_category = st.selectbox( - "الفئة", - ["أعمال ترابية", "أعمال خرسانية", "أعمال حديد", "أعمال بناء", "أعمال تشطيب", "أعمال كهربائية", "أعمال ميكانيكية", "أعمال صحية", "أخرى"], - key="new_boq_category" - ) + with col2: + if styled_button("تصدير إلى Excel", key="export_to_excel_button", type="info", icon="📊"): + try: + # إنشاء دليل الصادرات إذا لم يكن موجودًا + export_dir = "exports" + create_directory_if_not_exists(export_dir) + + # تصدير البيانات إلى ملف Excel + file_path = os.path.join(export_dir, f"تسعير_{tender_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx") + export_to_excel(items, file_path, tender_name) + + st.success(f"تم تصدير التسعير إلى Excel بنجاح! مسار الملف: {file_path}") + except Exception as e: + st.error(f"حدث خطأ أثناء تصدير البيانات: {str(e)}") - with col2: - new_unit = st.selectbox( - "الوحدة", - ["م3", "م2", "طن", "كجم", "عدد", "لتر", "متر"], - key="new_boq_unit" - ) - new_quantity = st.number_input("الكمية", min_value=0.0, key="new_boq_quantity") - new_unit_price = st.number_input("سعر الوحدة (ريال)", min_value=0.0, key="new_boq_unit_price") - - submit_button = st.form_submit_button("إضافة بند") - - if submit_button: - if new_code and new_description: - # إنشاء معرف جديد - new_id = max([item['id'] for item in st.session_state.bill_of_quantities], default=0) + 1 - - # حساب السعر الإجمالي - new_total_price = new_quantity * new_unit_price - - # إضافة البند الجديد - st.session_state.bill_of_quantities.append({ - 'id': new_id, - 'code': new_code, - 'description': new_description, - 'unit': new_unit, - 'quantity': new_quantity, - 'unit_price': new_unit_price, - 'total_price': new_total_price, - 'category': new_category - }) - - st.success(f"تمت إضافة البند '{new_code}' بنجاح!") - st.rerun() - else: - st.error("يرجى إدخال الكود والوصف.") + if styled_button("تصدير إلى PDF", key="export_to_pdf_button", type="info", icon="📄"): + try: + # إنشاء دليل الصادرات إذا لم يكن موجودًا + export_dir = "exports" + create_directory_if_not_exists(export_dir) + + # تصدير البيانات إلى ملف PDF + file_path = os.path.join(export_dir, f"تسعير_{tender_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf") + + # التحقق من وجود بيانات تحليل الأسعار + if 'items_price_analysis' in st.session_state and st.session_state.items_price_analysis: + # استخراج المعلومات الأساسية للمشروع + project_info = { + 'اسم_المشروع': tender_name, + 'وصف_المشروع': f"مناقصة رقم: {tender_number} - الجهة المالكة: {client} - الموقع: {location}" + } + + # تصدير البيانات مع تحليل الأسعار + export_pricing_with_analysis_to_pdf( + items, + st.session_state.items_price_analysis, + file_path, + title=f"تسعير مناقصة: {tender_name}", + project_info=project_info + ) + else: + # تصدير البيانات بدون تحليل الأسعار + export_pricing_to_pdf( + items, + file_path, + title=f"تسعير مناقصة: {tender_name}", + description=f"مناقصة رقم: {tender_number} - الجهة المالكة: {client} - الموقع: {location}" + ) + + st.success(f"تم تصدير التسعير إلى PDF بنجاح! مسار الملف: {file_path}") + except Exception as e: + st.error(f"حدث خطأ أثناء تصدير البيانات: {str(e)}") + + with col3: + if styled_button("تحليل المخاطر المالية", key="financial_risk_analysis_button", type="warning", icon="⚠️"): + st.success("تم إرسال الطلب إلى وحدة تحليل المخاطر!") + + def _render_unbalanced_pricing_tab(self): + """عرض تبويب التسعير غير المتزن""" - # تحليل جدول الكميات - st.markdown("#### تحليل جدول الكميات") + st.markdown("### التسعير غير المتزن") - col1, col2 = st.columns(2) + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return - with col1: - # توزيع البنود حسب الفئة - category_totals = {} + # شرح التسعير غير المتزن + with st.expander("ما هو التسعير غير المتزن؟", expanded=False): + st.markdown(""" + **التسعير غير المتزن** هو استراتيجية تسعير تقوم على توزيع التكاليف بين بنود المناقصة بشكل غير متساوٍ، مع الحفاظ على إجمالي قيمة العطاء. - for item in st.session_state.bill_of_quantities: - category = item['category'] - if category in category_totals: - category_totals[category] += item['total_price'] - else: - category_totals[category] = item['total_price'] + ### استراتيجيات التسعير غير المتزن: - category_df = pd.DataFrame({ - 'الفئة': list(category_totals.keys()), - 'المبلغ': list(category_totals.values()) - }) + 1. **التحميل الأمامي (Front Loading)**: زيادة أسعار البنود المبكرة في المشروع للحصول على تدفق نقدي أفضل في بداية المشروع. + 2. **التحميل الخلفي (Back Loading)**: زيادة أسعار البنود المتأخرة في المشروع. + 3. **تحميل البنود المؤكدة**: زيادة أسعار البنود التي من المؤكد تنفيذها بالكميات المحددة. + 4. **تخفيض أسعار البنود المحتملة**: تخفيض أسعار البنود التي قد تزيد كمياتها أثناء التنفيذ. - fig = px.pie( - category_df, - values='المبلغ', - names='الفئة', - title='توزيع جدول الكميات حسب الفئة' - ) + ### مزايا التسعير غير المتزن: - st.plotly_chart(fig, use_container_width=True) + - تحسين التدفق النقدي للمشروع. + - تعظيم الربحية في حالة التغييرات والأوامر التغييرية. + - زيادة فرص الفوز بالمناقصة. + + ### مخاطر التسعير غير المتزن: + + - قد يتم رفض العطاء إذا كان عدم التوازن واضحاً. + - قد تتأثر السمعة سلباً إذا تم استخدامه بشكل مفرط. + - قد يؤدي إلى خسائر إذا لم يتم تنفيذ البنود ذات الأسعار العالية. + """) + + # عرض بنود التسعير الحالي + items = st.session_state.current_pricing['items'].copy() + + # إضافة عمود إستراتيجية التسعير + if 'إستراتيجية التسعير' not in items.columns: + items['إستراتيجية التسعير'] = 'متوازن' + + st.markdown("### إستراتيجية التسعير غير المتزن") + + # اختيار الإستراتيجية + strategy = st.selectbox( + "اختر إستراتيجية التسعير", + [ + "تحميل أمامي (Front Loading)", + "تحميل البنود المؤكدة", + "تخفيض البنود المحتمل زيادتها", + "إستراتيجية مخصصة" + ], + key="pricing_strategy_selector" + ) - with col2: - # ترتيب البنود حسب القيمة - top_items = sorted(st.session_state.bill_of_quantities, key=lambda x: x['total_price'], reverse=True)[:5] + # تطبيق الإستراتيجية المختارة + if strategy == "تحميل أمامي (Front Loading)": + # محاكاة تحميل أمامي + items_count = len(items) + early_items = items.iloc[:items_count//3].index + middle_items = items.iloc[items_count//3:2*items_count//3].index + late_items = items.iloc[2*items_count//3:].index - top_items_df = pd.DataFrame({ - 'البند': [item['code'] + ' - ' + item['description'][:20] + '...' for item in top_items], - 'القيمة': [item['total_price'] for item in top_items] - }) + # تطبيق الزيادة والنقصان + for idx in early_items: + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.3 # زيادة 30% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' - fig = px.bar( - top_items_df, - x='البند', - y='القيمة', - title='أعلى 5 بنود من حيث القيمة', - color='القيمة', - text_auto='.2s' - ) + for idx in middle_items: + items.at[idx, 'إستراتيجية التسعير'] = 'متوازن' - st.plotly_chart(fig, use_container_width=True) + for idx in late_items: + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.7 # نقص 30% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + elif strategy == "تحميل البنود المؤكدة": + # محاكاة - اعتبار بعض البنود مؤكدة + confirmed_items = [0, 2, 4] # الأصفار-مستندة + variable_items = [idx for idx in range(len(items)) if idx not in confirmed_items] + + # تطبيق الزيادة والنقصان + for idx in confirmed_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.25 # زيادة 25% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in variable_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.85 # نقص 15% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + elif strategy == "تخفيض البنود المحتمل زيادتها": + # محاكاة - اعتبار بعض البنود محتمل زيادتها + variable_items = [1, 3] # الأصفار-مستندة + other_items = [idx for idx in range(len(items)) if idx not in variable_items] + + # تطبيق الزيادة والنقصان + for idx in variable_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.7 # نقص 30% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + for idx in other_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.15 # زيادة 15% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + else: # إستراتيجية مخصصة + st.markdown("### تعديل أسعار البنود يدوياً") + st.markdown("قم بتعديل أسعار البنود وإستراتيجية التسعير يدوياً من خلال النموذج أدناه.") + + # إضافة واجهة لتعديل الأسعار يدوياً + if 'edit_items' not in st.session_state: + st.session_state.edit_items = items.copy() + + # عرض نموذج التعديل لكل بند + for index, row in items.iterrows(): + with st.container(): + col1, col2, col3, col4 = st.columns([3, 1, 1, 1]) + + with col1: + st.markdown(f"**البند:** {row['وصف البند']}") + + with col2: + original_price = st.session_state.current_pricing['items'].iloc[index]['سعر الوحدة'] + new_price = st.number_input( + "سعر الوحدة الجديد", + min_value=0.01, + value=float(row['سعر الوحدة']), + key=f"price_{index}" + ) + items.at[index, 'سعر الوحدة'] = new_price + + with col3: + percent_change = ((new_price - original_price) / original_price) * 100 + st.metric( + "نسبة التغيير", + f"{percent_change:.1f}%", + delta=f"{new_price - original_price:.2f}" + ) + + with col4: + strategy_options = ['متوازن', 'زيادة', 'نقص'] + current_strategy = row['إستراتيجية التسعير'] + strategy_index = strategy_options.index(current_strategy) if current_strategy in strategy_options else 0 + + new_strategy = st.selectbox( + "الإستراتيجية", + options=strategy_options, + index=strategy_index, + key=f"strategy_{index}" + ) + items.at[index, 'إستراتيجية التسعير'] = new_strategy + + st.markdown("---") + + # حساب الإجمالي بعد التعديل + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + # تعيين ألوان للإستراتيجيات وتنسيق الجدول بشكل متقدم + def highlight_row(row): + strategy = row['إستراتيجية التسعير'] + styles = [''] * len(row) + + # تطبيق لون خلفية لكل صف حسب الإستراتيجية + if strategy == 'زيادة': + background = 'linear-gradient(90deg, rgba(168, 230, 207, 0.3), rgba(168, 230, 207, 0.1))' + text_color = '#1F7A8C' + elif strategy == 'نقص': + background = 'linear-gradient(90deg, rgba(255, 154, 162, 0.3), rgba(255, 154, 162, 0.1))' + text_color = '#9D2A45' + else: + background = 'linear-gradient(90deg, rgba(220, 237, 255, 0.3), rgba(220, 237, 255, 0.1))' + text_color = '#555555' + + # تطبيق النمط على جميع الخلايا في الصف + for i in range(len(styles)): + styles[i] = f'background: {background}; color: {text_color}; border-bottom: 1px solid #ddd;' + + # تطبيق نمط خاص على خلية الإستراتيجية + if strategy == 'زيادة': + styles[list(row.index).index('إستراتيجية التسعير')] = 'background-color: #a8e6cf; color: #007263; font-weight: bold; border-radius: 5px; text-align: center;' + elif strategy == 'نقص': + styles[list(row.index).index('إستراتيجية التسعير')] = 'background-color: #ff9aa2; color: #9D2A45; font-weight: bold; border-radius: 5px; text-align: center;' + else: + styles[list(row.index).index('إستراتيجية التسعير')] = 'background-color: #dceeff; color: #555555; font-weight: bold; border-radius: 5px; text-align: center;' + + # تنسيق عمود السعر + price_idx = list(row.index).index('سعر الوحدة') + styles[price_idx] = styles[price_idx] + 'font-weight: bold;' + + # تنسيق عمود الإجمالي + total_idx = list(row.index).index('الإجمالي') + styles[total_idx] = styles[total_idx] + 'font-weight: bold;' + + return styles - # استيراد وتصدير جدول الكميات - st.markdown("#### استيراد وتصدير جدول الكميات") + # عرض الجدول مع تنسيق متقدم + st.subheader("بنود التسعير غير المتزن") - col1, col2 = st.columns(2) - - with col1: - if st.button("تصدير جدول الكميات إلى Excel", key="export_boq_button"): - # محاكاة تصدير البيانات - st.success("تم تصدير جدول الكميات إلى Excel بنجاح!") - - with col2: - uploaded_file = st.file_uploader("استيراد جدول الكميات من Excel", type=["xlsx"], key="import_boq_file") - - if uploaded_file is not None: - if st.button("استيراد البيانات", key="import_boq_button"): - # محاكاة استيراد البيانات - st.success("تم استيراد جدول الكميات بنجاح!") - - def _render_cost_analysis_tab(self): - """عرض تبويب تحليل التكاليف""" + # تطبيق التنسيق على الجدول + styled_items = items.style.apply(highlight_row, axis=1) - st.markdown("### تحليل التكاليف") - - # عرض تحليل التكاليف الحالي - st.markdown("#### قائمة التكاليف") - - # تحويل قائمة التكاليف إلى DataFrame - cost_df = pd.DataFrame(st.session_state.cost_analysis) - - # عرض التكاليف كجدول قابل للتعديل - edited_df = st.data_editor( - cost_df, - column_config={ - "id": st.column_config.NumberColumn("الرقم", disabled=True), - "category": st.column_config.SelectboxColumn( - "الفئة", - options=["تكاليف مباشرة", "تكاليف غير مباشرة", "أرباح"] - ), - "subcategory": st.column_config.TextColumn("الفئة الفرعية"), - "description": st.column_config.TextColumn("الوصف"), - "amount": st.column_config.NumberColumn("المبلغ (ريال)", min_value=0, format="%.2f"), - "percentage": st.column_config.NumberColumn("النسبة (%)", min_value=0, format="%.1f", disabled=True) - }, - use_container_width=True, - hide_index=True, - num_rows="dynamic" - ) - - # حساب إجمالي التكاليف - total_amount = sum(item['amount'] for item in st.session_state.cost_analysis) + # تنسيق تنسيق الأرقام + styled_items = styled_items.format({ + 'الكمية': '{:,.2f}', + 'سعر الوحدة': '{:,.2f}', + 'الإجمالي': '{:,.2f}' + }) - # تحديث النسبة المئوية لكل بند - for i, row in edited_df.iterrows(): - edited_df.at[i, 'percentage'] = (row['amount'] / total_amount) * 100 + st.dataframe(styled_items, use_container_width=True, height=None) + + # المقارنة بين التسعير المتوازن وغير المتوازن + st.subheader("مقارنة التسعير المتوازن وغير المتوازن") + + original_items = st.session_state.current_pricing['items'].copy() + original_total = original_items['الإجمالي'].sum() + unbalanced_total = items['الإجمالي'].sum() + + # عرض بطاقات المقارنة بتصميم متقدم + st.markdown(""" + + """, unsafe_allow_html=True) - # تحديث قائمة التكاليف - if not edited_df.equals(cost_df): - st.session_state.cost_analysis = edited_df.to_dict('records') - st.success("تم تحديث تحليل التكاليف بنجاح!") + col1, col2, col3 = st.columns(3) - # عرض إجمالي التكاليف - st.metric("إجمالي التكاليف", f"{total_amount:,.2f} ريال") + with col1: + st.markdown(""" +
+
إجمالي التسعير المتوازن
+
{:,.2f} ريال
+
التسعير الأصلي
+
+ """.format(original_total), unsafe_allow_html=True) - # إضافة تكلفة جديدة - st.markdown("#### إضافة تكلفة جديدة") + with col2: + st.markdown(""" +
+
إجمالي التسعير غير المتوازن
+
{:,.2f} ريال
+
بعد إعادة توزيع الأسعار
+
+ """.format( + unbalanced_total, + "positive-delta" if unbalanced_total > original_total else "negative-delta" if unbalanced_total < original_total else "neutral-delta" + ), unsafe_allow_html=True) - with st.form(key="add_cost_item_form"): - col1, col2 = st.columns(2) - - with col1: - new_category = st.selectbox( - "الفئة", - ["تكاليف مباشرة", "تكاليف غير مباشرة", "أرباح"], - key="new_cost_category" - ) - new_subcategory = st.text_input("الفئة الفرعية", key="new_cost_subcategory") + with col3: + diff = unbalanced_total - original_total + delta_percent = diff/original_total*100 if original_total > 0 else 0 - with col2: - new_description = st.text_input("الوصف", key="new_cost_description") - new_amount = st.number_input("المبلغ (ريال)", min_value=0.0, key="new_cost_amount") - - submit_button = st.form_submit_button("إضافة تكلفة") - - if submit_button: - if new_description and new_subcategory: - # إنشاء معرف جديد - new_id = max([item['id'] for item in st.session_state.cost_analysis], default=0) + 1 - - # حساب النسبة المئوية - new_percentage = (new_amount / (total_amount + new_amount)) * 100 - - # إضافة التكلفة الجديدة - st.session_state.cost_analysis.append({ - 'id': new_id, - 'category': new_category, - 'subcategory': new_subcategory, - 'description': new_description, - 'amount': new_amount, - 'percentage': new_percentage - }) - - # إعادة حساب النسب المئوية لجميع البنود - new_total = total_amount + new_amount - for item in st.session_state.cost_analysis: - item['percentage'] = (item['amount'] / new_total) * 100 + st.markdown(""" +
+
الفرق بين التسعيرين
+
{:,.2f} ريال
+
نسبة الفرق: {:+.1f}%
+
+ """.format( + diff, + "positive-delta" if diff > 0 else "negative-delta" if diff < 0 else "neutral-delta", + delta_percent + ), unsafe_allow_html=True) + + # المعايرة للحفاظ على إجمالي التسعير + if abs(diff) > 1: # إذا كان هناك فرق كبير + if styled_button("معايرة الأسعار للحفاظ على إجمالي التسعير", key="calibrate_prices_button", type="primary", icon="⚖️", full_width=True): + # تعديل الأسعار للحفاظ على إجمالي التكلفة + adjustment_factor = original_total / unbalanced_total + items['سعر الوحدة'] = items['سعر الوحدة'] * adjustment_factor + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] - st.success(f"تمت إضافة التكلفة '{new_description}' بنجاح!") - st.rerun() - else: - st.error("يرجى إدخال الفئة الفرعية والوصف.") + st.success(f"تم تعديل الأسعار للحفاظ على إجمالي التسعير الأصلي ({original_total:,.2f} ريال)") + st.dataframe(items, use_container_width=True) - # تحليل التكاليف - st.markdown("#### تحليل التكاليف") - - # تحليل التكاليف حسب الفئة - st.markdown("##### توزيع التكاليف حسب الفئة") - - # تجميع البيانات حسب الفئة - category_totals = {} - - for item in st.session_state.cost_analysis: - category = item['category'] - if category in category_totals: - category_totals[category] += item['amount'] - else: - category_totals[category] = item['amount'] + # رسم بياني للمقارنة + st.subheader("تحليل بصري للتسعير غير المتوازن") - category_df = pd.DataFrame({ - 'الفئة': list(category_totals.keys()), - 'المبلغ': list(category_totals.values()) + # إعداد البيانات للرسم البياني + chart_data = pd.DataFrame({ + 'وصف البند': original_items['وصف البند'], + 'التسعير المتوازن': original_items['الإجمالي'], + 'التسعير غير المتوازن': items['الإجمالي'] }) - fig = px.pie( - category_df, - values='المبلغ', - names='الفئة', - title='توزيع التكاليف حسب الفئة', - color_discrete_sequence=px.colors.qualitative.Set3 - ) - - st.plotly_chart(fig, use_container_width=True) - - # تحليل التكاليف المباشرة - st.markdown("##### تحليل التكاليف المباشرة") - - col1, col2 = st.columns(2) - - with col1: - # تجميع البيانات حسب الفئة الفرعية للتكاليف المباشرة - direct_subcategory_totals = {} - - for item in st.session_state.cost_analysis: - if item['category'] == 'تكاليف مباشرة': - subcategory = item['subcategory'] - if subcategory in direct_subcategory_totals: - direct_subcategory_totals[subcategory] += item['amount'] - else: - direct_subcategory_totals[subcategory] = item['amount'] + # إضافة عمود للنسبة المئوية للتغيير + chart_data['نسبة التغيير'] = (chart_data['التسعير غير المتوازن'] - chart_data['التسعير المتوازن']) / chart_data['التسعير المتوازن'] * 100 + + # تحديد لون الأعمدة بناءً على نسبة التغيير + bar_colors = [] + for change in chart_data['نسبة التغيير']: + if change > 5: # زيادة كبيرة + bar_colors.append('#1F7A8C') # أزرق مخضر + elif change > 0: # زيادة صغيرة + bar_colors.append('#81B29A') # أخضر فاتح + elif change > -5: # نقص صغير + bar_colors.append('#F2CC8F') # أصفر + else: # نقص كبير + bar_colors.append('#E07A5F') # أحمر + + # التبويب بين مخططات مختلفة للمقارنة + chart_tabs = st.tabs(["مخطط شريطي", "مخطط مقارنة", "مخطط نسبة التغيير"]) + + with chart_tabs[0]: # رسم بياني شريطي + # رسم بياني شريطي للمقارنة + fig = go.Figure() - direct_subcategory_df = pd.DataFrame({ - 'الفئة الفرعية': list(direct_subcategory_totals.keys()), - 'المبلغ': list(direct_subcategory_totals.values()) - }) + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير المتوازن'], + name='التسعير المتوازن', + marker_color='rgba(55, 83, 109, 0.7)' + )) + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير غير المتوازن'], + name='التسعير غير المتوازن', + marker_color=bar_colors + )) - fig = px.pie( - direct_subcategory_df, - values='المبلغ', - names='الفئة الفرعية', - title='توزيع التكاليف المباشرة حسب الفئة الفرعية' + fig.update_layout( + title='مقارنة بين التسعير المتوازن وغير المتوازن', + xaxis_tickfont_size=14, + yaxis=dict( + title='الإجمالي (ريال)', + titlefont_size=16, + tickfont_size=14, + ), + legend=dict( + x=0.01, + y=0.99, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='rgba(0, 0, 0, 0.1)', + borderwidth=1 + ), + barmode='group', + bargap=0.15, + bargroupgap=0.1, + plot_bgcolor='rgba(240, 249, 255, 0.5)', + margin=dict(t=50, b=50, l=20, r=20) ) st.plotly_chart(fig, use_container_width=True) - with col2: - # تجميع البيانات حسب الوصف للتكاليف المباشرة - direct_description_totals = {} - - for item in st.session_state.cost_analysis: - if item['category'] == 'تكاليف مباشرة': - description = item['description'] - if description in direct_description_totals: - direct_description_totals[description] += item['amount'] - else: - direct_description_totals[description] = item['amount'] + with chart_tabs[1]: # رسم مقارنة + # رسم مقارنة بين التسعيرين + fig = go.Figure() - direct_description_df = pd.DataFrame({ - 'الوصف': list(direct_description_totals.keys()), - 'المبلغ': list(direct_description_totals.values()) - }) + # إضافة خط للتسعير المتوازن + fig.add_trace(go.Scatter( + x=chart_data['وصف البند'], + y=chart_data['التسعير المتوازن'], + name='التسعير المتوازن', + mode='lines+markers', + line=dict(color='rgb(55, 83, 109)', width=3), + marker=dict(size=10, color='rgb(55, 83, 109)') + )) - # ترتيب البيانات تنازلياً حسب المبلغ - direct_description_df = direct_description_df.sort_values(by='المبلغ', ascending=False) + # إضافة نقاط للتسعير غير المتوازن + fig.add_trace(go.Scatter( + x=chart_data['وصف البند'], + y=chart_data['التسعير غير المتوازن'], + name='التسعير غير المتوازن', + mode='lines+markers', + line=dict(color='rgb(26, 118, 255)', width=3), + marker=dict( + size=12, + color=bar_colors, + line=dict(width=2, color='white') + ) + )) - fig = px.bar( - direct_description_df, - x='الوصف', - y='المبلغ', - title='توزيع التكاليف المباشرة حسب البند', - color='المبلغ', - text_auto='.2s' + # تحديثات التخطيط + fig.update_layout( + title='مقارنة مرئية بين استراتيجيات التسعير', + xaxis_tickfont_size=14, + yaxis=dict( + title='القيمة الإجمالية (ريال)', + titlefont_size=16, + tickfont_size=14, + gridcolor='rgba(200, 200, 200, 0.2)' + ), + legend=dict( + x=0.01, + y=0.99, + bgcolor='rgba(255, 255, 255, 0.8)', + bordercolor='rgba(0, 0, 0, 0.1)', + borderwidth=1 + ), + plot_bgcolor='rgba(240, 249, 255, 0.5)', + margin=dict(t=50, b=50, l=20, r=20) ) st.plotly_chart(fig, use_container_width=True) - # تحليل التكاليف غير المباشرة - st.markdown("##### تحليل التكاليف غير المباشرة") - - col1, col2 = st.columns(2) - - with col1: - # تجميع البيانات حسب الفئة الفرعية للتكاليف غير المباشرة - indirect_subcategory_totals = {} - - for item in st.session_state.cost_analysis: - if item['category'] == 'تكاليف غير مباشرة': - subcategory = item['subcategory'] - if subcategory in indirect_subcategory_totals: - indirect_subcategory_totals[subcategory] += item['amount'] - else: - indirect_subcategory_totals[subcategory] = item['amount'] + with chart_tabs[2]: # مخطط نسبة التغيير + # مخطط للنسبة المئوية للتغيير + fig = go.Figure() - indirect_subcategory_df = pd.DataFrame({ - 'الفئة الفرعية': list(indirect_subcategory_totals.keys()), - 'المبلغ': list(indirect_subcategory_totals.values()) - }) + # إضافة أعمدة لنسبة التغيير مع ألوان مختلفة حسب القيمة + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['نسبة التغيير'], + name='نسبة التغيير', + marker_color=bar_colors, + text=[f"{val:.1f}%" for val in chart_data['نسبة التغيير']], + textposition='auto' + )) + + # إضافة خط أفقي عند الصفر + fig.add_shape( + type="line", + x0=-0.5, + y0=0, + x1=len(chart_data['وصف البند'])-0.5, + y1=0, + line=dict( + color="black", + width=2, + dash="dash", + ) + ) - fig = px.pie( - indirect_subcategory_df, - values='المبلغ', - names='الفئة الفرعية', - title='توزيع التكاليف غير المباشرة حسب الفئة الفرعية' + # تحديثات التخطيط + fig.update_layout( + title='نسبة التغيير في أسعار البنود (%)', + xaxis_tickfont_size=14, + yaxis=dict( + title='نسبة التغيير (%)', + titlefont_size=16, + tickfont_size=14, + gridcolor='rgba(200, 200, 200, 0.2)', + zeroline=True, + zerolinecolor='black', + zerolinewidth=2 + ), + plot_bgcolor='rgba(240, 249, 255, 0.5)', + margin=dict(t=50, b=50, l=20, r=20) ) st.plotly_chart(fig, use_container_width=True) - - with col2: - # تجميع البيانات حسب الوصف للتكاليف غير المباشرة - indirect_description_totals = {} - - for item in st.session_state.cost_analysis: - if item['category'] == 'تكاليف غير مباشرة': - description = item['description'] - if description in indirect_description_totals: - indirect_description_totals[description] += item['amount'] - else: - indirect_description_totals[description] = item['amount'] - indirect_description_df = pd.DataFrame({ - 'الوصف': list(indirect_description_totals.keys()), - 'المبلغ': list(indirect_description_totals.values()) - }) + # إضافة جدول مع نسب التغيير + st.markdown("#### جدول مفصل بنسب التغيير") - # ترتيب البيانات تنازلياً حسب المبلغ - indirect_description_df = indirect_description_df.sort_values(by='المبلغ', ascending=False) + # إعداد بيانات الجدول + table_data = chart_data[['وصف البند', 'التسعير المتوازن', 'التسعير غير المتوازن', 'نسبة التغيير']] - fig = px.bar( - indirect_description_df, - x='الوصف', - y='المبلغ', - title='توزيع التكاليف غير المباشرة حسب البند', - color='المبلغ', - text_auto='.2s' - ) + # تنسيق الجدول + def highlight_change(row): + change = row['نسبة التغيير'] + if change > 5: + return ['', '', '', 'background-color: rgba(31, 122, 140, 0.3); color: #1F7A8C; font-weight: bold;'] + elif change > 0: + return ['', '', '', 'background-color: rgba(129, 178, 154, 0.3); color: #2A9D8F; font-weight: bold;'] + elif change > -5: + return ['', '', '', 'background-color: rgba(242, 204, 143, 0.3); color: #BC6C25; font-weight: bold;'] + else: + return ['', '', '', 'background-color: rgba(224, 122, 95, 0.3); color: #AE2012; font-weight: bold;'] - st.plotly_chart(fig, use_container_width=True) + # تطبيق التنسيق + styled_table = table_data.style.apply(highlight_change, axis=1).format({ + 'التسعير المتوازن': '{:,.2f} ريال', + 'التسعير غير المتوازن': '{:,.2f} ريال', + 'نسبة التغيير': '{:+.1f}%' + }) + + st.dataframe(styled_table, use_container_width=True) + + # قسم لإضافة أو حذف البنود + st.markdown("### إدارة بنود التسعير") - # استيراد وتصدير تحليل التكاليف - st.markdown("#### استيراد وتصدير تحليل التكاليف") + # إضافة بند جديد + with st.expander("إضافة بند جديد"): + col1, col2, col3, col4 = st.columns([3, 1, 1, 1]) + + with col1: + new_item_desc = st.text_input("وصف البند", placeholder="أدخل وصف البند الجديد") + + with col2: + new_item_unit = st.selectbox( + "الوحدة", + options=["م3", "م2", "متر طولي", "طن", "قطعة", "كجم", "لتر"], + index=0, + key="construction_item_unit" + ) + + with col3: + new_item_qty = st.number_input("الكمية", min_value=0.1, value=1.0, format="%.2f") + + with col4: + new_item_price = st.number_input("سعر الوحدة", min_value=0.1, value=100.0, format="%.2f") + + if st.button("إضافة البند"): + if new_item_desc: + # إنشاء رقم البند الجديد + new_id = f"UB{len(items)+1}" + + # إنشاء صف جديد + new_row = pd.DataFrame({ + 'رقم البند': [new_id], + 'وصف البند': [new_item_desc], + 'الوحدة': [new_item_unit], + 'الكمية': [float(new_item_qty)], + 'سعر الوحدة': [float(new_item_price)], + 'الإجمالي': [float(new_item_qty * new_item_price)], + 'إستراتيجية التسعير': ['متوازن'] + }) + + # إضافة الصف إلى DataFrame + items = pd.concat([items, new_row], ignore_index=True) + + # تحديث الإجمالي + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + st.success(f"تم إضافة البند \"{new_item_desc}\" بنجاح!") + st.rerun() + else: + st.warning("يرجى إدخال وصف للبند") + + # حذف بند + with st.expander("حذف بند"): + if len(items) > 0: + # قائمة بالبنود الحالية + item_options = [f"{row['رقم البند']} - {row['وصف البند']}" for idx, row in items.iterrows()] + selected_item_to_delete = st.selectbox( + "اختر البند المراد حذفه", + options=item_options, + key="item_to_delete_selector" + ) + + # استخراج رقم البند + item_id_to_delete = selected_item_to_delete.split(" - ")[0] + + if st.button("حذف البند", key="delete_item_button_2"): + # البحث عن البند وحذفه + items = items[items['رقم البند'] != item_id_to_delete] + st.success(f"تم حذف البند {item_id_to_delete} بنجاح!") + st.rerun() + else: + st.info("لا توجد بنود لحذفها") + + # أزرار الحفظ والتصدير مع تصميم محسن + st.divider() + st.subheader("حفظ وتصدير البيانات") + + st.markdown(""" + + """, unsafe_allow_html=True) col1, col2 = st.columns(2) with col1: - if st.button("تصدير تحليل التكاليف إلى Excel", key="export_cost_button"): - # محاكاة تصدير البيانات - st.success("تم تصدير تحليل التكاليف إلى Excel بنجاح!") + # بطاقة حفظ التسعير + st.markdown(""" +
+
💾
+
حفظ التسعير غير المتوازن
+
قم بحفظ التسعير الحالي في المشروع لاستخدامه لاحقاً في التقارير وفي إجمالي التسعير.
+
+ """, unsafe_allow_html=True) + + # زر حفظ التسعير غير المتوازن + if st.button("حفظ التسعير غير المتوازن", type="primary", use_container_width=True, key="save_unbalanced_pricing_button"): + st.session_state.current_pricing['items'] = items.copy() + st.session_state.current_pricing['method'] = "التسعير غير المتزن" + st.success("تم حفظ التسعير غير المتوازن بنجاح!") + st.balloons() # إضافة تأثير احتفالي عند الحفظ with col2: - uploaded_file = st.file_uploader("استيراد تحليل التكاليف من Excel", type=["xlsx"], key="import_cost_file") + # بطاقة تصدير التسعير + st.markdown(""" +
+
📊
+
تصدير البيانات
+
قم بتصدير جدول التسعير الحالي بصيغة CSV لاستخدامه في برامج أخرى مثل Excel.
+
+ """, unsafe_allow_html=True) - if uploaded_file is not None: - if st.button("استيراد البيانات", key="import_cost_button"): - # محاكاة استيراد البيانات - st.success("تم استيراد تحليل التكاليف بنجاح!") - - def _render_pricing_scenarios_tab(self): - """عرض تبويب سيناريوهات التسعير""" - - st.markdown("### سيناريوهات التسعير") - - # عرض سيناريوهات التسعير الحالية - st.markdown("#### قائمة السيناريوهات") - - # تحويل قائمة السيناريوهات إلى DataFrame - scenarios_df = pd.DataFrame(st.session_state.price_scenarios) - - # عرض السيناريوهات كجدول قابل للتعديل - edited_df = st.data_editor( - scenarios_df, - column_config={ - "id": st.column_config.NumberColumn("الرقم", disabled=True), - "name": st.column_config.TextColumn("اسم السيناريو"), - "description": st.column_config.TextColumn("الوصف"), - "total_cost": st.column_config.NumberColumn("إجمالي التكلفة (ريال)", min_value=0, format="%.2f"), - "profit_margin": st.column_config.NumberColumn("هامش الربح (%)", min_value=0, format="%.1f"), - "total_price": st.column_config.NumberColumn("السعر الإجمالي (ريال)", min_value=0, format="%.2f"), - "is_active": st.column_config.CheckboxColumn("نشط") - }, - use_container_width=True, - hide_index=True, - num_rows="dynamic" - ) + # زر تصدير التسعير + export_button = st.button("تجهيز ملف للتصدير", use_container_width=True, key="prepare_export_file_button") + if export_button: + # تحويل البيانات إلى CSV + csv = items.to_csv(index=False) + st.success("تم تجهيز ملف التصدير بنجاح! يمكنك تنزيله الآن.") + # تقديم البيانات للتنزيل + st.download_button( + label="تنزيل ملف CSV", + data=csv, + file_name="unbalanced_pricing.csv", + mime="text/csv", + use_container_width=True + ) + + def _render_construction_calculator_tab(self): + """عرض تبويب حاسبة تكاليف البناء المتكاملة""" - # تحديث قائمة السيناريوهات - if not edited_df.equals(scenarios_df): - # التأكد من وجود سيناريو نشط واحد فقط - active_count = sum(edited_df['is_active']) - if active_count != 1: - st.error("يجب أن يكون هناك سيناريو نشط واحد فقط.") - else: - st.session_state.price_scenarios = edited_df.to_dict('records') - st.success("تم تحديث سيناريوهات التسعير بنجاح!") + # استدعاء حاسبة تكاليف البناء المتكاملة الجديدة + from .construction_calculator import render_construction_calculator - # إضافة سيناريو جديد - st.markdown("#### إضافة سيناريو جديد") + st.markdown("### حاسبة تكاليف البناء المتكاملة") - with st.form(key="add_scenario_form"): + # شرح حاسبة تكاليف البناء + with st.expander("دليل استخدام حاسبة تكاليف البناء", expanded=False): + st.markdown(""" + **حاسبة تكاليف البناء المتكاملة** هي أداة تساعد في حساب تكاليف البناء بشكل تفصيلي، مع مراعاة جميع عناصر التكلفة: + + ### مكونات التكلفة: + - المواد الخام + - العمالة + - المعدات + - المصاريف الإدارية + - هامش الربح + + ### كيفية الاستخدام: + 1. اختر إما حساب تكلفة بند واحد أو حساب تكلفة مشروع. + 2. أدخل بيانات المواد والعمالة والمعدات. + 3. حدد نسب المصاريف الإدارية وهامش الربح. + 4. اضبط عوامل التعديل حسب ظروف المشروع. + 5. احصل على تحليل تفصيلي للتكاليف والسعر النهائي. + + ### مميزات الحاسبة: + - قاعدة بيانات مدمجة للأسعار المرجعية للمواد والعمالة. + - تحليل نسب مساهمة كل عنصر في التكلفة. + - إمكانية تعديل عوامل التكلفة حسب الموقع والظروف. + - تصدير النتائج بتنسيقات متعددة. + - الربط مع وحدة التسعير لنقل النتائج مباشرة إلى جدول البنود. + - قاعدة بيانات للبنود النموذجية في أعمال المقاولات. + """) + + render_construction_calculator() + + # شرح حاسبة تكاليف البناء + with st.expander("دليل استخدام حاسبة تكاليف البناء", expanded=False): + st.markdown(""" + **حاسبة تكاليف البناء المتكاملة** هي أداة تساعد في حساب تكاليف البناء بشكل تفصيلي، مع مراعاة جميع عناصر التكلفة: + + ### مكونات التكلفة: + + 1. **المواد الخام**: جميع المواد المستخدمة في البناء مثل الخرسانة، الحديد، الطوب، الأسمنت، وغيرها. + 2. **العمالة**: تكاليف جميع العمالة المطلوبة بمختلف تخصصاتها. + 3. **المعدات**: تكاليف استخدام أو استئجار المعدات اللازمة للمشروع. + 4. **المصاريف الإدارية**: النفقات العامة والإدارية للمشروع (نسبة من التكلفة المباشرة). + 5. **هامش الربح**: نسبة الربح المضافة على التكلفة. + + ### طريقة الاستخدام: + + 1. اختر إما حساب تكلفة بند واحد أو حساب تكلفة مشروع كامل. + 2. أدخل بيانات المواد والعمالة والمعدات المستخدمة. + 3. حدد نسب المصاريف الإدارية وهامش الربح. + 4. اضبط عوامل التعديل حسب ظروف المشروع. + 5. احصل على تحليل تفصيلي للتكاليف والسعر النهائي. + + ### ميزات الحاسبة: + + - قاعدة بيانات مدمجة للأسعار المرجعية للمواد والعمالة والمعدات. + - تحليل نسب مساهمة كل عنصر في التكلفة الإجمالية. + - إمكانية تعديل عوامل التكلفة حسب الموقع والظروف السوقية. + - تصدير النتائج بتنسيقات مختلفة. + - الربط مع وحدة التسعير لنقل النتائج مباشرة إلى جدول التسعير. + - قاعدة بيانات للبنود النموذجية في أعمال المقاولات (مناهل، مواسير، إسفلت، إلخ). + """) + + # تبويبات حاسبة التكاليف + calc_tabs = st.tabs(["حساب تكلفة بند", "حساب تكلفة مشروع", "قوائم الأسعار المرجعية", "كتالوج أعمال المقاولات"]) + + with calc_tabs[0]: # حساب تكلفة بند + st.markdown("#### حساب تكلفة بند بناء") + + # تهيئة بيانات البند الافتراضية إذا لم تكن موجودة + if 'construction_item' not in st.session_state: + st.session_state.construction_item = { + 'وصف_البند': "توريد وصب خرسانة مسلحة للأساسات", + 'الكمية': 25.0, + 'الوحدة': "م3", + 'المواد': [ + {'الاسم': 'خرسانة جاهزة', 'الكمية': 25.0, 'الوحدة': 'م3', 'سعر_الوحدة': 750.0}, + {'الاسم': 'حديد تسليح', 'الكمية': 3.5, 'الوحدة': 'طن', 'سعر_الوحدة': 5500.0} + ], + 'العمالة': [ + {'النوع': 'عامل خرسانة', 'العدد': 6, 'المدة': 1.0, 'سعر_اليوم': 150.0}, + {'النوع': 'حداد مسلح', 'العدد': 4, 'المدة': 3.0, 'سعر_اليوم': 250.0}, + {'النوع': 'نجار مسلح', 'العدد': 4, 'المدة': 3.0, 'سعر_اليوم': 250.0} + ], + 'المعدات': [ + {'النوع': 'مضخة خرسانة', 'العدد': 1.0, 'المدة': 0.5, 'سعر_اليوم': 5000.0}, + {'النوع': 'هزاز خرسانة', 'العدد': 2.0, 'المدة': 1.0, 'سعر_اليوم': 150.0} + ], + 'المصاريف_الإدارية': 0.05, # 5% + 'هامش_الربح': 0.10, # 10% + 'عوامل_التعديل': { + 'location_factor': 1.0, + 'time_factor': 1.0, + 'risk_factor': 1.0, + 'market_factor': 1.0 + } + } + + # نموذج إدخال بيانات البند col1, col2 = st.columns(2) with col1: - new_name = st.text_input("اسم السيناريو", key="new_scenario_name") - new_description = st.text_area("الوصف", key="new_scenario_description") + st.session_state.construction_item['وصف_البند'] = st.text_area( + "وصف البند", + value=st.session_state.construction_item['وصف_البند'], + key="construction_item_description" + ) with col2: - # حساب إجمالي التكاليف الحالي - total_cost = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] != 'أرباح') - - st.number_input("إجمالي التكلفة (ريال)", min_value=0.0, value=total_cost, key="new_scenario_total_cost", disabled=True) - new_profit_margin = st.number_input("هامش الربح (%)", min_value=0.0, max_value=100.0, value=10.0, key="new_scenario_profit_margin") - - # حساب السعر الإجمالي - new_profit_amount = total_cost * (new_profit_margin / 100) - new_total_price = total_cost + new_profit_amount - - st.number_input("السعر الإجمالي (ريال)", min_value=0.0, value=new_total_price, key="new_scenario_total_price", disabled=True) - new_is_active = st.checkbox("نشط", key="new_scenario_is_active") - - submit_button = st.form_submit_button("إضافة سيناريو") - - if submit_button: - if new_name: - # التحقق من حالة التنشيط - if new_is_active: - # إلغاء تنشيط جميع السيناريوهات الأخرى - for scenario in st.session_state.price_scenarios: - scenario['is_active'] = False - - # إنشاء معرف جديد - new_id = max([item['id'] for item in st.session_state.price_scenarios], default=0) + 1 - - # إضافة السيناريو الجديد - st.session_state.price_scenarios.append({ - 'id': new_id, - 'name': new_name, - 'description': new_description, - 'total_cost': total_cost, - 'profit_margin': new_profit_margin, - 'total_price': new_total_price, - 'is_active': new_is_active - }) + st.session_state.construction_item['الكمية'] = st.number_input( + "الكمية", + value=st.session_state.construction_item['الكمية'], + min_value=0.1, + format="%.2f" + ) + + unit_options = ["م3", "م2", "طن", "متر طولي", "قطعة", "كجم", "لتر"] + st.session_state.construction_item['الوحدة'] = st.selectbox( + "الوحدة", + options=unit_options, + index=unit_options.index(st.session_state.construction_item['الوحدة']) if st.session_state.construction_item['الوحدة'] in unit_options else 0, + key="construction_item_unit_2" + ) + + # إدخال تفاصيل المواد + st.markdown("#### تفاصيل المواد") + + material_controls = [] + for i, material in enumerate(st.session_state.construction_item['المواد']): + col1, col2, col3, col4, col5 = st.columns([3, 2, 1, 2, 1]) + + with col1: + material_name = st.text_input( + "اسم المادة", + value=material['الاسم'], + key=f"material_name_{i}" + ) + + with col2: + material_qty = st.number_input( + "الكمية", + value=material['الكمية'], + min_value=0.0, + format="%.2f", + key=f"material_qty_{i}" + ) + + with col3: + material_unit = st.selectbox( + "الوحدة", + options=unit_options, + index=unit_options.index(material['الوحدة']) if material['الوحدة'] in unit_options else 0, + key=f"material_unit_{i}" + ) + + with col4: + material_price = st.number_input( + "سعر الوحدة", + value=material['سعر_الوحدة'], + min_value=0.0, + format="%.2f", + key=f"material_price_{i}" + ) - st.success(f"تمت إضافة السيناريو '{new_name}' بنجاح!") + with col5: + delete_button = st.button("حذف", key=f"delete_material_{i}") + + material_controls.append({ + 'الاسم': material_name, + 'الكمية': material_qty, + 'الوحدة': material_unit, + 'سعر_الوحدة': material_price, + 'delete': delete_button + }) + + # إضافة مادة جديدة + if st.button("إضافة مادة جديدة"): + st.session_state.construction_item['المواد'].append({ + 'الاسم': '', + 'الكمية': 0.0, + 'الوحدة': 'م3', + 'سعر_الوحدة': 0.0 + }) + st.rerun() + + # تحديث أو حذف المواد + new_materials = [] + for i, control in enumerate(material_controls): + if not control['delete']: + new_materials.append({ + 'الاسم': control['الاسم'], + 'الكمية': control['الكمية'], + 'الوحدة': control['الوحدة'], + 'سعر_الوحدة': control['سعر_الوحدة'] + }) + + if len(new_materials) != len(st.session_state.construction_item['المواد']): + st.session_state.construction_item['المواد'] = new_materials st.rerun() else: - st.error("يرجى إدخال اسم السيناريو.") - - # تحليل السيناريوهات - st.markdown("#### تحليل السيناريوهات") - - # مقارنة السيناريوهات - st.markdown("##### مقارنة السيناريوهات") - - # إنشاء DataFrame للرسم البياني - scenarios_comparison_df = pd.DataFrame({ - 'السيناريو': [item['name'] for item in st.session_state.price_scenarios], - 'التكلفة الإجمالية': [item['total_cost'] for item in st.session_state.price_scenarios], - 'هامش الربح (%)': [item['profit_margin'] for item in st.session_state.price_scenarios], - 'السعر الإجمالي': [item['total_price'] for item in st.session_state.price_scenarios], - 'الحالة': ['نشط' if item['is_active'] else 'غير نشط' for item in st.session_state.price_scenarios] - }) - - # إنشاء رسم بياني شريطي مزدوج - fig = go.Figure() - - # إضافة شريط للتكلفة الإجمالية - fig.add_trace(go.Bar( - x=scenarios_comparison_df['السيناريو'], - y=scenarios_comparison_df['التكلفة الإجمالية'], - name='التكلفة الإجمالية', - marker_color='indianred' - )) - - # إضافة شريط للسعر الإجمالي - fig.add_trace(go.Bar( - x=scenarios_comparison_df['السيناريو'], - y=scenarios_comparison_df['السعر الإجمالي'], - name='السعر الإجمالي', - marker_color='lightsalmon' - )) - - # إضافة خط لهامش الربح - fig.add_trace(go.Scatter( - x=scenarios_comparison_df['السيناريو'], - y=scenarios_comparison_df['هامش الربح (%)'] * 10000, # تكبير القيم لتظهر على الرسم البياني - name='هامش الربح (%)', - yaxis='y2', - line=dict(color='royalblue', width=4) - )) - - # تعديل تخطيط الرسم البياني - fig.update_layout( - title='مقارنة سيناريوهات التسعير', - xaxis_title='السيناريو', - yaxis_title='المبلغ (ريال)', - yaxis2=dict( - title='هامش الربح (%)', - titlefont=dict(color='royalblue'), - tickfont=dict(color='royalblue'), - overlaying='y', - side='right', - range=[0, 20] - ), - barmode='group', - legend=dict( - x=0, - y=1.2, - orientation='h' - ) - ) - - # تعديل النص على الأشرطة - fig.update_traces( - texttemplate='%{y:,.0f}', - textposition='outside' - ) - - st.plotly_chart(fig, use_container_width=True) - - # تحليل تأثير هامش الربح - st.markdown("##### تحليل تأثير هامش الربح") - - # إنشاء نطاق من هوامش الربح - profit_margins = list(range(0, 21, 2)) # من 0% إلى 20% بزيادة 2% - - # حساب السعر الإجمالي لكل هامش ربح - total_cost = st.session_state.price_scenarios[0]['total_cost'] # استخدام التكلفة الإجمالية من السيناريو الأول - total_prices = [total_cost * (1 + margin / 100) for margin in profit_margins] - - # إنشاء DataFrame للرسم البياني - profit_analysis_df = pd.DataFrame({ - 'هامش الربح (%)': profit_margins, - 'السعر الإجمالي': total_prices - }) - - # إنشاء رسم بياني خطي - fig = px.line( - profit_analysis_df, - x='هامش الربح (%)', - y='السعر الإجمالي', - title='تأثير هامش الربح على السعر الإجمالي', - markers=True - ) - - # تعديل النص على النقاط - fig.update_traces( - texttemplate='%{y:,.0f}', - textposition='top center' - ) - - st.plotly_chart(fig, use_container_width=True) - - # تحليل نقطة التعادل - st.markdown("##### تحليل نقطة التعادل") - - # افتراض تكاليف ثابتة ومتغيرة - fixed_costs = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'تكاليف غير مباشرة') - variable_costs = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'تكاليف مباشرة') - - # افتراض سعر البيع من السيناريو النشط - active_scenario = next((item for item in st.session_state.price_scenarios if item['is_active']), None) - if active_scenario: - selling_price = active_scenario['total_price'] - else: - selling_price = st.session_state.price_scenarios[0]['total_price'] - - # حساب نقطة التعادل - if selling_price > variable_costs: - breakeven_point = fixed_costs / (selling_price - variable_costs) - st.metric("نقطة التعادل", f"{breakeven_point:.2f} وحدة") - - # إنشاء رسم بياني لنقطة التعادل - units = list(range(0, int(breakeven_point * 2) + 1, max(1, int(breakeven_point / 10)))) - - total_costs = [fixed_costs + variable_costs * unit for unit in units] - total_revenues = [selling_price * unit for unit in units] - profits = [revenue - cost for revenue, cost in zip(total_revenues, total_costs)] - - breakeven_df = pd.DataFrame({ - 'الوحدات': units, - 'إجمالي التكاليف': total_costs, - 'إجمالي الإيرادات': total_revenues, - 'الربح': profits - }) + for i, material in enumerate(new_materials): + st.session_state.construction_item['المواد'][i] = material - fig = go.Figure() + # إدخال تفاصيل العمالة + st.markdown("#### تفاصيل العمالة") - fig.add_trace(go.Scatter( - x=breakeven_df['الوحدات'], - y=breakeven_df['إجمالي التكاليف'], - name='إجمالي التكاليف', - line=dict(color='red', width=2) - )) + labor_controls = [] + for i, labor in enumerate(st.session_state.construction_item['العمالة']): + col1, col2, col3, col4, col5 = st.columns([3, 1, 1, 2, 1]) + + with col1: + labor_type = st.text_input( + "نوع العامل", + value=labor['النوع'], + key=f"labor_type_{i}" + ) + + with col2: + labor_count = st.number_input( + "العدد", + value=float(labor['العدد']), + min_value=1.0, + step=1.0, + key=f"labor_count_{i}" + ) + + with col3: + labor_days = st.number_input( + "المدة (أيام)", + value=labor['المدة'], + min_value=0.1, + format="%.1f", + key=f"labor_days_{i}" + ) + + with col4: + labor_daily_rate = st.number_input( + "سعر اليوم", + value=labor['سعر_اليوم'], + min_value=0.0, + format="%.2f", + key=f"labor_daily_rate_{i}" + ) + + with col5: + delete_button = st.button("حذف", key=f"delete_labor_{i}") + + labor_controls.append({ + 'النوع': labor_type, + 'العدد': labor_count, + 'المدة': labor_days, + 'سعر_اليوم': labor_daily_rate, + 'delete': delete_button + }) - fig.add_trace(go.Scatter( - x=breakeven_df['الوحدات'], - y=breakeven_df['إجمالي الإيرادات'], - name='إجمالي الإيرادات', - line=dict(color='green', width=2) - )) + # إضافة عامل جديد + if st.button("إضافة عامل جديد"): + st.session_state.construction_item['العمالة'].append({ + 'النوع': '', + 'العدد': 1.0, + 'المدة': 1.0, + 'سعر_اليوم': 0.0 + }) + st.rerun() - fig.add_trace(go.Scatter( - x=breakeven_df['الوحدات'], - y=breakeven_df['الربح'], - name='الربح', - line=dict(color='blue', width=2) - )) + # تحديث أو حذف العمالة + new_labor = [] + for i, control in enumerate(labor_controls): + if not control['delete']: + new_labor.append({ + 'النوع': control['النوع'], + 'العدد': control['العدد'], + 'المدة': control['المدة'], + 'سعر_اليوم': control['سعر_اليوم'] + }) - # إضافة خط عمودي عند نقطة التعادل - fig.add_vline( - x=breakeven_point, - line_dash="dash", - line_color="black", - annotation_text=f"نقطة التعادل: {breakeven_point:.2f}", - annotation_position="top right" - ) + if len(new_labor) != len(st.session_state.construction_item['العمالة']): + st.session_state.construction_item['العمالة'] = new_labor + st.rerun() + else: + for i, labor in enumerate(new_labor): + st.session_state.construction_item['العمالة'][i] = labor - # إضافة خط أفقي عند الصفر - fig.add_hline( - y=0, - line_dash="dash", - line_color="gray" - ) + # إدخال تفاصيل المعدات + st.markdown("#### تفاصيل المعدات") - fig.update_layout( - title='تحليل نقطة التعادل', - xaxis_title='الوحدات', - yaxis_title='المبلغ (ريال)' - ) + equipment_controls = [] + for i, equipment in enumerate(st.session_state.construction_item['المعدات']): + col1, col2, col3, col4, col5 = st.columns([3, 1, 1, 2, 1]) + + with col1: + equip_type = st.text_input( + "نوع المعدة", + value=equipment['النوع'], + key=f"equip_type_{i}" + ) + + with col2: + equip_count = st.number_input( + "العدد", + value=float(equipment['العدد']), + min_value=1.0, + step=1.0, + key=f"equip_count_{i}" + ) + + with col3: + equip_days = st.number_input( + "المدة (أيام)", + value=equipment['المدة'], + min_value=0.1, + format="%.1f", + key=f"equip_days_{i}" + ) + + with col4: + equip_daily_rate = st.number_input( + "سعر اليوم", + value=equipment['سعر_اليوم'], + min_value=0.0, + format="%.2f", + key=f"equip_daily_rate_{i}" + ) + + with col5: + delete_button = st.button("حذف", key=f"delete_equipment_{i}") + + equipment_controls.append({ + 'النوع': equip_type, + 'العدد': equip_count, + 'المدة': equip_days, + 'سعر_اليوم': equip_daily_rate, + 'delete': delete_button + }) - st.plotly_chart(fig, use_container_width=True) - else: - st.warning("لا يمكن حساب نقطة التعادل لأن سعر البيع أقل من التكاليف المتغيرة.") - - def _render_competitive_analysis_tab(self): - """عرض تبويب المقارنة التنافسية""" - - st.markdown("### المقارنة التنافسية") - - # بيانات افتراضية للمنافسين - competitors_data = [ - { - 'name': 'شركتنا', - 'price': 670000, - 'quality': 4.5, - 'delivery_time': 180, - 'experience': 8, - 'local_content': 85 - }, - { - 'name': 'المنافس أ', - 'price': 700000, - 'quality': 4.2, - 'delivery_time': 200, - 'experience': 10, - 'local_content': 75 - }, - { - 'name': 'المنافس ب', - 'price': 650000, - 'quality': 3.8, - 'delivery_time': 160, - 'experience': 5, - 'local_content': 90 - }, - { - 'name': 'المنافس ج', - 'price': 680000, - 'quality': 4.0, - 'delivery_time': 190, - 'experience': 12, - 'local_content': 80 - } - ] - - # عرض بيانات المنافسين - st.markdown("#### بيانات المنافسين") - - competitors_df = pd.DataFrame(competitors_data) - st.dataframe(competitors_df, use_container_width=True, hide_index=True) - - # مقارنة الأسعار - st.markdown("#### مقارنة الأسعار") - - fig = px.bar( - competitors_df, - x='name', - y='price', - title='مقارنة الأسعار بين المنافسين', - color='price', - text_auto='.2s' - ) - - fig.update_layout( - xaxis_title='المنافس', - yaxis_title='السعر (ريال)' - ) - - st.plotly_chart(fig, use_container_width=True) - - # مقارنة متعددة الأبعاد - st.markdown("#### مقارنة متعددة الأبعاد") - - # تحويل البيانات إلى تنسيق مناسب للرسم البياني الراداري - categories = ['price', 'quality', 'delivery_time', 'experience', 'local_content'] - - # تطبيع البيانات (لجعل القيم بين 0 و 1) - normalized_data = {} - - for category in categories: - if category == 'price' or category == 'delivery_time': - # للسعر ووقت التسليم، القيمة الأقل أفضل - min_val = min(item[category] for item in competitors_data) - max_val = max(item[category] for item in competitors_data) - normalized_data[category] = [(max_val - item[category]) / (max_val - min_val) if max_val != min_val else 0.5 for item in competitors_data] + # إضافة معدة جديدة + if st.button("إضافة معدة جديدة"): + st.session_state.construction_item['المعدات'].append({ + 'النوع': '', + 'العدد': 1.0, + 'المدة': 1.0, + 'سعر_اليوم': 0.0 + }) + st.rerun() + + # تحديث أو حذف المعدات + new_equipment = [] + for i, control in enumerate(equipment_controls): + if not control['delete']: + new_equipment.append({ + 'النوع': control['النوع'], + 'العدد': control['العدد'], + 'المدة': control['المدة'], + 'سعر_اليوم': control['سعر_اليوم'] + }) + + if len(new_equipment) != len(st.session_state.construction_item['المعدات']): + st.session_state.construction_item['المعدات'] = new_equipment + st.rerun() else: - # للجودة والخبرة والمحتوى المحلي، القيمة الأعلى أفضل - min_val = min(item[category] for item in competitors_data) - max_val = max(item[category] for item in competitors_data) - normalized_data[category] = [(item[category] - min_val) / (max_val - min_val) if max_val != min_val else 0.5 for item in competitors_data] - - # إنشاء الرسم البياني الراداري - fig = go.Figure() - - for i, competitor in enumerate(competitors_data): - fig.add_trace(go.Scatterpolar( - r=[normalized_data[category][i] for category in categories], - theta=['السعر', 'الجودة', 'وقت التسليم', 'الخبرة', 'المحتوى المحلي'], - fill='toself', - name=competitor['name'] - )) - - fig.update_layout( - polar=dict( - radialaxis=dict( - visible=True, - range=[0, 1] + for i, equipment in enumerate(new_equipment): + st.session_state.construction_item['المعدات'][i] = equipment + + # النسب والعوامل + st.markdown("#### المصاريف الإدارية وهامش الربح") + + col1, col2 = st.columns(2) + + with col1: + st.session_state.construction_item['المصاريف_الإدارية'] = st.slider( + "نسبة المصاريف الإدارية (%)", + min_value=0.0, + max_value=20.0, + value=st.session_state.construction_item['المصاريف_الإدارية'] * 100, + step=0.5 + ) / 100 + + with col2: + st.session_state.construction_item['هامش_الربح'] = st.slider( + "نسبة هامش الربح (%)", + min_value=0.0, + max_value=30.0, + value=st.session_state.construction_item['هامش_الربح'] * 100, + step=0.5 + ) / 100 + + # عوامل التعديل + st.markdown("#### عوامل تعديل التكلفة") + + col1, col2 = st.columns(2) + + with col1: + st.session_state.construction_item['عوامل_التعديل']['location_factor'] = st.slider( + "معامل الموقع", + min_value=0.5, + max_value=2.0, + value=st.session_state.construction_item['عوامل_التعديل']['location_factor'], + step=0.05, + help="يؤثر في التكلفة حسب صعوبة أو سهولة الوصول للموقع وتوفر الخدمات" ) - ), - title='مقارنة متعددة الأبعاد بين المنافسين', - showlegend=True - ) - - st.plotly_chart(fig, use_container_width=True) - - # تحليل نقاط القوة والضعف - st.markdown("#### تحليل نقاط القوة والضعف") - - # تحديد نقاط القوة والضعف لشركتنا - our_company = competitors_data[0] - - strengths = [] - weaknesses = [] - - # مقارنة السعر - other_prices = [comp['price'] for comp in competitors_data[1:]] - if our_company['price'] <= min(other_prices): - strengths.append("السعر تنافسي جداً") - elif our_company['price'] <= sum(other_prices) / len(other_prices): - strengths.append("السعر تنافسي") - else: - weaknesses.append("السعر أعلى من المتوسط") - - # مقارنة الجودة - other_qualities = [comp['quality'] for comp in competitors_data[1:]] - if our_company['quality'] >= max(other_qualities): - strengths.append("الجودة ممتازة") - elif our_company['quality'] >= sum(other_qualities) / len(other_qualities): - strengths.append("الجودة جيدة") - else: - weaknesses.append("الجودة أقل من المتوسط") - - # مقارنة وقت التسليم - other_delivery_times = [comp['delivery_time'] for comp in competitors_data[1:]] - if our_company['delivery_time'] <= min(other_delivery_times): - strengths.append("وقت التسليم سريع جداً") - elif our_company['delivery_time'] <= sum(other_delivery_times) / len(other_delivery_times): - strengths.append("وقت التسليم جيد") - else: - weaknesses.append("وقت التسليم أطول من المتوسط") - - # مقارنة الخبرة - other_experiences = [comp['experience'] for comp in competitors_data[1:]] - if our_company['experience'] >= max(other_experiences): - strengths.append("خبرة واسعة جداً") - elif our_company['experience'] >= sum(other_experiences) / len(other_experiences): - strengths.append("خبرة جيدة") - else: - weaknesses.append("خبرة أقل من المتوسط") - - # مقارنة المحتوى المحلي - other_local_contents = [comp['local_content'] for comp in competitors_data[1:]] - if our_company['local_content'] >= max(other_local_contents): - strengths.append("محتوى محلي ممتاز") - elif our_company['local_content'] >= sum(other_local_contents) / len(other_local_contents): - strengths.append("محتوى محلي جيد") - else: - weaknesses.append("محتوى محلي أقل من المتوسط") - - # عرض نقاط القوة والضعف - col1, col2 = st.columns(2) - - with col1: - st.markdown("##### نقاط القوة") - for strength in strengths: - st.markdown(f"- {strength}") - - with col2: - st.markdown("##### نقاط الضعف") - for weakness in weaknesses: - st.markdown(f"- {weakness}") - - # توصيات للتسعير - st.markdown("#### توصيات للتسعير") - - # تحديد التوصيات بناءً على المقارنة - recommendations = [] - - # توصية بناءً على السعر - avg_price = sum(comp['price'] for comp in competitors_data) / len(competitors_data) - if our_company['price'] > avg_price: - recommendations.append("النظر في تخفيض السعر للمنافسة بشكل أفضل.") - - # توصية بناءً على الجودة - if our_company['quality'] > sum(comp['quality'] for comp in competitors_data[1:]) / len(competitors_data[1:]): - recommendations.append("التأكيد على جودة الخدمات في العروض التسويقية.") - - # توصية بناءً على وقت التسليم - if our_company['delivery_time'] < sum(comp['delivery_time'] for comp in competitors_data[1:]) / len(competitors_data[1:]): - recommendations.append("التأكيد على سرعة التسليم كميزة تنافسية.") - - # توصية بناءً على الخبرة - if our_company['experience'] < max(comp['experience'] for comp in competitors_data[1:]): - recommendations.append("تعزيز فريق العمل بخبرات إضافية.") - - # توصية بناءً على المحتوى المحلي - if our_company['local_content'] > sum(comp['local_content'] for comp in competitors_data[1:]) / len(competitors_data[1:]): - recommendations.append("التأكيد على نسبة المحتوى المحلي العالية في العروض.") - - # توصية عامة - recommendations.append("مراجعة هيكل التكاليف بشكل دوري للحفاظ على القدرة التنافسية.") - - # عرض التوصيات - for recommendation in recommendations: - st.markdown(f"- {recommendation}") - - def _render_reports_tab(self): - """عرض تبويب التقارير""" - - st.markdown("### التقارير") - - # قائمة التقارير المتاحة - reports = [ - "تقرير جدول الكميات", - "تقرير تحليل التكاليف", - "تقرير سيناريوهات التسعير", - "تقرير المقارنة التنافسية", - "تقرير ملخص التسعير" - ] - - # اختيار التقرير - selected_report = st.selectbox("اختر التقرير", reports) - - # خيارات التصدير - export_format = st.radio("صيغة التصدير", ["PDF", "Excel", "Word"]) - - # زر إنشاء التقرير - if st.button("إنشاء التقرير"): - st.success(f"تم إنشاء {selected_report} بصيغة {export_format} بنجاح!") - - # عرض نموذج للتقرير - st.markdown("#### نموذج التقرير") - - if selected_report == "تقرير جدول الكميات": - self._render_boq_report() - elif selected_report == "تقرير تحليل التكاليف": - self._render_cost_analysis_report() - elif selected_report == "تقرير سيناريوهات التسعير": - self._render_pricing_scenarios_report() - elif selected_report == "تقرير المقارنة التنافسية": - self._render_competitive_analysis_report() - elif selected_report == "تقرير ملخص التسعير": - self._render_pricing_summary_report() - - def _render_boq_report(self): - """عرض نموذج تقرير جدول الكميات""" - - st.markdown("### تقرير جدول الكميات") - st.markdown("**تاريخ التقرير:** " + time.strftime("%Y-%m-%d")) - st.markdown("**اسم المشروع:** مشروع إنشاء مبنى إداري") - st.markdown("**رقم المناقصة:** T-2024-001") - - st.markdown("#### جدول الكميات") - - # عرض جدول الكميات - boq_df = pd.DataFrame(st.session_state.bill_of_quantities) - st.dataframe(boq_df, use_container_width=True, hide_index=True) - - # عرض إجمالي جدول الكميات - total_boq = sum(item['total_price'] for item in st.session_state.bill_of_quantities) - st.metric("إجمالي جدول الكميات", f"{total_boq:,.2f} ريال") - - # عرض توزيع البنود حسب الفئة - st.markdown("#### توزيع البنود حسب الفئة") - - # تجميع البيانات حسب الفئة - category_totals = {} - - for item in st.session_state.bill_of_quantities: - category = item['category'] - if category in category_totals: - category_totals[category] += item['total_price'] - else: - category_totals[category] = item['total_price'] - - category_df = pd.DataFrame({ - 'الفئة': list(category_totals.keys()), - 'المبلغ': list(category_totals.values()) - }) + + st.session_state.construction_item['عوامل_التعديل']['time_factor'] = st.slider( + "معامل الوقت", + min_value=0.8, + max_value=1.5, + value=st.session_state.construction_item['عوامل_التعديل']['time_factor'], + step=0.05, + help="يؤثر في التكلفة حسب الجدول الزمني للمشروع وضرورة الإسراع في التنفيذ" + ) + + with col2: + st.session_state.construction_item['عوامل_التعديل']['risk_factor'] = st.slider( + "معامل المخاطر", + min_value=1.0, + max_value=1.5, + value=st.session_state.construction_item['عوامل_التعديل']['risk_factor'], + step=0.05, + help="يؤثر في التكلفة حسب المخاطر المتوقعة في المشروع" + ) + + st.session_state.construction_item['عوامل_التعديل']['market_factor'] = st.slider( + "معامل السوق", + min_value=0.8, + max_value=1.3, + value=st.session_state.construction_item['عوامل_التعديل']['market_factor'], + step=0.05, + help="يؤثر في التكلفة حسب حالة السوق الحالية وتغيرات الأسعار" + ) + + # صف أزرار العمليات + col1, col2 = st.columns(2) + + with col1: + # زر حساب التكلفة + if st.button("حساب تكلفة البند", type="primary"): + with st.spinner("جاري حساب التكلفة..."): + # حساب التكلفة باستخدام الحاسبة + item_cost = self.construction_calculator.calculate_item_cost(st.session_state.construction_item) + st.session_state.item_cost_result = item_cost + + with col2: + # زر استيراد بند من كتالوج أعمال المقاولات + if st.button("استيراد بند من الكتالوج"): + # تهيئة session state للاختيار من الكتالوج + if 'show_catalog_selection' not in st.session_state: + st.session_state.show_catalog_selection = True + else: + st.session_state.show_catalog_selection = True + st.rerun() + + # عرض واجهة اختيار البند من الكتالوج + if 'show_catalog_selection' in st.session_state and st.session_state.show_catalog_selection: + st.markdown("#### اختيار بند من كتالوج أعمال المقاولات") + + # الحصول على فئات البنود + categories = self.construction_templates.get_all_templates()['categories'] + category_options = [f"{cat_data['name']}" for cat_id, cat_data in categories.items()] + category_ids = list(categories.keys()) + + selected_category_index = st.selectbox( + "اختر فئة البند", + options=range(len(category_options)), + format_func=lambda i: category_options[i], + key="construction_category_selector" + ) + + selected_category_id = category_ids[selected_category_index] + + # الحصول على البنود في الفئة المحددة + templates = self.construction_templates.get_templates_by_category(selected_category_id) + + if templates: + template_options = [f"{template['name']}" for template in templates] + + selected_template_index = st.selectbox( + "اختر البند", + options=range(len(template_options)), + format_func=lambda i: template_options[i], + key="construction_template_selector" + ) + + selected_template = templates[selected_template_index] + + # عرض تفاصيل البند المحدد + st.markdown(f"**وصف البند**: {selected_template['description']}") + st.markdown(f"**الوحدة**: {selected_template['unit']}") + + if st.button("استخدام هذا البند", type="primary"): + # تحويل البند إلى صيغة الحاسبة + template_id = selected_template['id'] + construction_item = self.construction_templates.convert_template_to_item(template_id) + + # تحديث بيانات البند في session state + st.session_state.construction_item = construction_item + st.session_state.show_catalog_selection = False + st.rerun() + else: + st.info("لا توجد بنود في هذه الفئة") + + if st.button("إلغاء"): + st.session_state.show_catalog_selection = False + st.rerun() + + # عرض نتائج الحساب + if 'item_cost_result' in st.session_state: + st.markdown("### نتائج حساب تكلفة البند") + + result = st.session_state.item_cost_result + + # ملخص البند + st.markdown(f"**البند:** {result['وصف_البند']}") + st.markdown(f"**الكمية:** {result['الكمية']} {result['الوحدة']}") + + # المكونات الرئيسية للتكلفة + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.metric( + "تكلفة المواد", + f"{result['تكاليف_مباشرة']['المواد']['الإجمالي']:,.2f} ريال" + ) + + with col2: + st.metric( + "تكلفة العمالة", + f"{result['تكاليف_مباشرة']['العمالة']['الإجمالي']:,.2f} ريال" + ) + + with col3: + st.metric( + "تكلفة المعدات", + f"{result['تكاليف_مباشرة']['المعدات']['الإجمالي']:,.2f} ريال" + ) + + with col4: + st.metric( + "التكلفة المباشرة", + f"{result['تكاليف_مباشرة']['إجمالي_تكاليف_مباشرة']:,.2f} ريال" + ) + + # تفاصيل المصاريف والربح والسعر النهائي + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + f"المصاريف الإدارية ({result['مصاريف_إدارية']['نسبة']}%)", + f"{result['مصاريف_إدارية']['قيمة']:,.2f} ريال" + ) + + with col2: + st.metric( + f"هامش الربح ({result['هامش_ربح']['نسبة']}%)", + f"{result['هامش_ربح']['قيمة']:,.2f} ريال" + ) + + with col3: + st.metric( + "التكلفة الإجمالية", + f"{result['التكلفة_الإجمالية']:,.2f} ريال" + ) + + # التكلفة بعد تطبيق عوامل التعديل + adjustment_factor = result['عوامل_التعديل']['المعامل_الإجمالي'] + st.metric( + f"السعر النهائي المعدل (معامل التعديل: {adjustment_factor:.2f})", + f"{result['السعر_المعدل']['إجمالي']:,.2f} ريال", + delta=f"{(adjustment_factor - 1) * 100:.1f}%" + ) + + # سعر الوحدة وزر نقل السعر إلى جدول التسعير + col1, col2 = st.columns([2, 1]) + + with col1: + st.metric( + f"سعر الوحدة ({result['الوحدة']})", + f"{result['السعر_المعدل']['سعر_الوحدة']:,.2f} ريال" + ) + + with col2: + if st.button("نقل السعر إلى جدول التسعير", key="transfer_to_pricing"): + if 'manual_items' not in st.session_state: + st.session_state.manual_items = pd.DataFrame(columns=[ + 'رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'سعر الوحدة', 'الإجمالي' + ]) + + # إنشاء رقم البند الجديد + new_id = f"B{len(st.session_state.manual_items)+1}" + + # إنشاء صف جديد + new_row = pd.DataFrame({ + 'رقم البند': [new_id], + 'وصف البند': [result['وصف_البند']], + 'الوحدة': [result['الوحدة']], + 'الكمية': [float(result['الكمية'])], + 'سعر الوحدة': [float(result['السعر_المعدل']['سعر_الوحدة'])], + 'الإجمالي': [float(result['السعر_المعدل']['إجمالي'])] + }) + + # إضافة الصف إلى DataFrame + st.session_state.manual_items = pd.concat([st.session_state.manual_items, new_row], ignore_index=True) + + # إنشاء حالة التسعير الحالي إذا لم تكن موجودة + if 'current_pricing' not in st.session_state: + st.session_state.current_pricing = { + 'name': 'تسعير جديد', + 'method': 'تسعير مستورد من حاسبة تكاليف البناء', + 'items': st.session_state.manual_items + } + else: + # تحديث البنود في التسعير الحالي + st.session_state.current_pricing['items'] = st.session_state.manual_items + + st.success(f"تم نقل البند \"{result['وصف_البند']}\" إلى جدول التسعير بنجاح!") + + # تفاصيل التكلفة - المواد + st.markdown("#### تفاصيل تكلفة المواد") + if len(result['تكاليف_مباشرة']['المواد']['التفاصيل']) > 0: + materials_df = pd.DataFrame(result['تكاليف_مباشرة']['المواد']['التفاصيل']) + st.dataframe(materials_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد مواد مضافة") + + # تفاصيل التكلفة - العمالة + st.markdown("#### تفاصيل تكلفة العمالة") + if len(result['تكاليف_مباشرة']['العمالة']['التفاصيل']) > 0: + labor_df = pd.DataFrame(result['تكاليف_مباشرة']['العمالة']['التفاصيل']) + st.dataframe(labor_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد عمالة مضافة") + + # تفاصيل التكلفة - المعدات + st.markdown("#### تفاصيل تكلفة المعدات") + if len(result['تكاليف_مباشرة']['المعدات']['التفاصيل']) > 0: + equipment_df = pd.DataFrame(result['تكاليف_مباشرة']['المعدات']['التفاصيل']) + st.dataframe(equipment_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد معدات مضافة") + + # رسم بياني لتوزيع التكلفة + st.markdown("#### توزيع مكونات التكلفة") + + cost_components = { + 'المواد': result['تكاليف_مباشرة']['المواد']['الإجمالي'], + 'العمالة': result['تكاليف_مباشرة']['العمالة']['الإجمالي'], + 'المعدات': result['تكاليف_مباشرة']['المعدات']['الإجمالي'], + 'المصاريف الإدارية': result['مصاريف_إدارية']['قيمة'], + 'هامش الربح': result['هامش_ربح']['قيمة'] + } + + colors = ['#2E86C1', '#28B463', '#EB984E', '#8E44AD', '#C0392B'] + + fig = px.pie( + values=list(cost_components.values()), + names=list(cost_components.keys()), + title='توزيع مكونات التكلفة', + color_discrete_sequence=colors + ) + + st.plotly_chart(fig, use_container_width=True) - fig = px.pie( - category_df, - values='المبلغ', - names='الفئة', - title='توزيع جدول الكميات حسب الفئة' - ) + with calc_tabs[1]: # حساب تكلفة مشروع + st.markdown("#### حساب تكلفة مشروع كامل") + + # تهيئة بيانات المشروع الافتراضية إذا لم تكن موجودة + if 'construction_project' not in st.session_state: + st.session_state.construction_project = self.construction_calculator.generate_sample_project_data() + + # نموذج إدخال بيانات المشروع + col1, col2 = st.columns(2) + + with col1: + st.session_state.construction_project['اسم_المشروع'] = st.text_input( + "اسم المشروع", + value=st.session_state.construction_project['اسم_المشروع'] + ) + + with col2: + st.session_state.construction_project['وصف_المشروع'] = st.text_area( + "وصف المشروع", + value=st.session_state.construction_project['وصف_المشروع'], + height=100, + key="construction_project_description" + ) + + # النسب والعوامل الإجمالية للمشروع + st.markdown("#### النسب والعوامل الإجمالية للمشروع") + + col1, col2 = st.columns(2) + + with col1: + st.session_state.construction_project['المصاريف_الإدارية'] = st.slider( + "نسبة المصاريف الإدارية الإجمالية (%)", + min_value=0.0, + max_value=20.0, + value=st.session_state.construction_project['المصاريف_الإدارية'] * 100, + step=0.5, + key="project_admin_expenses" + ) / 100 + + with col2: + st.session_state.construction_project['هامش_الربح'] = st.slider( + "نسبة هامش الربح الإجمالي (%)", + min_value=0.0, + max_value=30.0, + value=st.session_state.construction_project['هامش_الربح'] * 100, + step=0.5, + key="project_profit_margin" + ) / 100 + + # عوامل التعديل للمشروع + st.markdown("#### عوامل تعديل التكلفة للمشروع") + + col1, col2 = st.columns(2) + + with col1: + st.session_state.construction_project['عوامل_التعديل']['location_factor'] = st.slider( + "معامل الموقع", + min_value=0.5, + max_value=2.0, + value=st.session_state.construction_project['عوامل_التعديل']['location_factor'], + step=0.05, + help="يؤثر في التكلفة حسب صعوبة أو سهولة الوصول للموقع وتوفر الخدمات", + key="project_location_factor" + ) + + st.session_state.construction_project['عوامل_التعديل']['time_factor'] = st.slider( + "معامل الوقت", + min_value=0.8, + max_value=1.5, + value=st.session_state.construction_project['عوامل_التعديل']['time_factor'], + step=0.05, + help="يؤثر في التكلفة حسب الجدول الزمني للمشروع وضرورة الإسراع في التنفيذ", + key="project_time_factor" + ) + + with col2: + st.session_state.construction_project['عوامل_التعديل']['risk_factor'] = st.slider( + "معامل المخاطر", + min_value=1.0, + max_value=1.5, + value=st.session_state.construction_project['عوامل_التعديل']['risk_factor'], + step=0.05, + help="يؤثر في التكلفة حسب المخاطر المتوقعة في المشروع", + key="project_risk_factor" + ) + + st.session_state.construction_project['عوامل_التعديل']['market_factor'] = st.slider( + "معامل السوق", + min_value=0.8, + max_value=1.3, + value=st.session_state.construction_project['عوامل_التعديل']['market_factor'], + step=0.05, + help="يؤثر في التكلفة حسب حالة السوق الحالية وتغيرات الأسعار", + key="project_market_factor" + ) + + # زر حساب تكلفة المشروع + if st.button("حساب تكلفة المشروع", type="primary"): + with st.spinner("جاري حساب تكلفة المشروع..."): + # حساب التكلفة باستخدام الحاسبة + project_cost = self.construction_calculator.calculate_project_cost(st.session_state.construction_project) + st.session_state.project_cost_result = project_cost + + # عرض نتائج حساب المشروع + if 'project_cost_result' in st.session_state: + st.markdown("### نتائج حساب تكلفة المشروع") + + result = st.session_state.project_cost_result + + # ملخص المشروع + st.markdown(f"**المشروع:** {result['اسم_المشروع']}") + st.markdown(f"**الوصف:** {result['وصف_المشروع']}") + st.markdown(f"**عدد البنود:** {result['عدد_البنود']} بند") + + # المكونات الرئيسية للتكلفة + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + "إجمالي تكلفة المواد", + f"{result['تكاليف_مباشرة']['المواد']['الإجمالي']:,.2f} ريال", + delta=f"{result['تكاليف_مباشرة']['المواد']['النسبة_المئوية']:.1f}% من التكلفة المباشرة" + ) + + with col2: + st.metric( + "إجمالي تكلفة العمالة", + f"{result['تكاليف_مباشرة']['العمالة']['الإجمالي']:,.2f} ريال", + delta=f"{result['تكاليف_مباشرة']['العمالة']['النسبة_المئوية']:.1f}% من التكلفة المباشرة" + ) + + with col3: + st.metric( + "إجمالي تكلفة المعدات", + f"{result['تكاليف_مباشرة']['المعدات']['الإجمالي']:,.2f} ريال", + delta=f"{result['تكاليف_مباشرة']['المعدات']['النسبة_المئوية']:.1f}% من التكلفة المباشرة" + ) + + # إجمالي التكاليف المباشرة + st.metric( + "إجمالي التكاليف المباشرة", + f"{result['تكاليف_مباشرة']['إجمالي_تكاليف_مباشرة']:,.2f} ريال" + ) + + # تفاصيل المصاريف والربح والسعر النهائي + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + f"المصاريف الإدارية ({result['مصاريف_إدارية']['نسبة']}%)", + f"{result['مصاريف_إدارية']['قيمة']:,.2f} ريال" + ) + + with col2: + st.metric( + f"هامش الربح ({result['هامش_ربح']['نسبة']}%)", + f"{result['هامش_ربح']['قيمة']:,.2f} ريال" + ) + + with col3: + st.metric( + "التكلفة الإجمالية", + f"{result['التكلفة_الإجمالية']:,.2f} ريال" + ) + + # التكلفة بعد تطبيق عوامل التعديل + adjustment_factor = result['عوامل_التعديل']['المعامل_الإجمالي'] + st.metric( + f"السعر النهائي المعدل (معامل التعديل: {adjustment_factor:.2f})", + f"{result['التكلفة_النهائية_المعدلة']:,.2f} ريال", + delta=f"{(adjustment_factor - 1) * 100:.1f}%" + ) + + # رسم بياني لتوزيع التكلفة + st.markdown("#### توزيع مكونات التكلفة") + + cost_components = { + 'المواد': result['تكاليف_مباشرة']['المواد']['الإجمالي'], + 'العمالة': result['تكاليف_مباشرة']['العمالة']['الإجمالي'], + 'المعدات': result['تكاليف_مباشرة']['المعدات']['الإجمالي'], + 'المصاريف الإدارية': result['مصاريف_إدارية']['قيمة'], + 'هامش الربح': result['هامش_ربح']['قيمة'] + } + + colors = ['#2E86C1', '#28B463', '#EB984E', '#8E44AD', '#C0392B'] + + fig = px.pie( + values=list(cost_components.values()), + names=list(cost_components.keys()), + title='توزيع مكونات التكلفة', + color_discrete_sequence=colors + ) + + st.plotly_chart(fig, use_container_width=True) + + # جدول تفاصيل البنود + st.markdown("#### تفاصيل بنود المشروع") + + items_data = [] + for i, item in enumerate(result['تفاصيل_البنود']): + items_data.append({ + 'رقم': i + 1, + 'الوصف': item['وصف_البند'], + 'الكمية': item['الكمية'], + 'الوحدة': item['الوحدة'], + 'سعر الوحدة': item['سعر_الوحدة'], + 'الإجمالي': item['التكلفة_الإجمالية'], + 'بعد التعديل': item['السعر_المعدل']['إجمالي'] + }) + + if len(items_data) > 0: + items_df = pd.DataFrame(items_data) + + # تنسيق الجدول لعرض الأرقام بشكل أفضل + def highlight_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = items_df.style.apply(highlight_row, axis=1) + styled_df = styled_df.format({ + 'الكمية': '{:,.2f}', + 'سعر الوحدة': '{:,.2f}', + 'الإجمالي': '{:,.2f}', + 'بعد التعديل': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True) + else: + st.info("لا توجد بنود في المشروع") - st.plotly_chart(fig, use_container_width=True) + with calc_tabs[2]: # قوائم الأسعار المرجعية + st.markdown("#### قوائم الأسعار المرجعية") + + ref_tabs = st.tabs(["قائمة المواد", "قائمة العمالة", "قائمة المعدات"]) + + with ref_tabs[0]: # قائمة المواد + st.markdown("#### قائمة أسعار المواد المرجعية") + + # الحصول على قائمة المواد + materials = self.construction_calculator.get_all_rates(item_type='مادة') + + if materials and 'المواد' in materials: + # تحويل قاموس المواد إلى DataFrame + materials_list = [] + for name, data in materials['المواد'].items(): + materials_list.append({ + 'اسم المادة': name, + 'الوحدة': data.get('وحدة', ''), + 'سعر الوحدة': data.get('سعر_الوحدة', 0.0), + 'الوصف': data.get('وصف', ''), + 'الفئة': data.get('فئة', '') + }) + + if materials_list: + materials_df = pd.DataFrame(materials_list) + + # تصفية حسب الفئة + categories = ['الكل'] + sorted(materials_df['الفئة'].unique().tolist()) + selected_category = st.selectbox("تصفية حسب الفئة", categories, key="materials_cat_filter_section1") + + if selected_category != 'الكل': + filtered_df = materials_df[materials_df['الفئة'] == selected_category] + else: + filtered_df = materials_df + + # تنسيق الجدول + def highlight_materials_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = filtered_df.style.apply(highlight_materials_row, axis=1) + styled_df = styled_df.format({ + 'سعر الوحدة': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد مواد في قاعدة البيانات") + else: + st.info("لا توجد قائمة مواد متاحة") + + with ref_tabs[1]: # قائمة العمالة + st.markdown("#### قائمة أسعار العمالة المرجعية") + + # الحصول على قائمة العمالة + labor = self.construction_calculator.get_all_rates(item_type='عمالة') + + if labor and 'العمالة' in labor: + # تحويل قاموس العمالة إلى DataFrame + labor_list = [] + for name, data in labor['العمالة'].items(): + labor_list.append({ + 'نوع العامل': name, + 'وحدة الأجر': data.get('وحدة', ''), + 'سعر الوحدة': data.get('سعر_الوحدة', 0.0), + 'الوصف': data.get('وصف', ''), + 'الفئة': data.get('فئة', '') + }) + + if labor_list: + labor_df = pd.DataFrame(labor_list) + + # تصفية حسب الفئة + categories = ['الكل'] + sorted(labor_df['الفئة'].unique().tolist()) + selected_category = st.selectbox("تصفية حسب الفئة", categories, key="labor_cat_filter_section1") + + if selected_category != 'الكل': + filtered_df = labor_df[labor_df['الفئة'] == selected_category] + else: + filtered_df = labor_df + + # تنسيق الجدول + def highlight_labor_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = filtered_df.style.apply(highlight_labor_row, axis=1) + styled_df = styled_df.format({ + 'سعر الوحدة': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد عمالة في قاعدة البيانات") + else: + st.info("لا توجد قائمة عمالة متاحة") + + with ref_tabs[2]: # قائمة المعدات + st.markdown("#### قائمة أسعار المعدات المرجعية") + + # الحصول على قائمة المعدات + equipment = self.construction_calculator.get_all_rates(item_type='معدة') + + if equipment and 'المعدات' in equipment: + # تحويل قاموس المعدات إلى DataFrame + equipment_list = [] + for name, data in equipment['المعدات'].items(): + equipment_list.append({ + 'نوع المعدة': name, + 'وحدة الإيجار': data.get('وحدة', ''), + 'سعر الوحدة': data.get('سعر_الوحدة', 0.0), + 'الوصف': data.get('وصف', ''), + 'الفئة': data.get('فئة', '') + }) + + if equipment_list: + equipment_df = pd.DataFrame(equipment_list) + + # تصفية حسب الفئة + categories = ['الكل'] + sorted(equipment_df['الفئة'].unique().tolist()) + selected_category = st.selectbox("تصفية حسب الفئة", categories, key="equipment_cat_filter_section1") + + if selected_category != 'الكل': + filtered_df = equipment_df[equipment_df['الفئة'] == selected_category] + else: + filtered_df = equipment_df + + # تنسيق الجدول + def highlight_equipment_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = filtered_df.style.apply(highlight_equipment_row, axis=1) + styled_df = styled_df.format({ + 'سعر الوحدة': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد معدات في قاعدة البيانات") + else: + st.info("لا توجد قائمة معدات متاحة") + + with calc_tabs[3]: # كتالوج أعمال المقاولات + st.markdown("#### كتالوج أعمال المقاولات") + + # شرح كتالوج أعمال المقاولات + with st.expander("معلومات عن كتالوج أعمال المقاولات", expanded=False): + st.markdown(""" + **كتالوج أعمال المقاولات** هو قاعدة بيانات شاملة للبنود النموذجية المستخدمة في مشاريع المقاولات ويوفر: + + - بنود جاهزة لمختلف أنواع الأعمال الإنشائية (خرسانة مناهل مواسير طرق إلخ). + - تفاصيل دقيقة للمواد والعمالة والمعدات المطلوبة لكل بند. + - تحليل تكلفة تفصيلي يمكن استخدامه مباشرة في عروض الأسعار والمناقصات. + - ربط مباشر مع حاسبة تكاليف البناء وحاسبة التسعير. + + **استخدامات الكتالوج:** + + 1. استخدام البنود النموذجية مباشرة في التسعير. + 2. تعديل البنود النموذجية لتناسب متطلبات المشروع. + 3. إضافة بنود جديدة إلى الكتالوج للاستخدام المستقبلي. + """) + + # الفئات وعرض محتوى الكتالوج + category_col, template_col = st.columns([1, 2]) + + with category_col: + st.markdown("### فئات البنود") + + # الحصول على فئات البنود + templates = self.construction_templates.get_all_templates() + categories = templates['categories'] + + for cat_id, cat_data in categories.items(): + st.markdown(f"#### {cat_data['name']}") + st.markdown(f"{cat_data['description']}") + + if st.button(f"عرض بنود {cat_data['name']}", key=f"cat_btn_{cat_id}"): + st.session_state.selected_category = cat_id + st.rerun() + + with template_col: + st.markdown("### البنود النموذجية") + + selected_category = st.session_state.get("selected_category", list(categories.keys())[0]) + + # عرض البنود في الفئة المحددة + cat_templates = self.construction_templates.get_templates_by_category(selected_category) + + if cat_templates: + st.markdown(f"#### بنود فئة {categories[selected_category]['name']}") + + for template in cat_templates: + with st.expander(template['name'], expanded=False): + st.markdown(f"**الوصف**: {template['description']}") + st.markdown(f"**الوحدة**: {template['unit']}") + + # عرض مكونات البند + st.markdown("##### مكونات البند") + + # المواد + materials = template['components']['materials'] + if materials: + materials_df = pd.DataFrame(materials) + st.markdown("**المواد:**") + st.dataframe(materials_df, hide_index=True) + + # العمالة + labor = template['components']['labor'] + if labor: + labor_df = pd.DataFrame(labor) + st.markdown("**العمالة:**") + st.dataframe(labor_df, hide_index=True) + + # المعدات + equipment = template['components']['equipment'] + if equipment: + equipment_df = pd.DataFrame(equipment) + st.markdown("**المعدات:**") + st.dataframe(equipment_df, hide_index=True) + + # أزرار العمليات + col1, col2 = st.columns(2) + + with col1: + if st.button("استخدام هذا البند في حاسبة التكاليف", key=f"use_template_{template['id']}"): + # تحويل البند إلى صيغة الحاسبة + construction_item = self.construction_templates.convert_template_to_item(template['id']) + + # تحديث بيانات البند في session state + st.session_state.construction_item = construction_item + st.session_state.active_tab = 0 # الانتقال إلى تبويب حساب تكلفة البند + st.rerun() + + with col2: + if st.button("إضافة مباشرة إلى جدول التسعير", key=f"add_to_pricing_{template['id']}"): + # تحويل البند إلى صيغة الحاسبة + construction_item = self.construction_templates.convert_template_to_item(template['id']) + + # حساب التكلفة + item_cost = self.construction_calculator.calculate_item_cost(construction_item) + + # إضافة البند إلى جدول التسعير + if 'manual_items' not in st.session_state: + st.session_state.manual_items = pd.DataFrame(columns=[ + 'رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'سعر الوحدة', 'الإجمالي' + ]) + + # إنشاء رقم البند الجديد + new_id = f"C{len(st.session_state.manual_items)+1}" + + # إنشاء صف جديد + new_row = pd.DataFrame({ + 'رقم البند': [new_id], + 'وصف البند': [item_cost['وصف_البند']], + 'الوحدة': [item_cost['الوحدة']], + 'الكمية': [float(item_cost['الكمية'])], + 'سعر الوحدة': [float(item_cost['السعر_المعدل']['سعر_الوحدة'])], + 'الإجمالي': [float(item_cost['السعر_المعدل']['إجمالي'])] + }) + + # إضافة الصف إلى DataFrame + st.session_state.manual_items = pd.concat([st.session_state.manual_items, new_row], ignore_index=True) + + # إنشاء حالة التسعير الحالي إذا لم تكن موجودة + if 'current_pricing' not in st.session_state: + st.session_state.current_pricing = { + 'name': 'تسعير جديد', + 'method': 'تسعير مستورد من كتالوج المقاولات', + 'items': st.session_state.manual_items + } + else: + # تحديث البنود في التسعير الحالي + st.session_state.current_pricing['items'] = st.session_state.manual_items + + st.success(f"تم إضافة البند \"{item_cost['وصف_البند']}\" إلى جدول التسعير بنجاح!") + + # إضافة قسم لإضافة بند جديد إلى الكتالوج + st.markdown("### إضافة بند جديد إلى الكتالوج") + + if st.button("إضافة البند الحالي إلى الكتالوج"): + if 'item_cost_result' in st.session_state: + # عرض نموذج لإضافة البند إلى الكتالوج + st.session_state.show_add_to_catalog = True + st.rerun() + else: + st.warning("يجب حساب تكلفة البند أولاً في تبويب 'حساب تكلفة بند' قبل إضافته إلى الكتالوج.") + + # عرض نموذج إضافة البند إلى الكتالوج + if 'show_add_to_catalog' in st.session_state and st.session_state.show_add_to_catalog: + st.markdown("#### إضافة البند الحالي إلى كتالوج المقاولات") + + # اختيار الفئة + category_options = [f"{cat_data['name']}" for cat_id, cat_data in categories.items()] + category_ids = list(categories.keys()) + + selected_category_index = st.selectbox( + "اختر فئة البند", + options=range(len(category_options)), + format_func=lambda i: category_options[i], + key="new_template_category" + ) + + selected_category_id = category_ids[selected_category_index] + + # معلومات البند + item_result = st.session_state.item_cost_result + + template_name = st.text_input("اسم البند في الكتالوج", value=item_result['وصف_البند'][:50]) + template_description = st.text_area("وصف البند", value=item_result['وصف_البند'], key="template_item_description") + + # الكلمات المفتاحية + tags_input = st.text_input("الكلمات المفتاحية (مفصولة بفواصل)", value="بناء, مقاولات") + tags = [tag.strip() for tag in tags_input.split(",")] + + if st.button("إضافة إلى الكتالوج", type="primary"): + # تحويل البند إلى صيغة قالب + template_data = { + "category": selected_category_id, + "name": template_name, + "description": template_description, + "unit": item_result['الوحدة'], + "components": { + "materials": item_result['تكاليف_مباشرة']['المواد']['التفاصيل'], + "labor": item_result['تكاليف_مباشرة']['العمالة']['التفاصيل'], + "equipment": item_result['تكاليف_مباشرة']['المعدات']['التفاصيل'] + }, + "admin_expenses": item_result['مصاريف_إدارية']['نسبة'] / 100, + "profit_margin": item_result['هامش_ربح']['نسبة'] / 100, + "tags": tags + } + + # إضافة القالب إلى الكتالوج + template_id = self.construction_templates.add_template(template_data) + + st.success(f"تم إضافة البند \"{template_name}\" إلى كتالوج المقاولات بنجاح!") + st.session_state.show_add_to_catalog = False + st.rerun() + + if st.button("إلغاء", key="cancel_add_to_catalog"): + st.session_state.show_add_to_catalog = False + st.rerun() + + with ref_tabs[0]: # قائمة المواد + st.markdown("#### قائمة أسعار المواد المرجعية") + + # الحصول على قائمة المواد + materials = self.construction_calculator.get_all_rates(item_type='مادة') + + if materials and 'المواد' in materials: + # تحويل قاموس المواد إلى DataFrame + materials_list = [] + for name, data in materials['المواد'].items(): + materials_list.append({ + 'اسم المادة': name, + 'الوحدة': data.get('وحدة', ''), + 'سعر الوحدة': data.get('سعر_الوحدة', 0.0), + 'الوصف': data.get('وصف', ''), + 'الفئة': data.get('فئة', '') + }) + + if materials_list: + materials_df = pd.DataFrame(materials_list) + + # تصفية حسب الفئة + categories = ['الكل'] + sorted(materials_df['الفئة'].unique().tolist()) + selected_category = st.selectbox("تصفية حسب الفئة", categories, key="materials_cat_filter_section2") + + if selected_category != 'الكل': + filtered_df = materials_df[materials_df['الفئة'] == selected_category] + else: + filtered_df = materials_df + + # تنسيق الجدول + def highlight_materials_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = filtered_df.style.apply(highlight_materials_row, axis=1) + styled_df = styled_df.format({ + 'سعر الوحدة': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد مواد في قاعدة البيانات") + else: + st.info("لا توجد قائمة مواد متاحة") + + with ref_tabs[1]: # قائمة العمالة + st.markdown("#### قائمة أسعار العمالة المرجعية") + + # الحصول على قائمة العمالة + labor = self.construction_calculator.get_all_rates(item_type='عمالة') + + if labor and 'العمالة' in labor: + # تحويل قاموس العمالة إلى DataFrame + labor_list = [] + for name, data in labor['العمالة'].items(): + labor_list.append({ + 'نوع العامل': name, + 'وحدة الأجر': data.get('وحدة', ''), + 'سعر الوحدة': data.get('سعر_الوحدة', 0.0), + 'الوصف': data.get('وصف', ''), + 'الفئة': data.get('فئة', '') + }) + + if labor_list: + labor_df = pd.DataFrame(labor_list) + + # تصفية حسب الفئة + categories = ['الكل'] + sorted(labor_df['الفئة'].unique().tolist()) + selected_category = st.selectbox("تصفية حسب الفئة", categories, key="labor_cat_filter_section2") + + if selected_category != 'الكل': + filtered_df = labor_df[labor_df['الفئة'] == selected_category] + else: + filtered_df = labor_df + + # تنسيق الجدول + def highlight_labor_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = filtered_df.style.apply(highlight_labor_row, axis=1) + styled_df = styled_df.format({ + 'سعر الوحدة': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد عمالة في قاعدة البيانات") + else: + st.info("لا توجد قائمة عمالة متاحة") + + with ref_tabs[2]: # قائمة المعدات + st.markdown("#### قائمة أسعار المعدات المرجعية") + + # الحصول على قائمة المعدات + equipment = self.construction_calculator.get_all_rates(item_type='معدة') + + if equipment and 'المعدات' in equipment: + # تحويل قاموس المعدات إلى DataFrame + equipment_list = [] + for name, data in equipment['المعدات'].items(): + equipment_list.append({ + 'نوع المعدة': name, + 'وحدة الأجر': data.get('وحدة', ''), + 'سعر الوحدة': data.get('سعر_الوحدة', 0.0), + 'الوصف': data.get('وصف', ''), + 'الفئة': data.get('فئة', '') + }) + + if equipment_list: + equipment_df = pd.DataFrame(equipment_list) + + # تصفية حسب الفئة + categories = ['الكل'] + sorted(equipment_df['الفئة'].unique().tolist()) + selected_category = st.selectbox("تصفية حسب الفئة", categories, key="equipment_cat_filter_section2") + + if selected_category != 'الكل': + filtered_df = equipment_df[equipment_df['الفئة'] == selected_category] + else: + filtered_df = equipment_df + + # تنسيق الجدول + def highlight_equipment_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = filtered_df.style.apply(highlight_equipment_row, axis=1) + styled_df = styled_df.format({ + 'سعر الوحدة': '{:,.2f}' + }) + + st.dataframe(styled_df, use_container_width=True, hide_index=True) + else: + st.info("لا توجد معدات في قاعدة البيانات") + else: + st.info("لا توجد قائمة معدات متاحة") - def _render_cost_analysis_report(self): - """عرض نموذج تقرير تحليل التكاليف""" - - st.markdown("### تقرير تحليل التكاليف") - st.markdown("**تاريخ التقرير:** " + time.strftime("%Y-%m-%d")) - st.markdown("**اسم المشروع:** مشروع إنشاء مبنى إداري") - st.markdown("**رقم المناقصة:** T-2024-001") - - st.markdown("#### تحليل التكاليف") - - # عرض تحليل التكاليف - cost_df = pd.DataFrame(st.session_state.cost_analysis) - st.dataframe(cost_df, use_container_width=True, hide_index=True) - - # عرض إجمالي التكاليف - total_cost = sum(item['amount'] for item in st.session_state.cost_analysis) - st.metric("إجمالي التكاليف", f"{total_cost:,.2f} ريال") - - # عرض توزيع التكاليف حسب الفئة - st.markdown("#### توزيع التكاليف حسب الفئة") - - # تجميع البيانات حسب الفئة - category_totals = {} + def _render_local_content_tab(self): + """عرض تبويب المحتوى المحلي""" - for item in st.session_state.cost_analysis: - category = item['category'] - if category in category_totals: - category_totals[category] += item['amount'] - else: - category_totals[category] = item['amount'] - - category_df = pd.DataFrame({ - 'الفئة': list(category_totals.keys()), - 'المبلغ': list(category_totals.values()) - }) - - fig = px.pie( - category_df, - values='المبلغ', - names='الفئة', - title='توزيع التكاليف حسب الفئة' - ) + st.markdown("

تحليل المحتوى المحلي

", unsafe_allow_html=True) - st.plotly_chart(fig, use_container_width=True) + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return - # عرض توزيع التكاليف المباشرة - st.markdown("#### توزيع التكاليف المباشرة") + # شرح المحتوى المحلي + with st.expander("ما هو المحتوى المحلي؟", expanded=False): + st.markdown(""" + **المحتوى المحلي** هو نسبة المنتجات والخدمات والقوى العاملة المحلية المستخدمة في المشروع. يهدف إلى زيادة مساهمة المنتجات والخدمات المحلية في المشاريع. + + ### مكونات المحتوى المحلي: + + 1. **المنتجات**: المنتجات والمواد المصنعة محلياً. + 2. **الخدمات**: الخدمات المقدمة من شركات محلية. + 3. **القوى العاملة**: العمالة والكوادر الفنية والإدارية المحلية. + + ### أهمية المحتوى المحلي: + + - تعزيز الاقتصاد المحلي وخلق فرص عمل. + - تحقيق أهداف رؤية 2030 في زيادة المحتوى المحلي. + - التأهل للمشاريع الحكومية التي تتطلب نسبة محتوى محلي محددة. + - الحصول على حوافز وأفضلية في المناقصات الحكومية. + + ### متطلبات المحتوى المحلي: + + - نسبة المحتوى المحلي للقوى العاملة: 80% + - نسبة المحتوى المحلي للمنتجات: 70% + - نسبة المحتوى المحلي للخدمات: 60% + """) + + # عرض لوحة إدخال بيانات المحتوى المحلي + st.markdown("### بيانات المحتوى المحلي") - # تجميع البيانات حسب الفئة الفرعية للتكاليف المباشرة - direct_subcategory_totals = {} + # التبويبات لأنواع المحتوى المحلي + lc_tabs = st.tabs(["المنتجات", "الخدمات", "القوى العاملة", "التحليل"]) - for item in st.session_state.cost_analysis: - if item['category'] == 'تكاليف مباشرة': - subcategory = item['subcategory'] - if subcategory in direct_subcategory_totals: - direct_subcategory_totals[subcategory] += item['amount'] + with lc_tabs[0]: # المنتجات + st.markdown("#### بيانات المنتجات") + + # إنشاء بيانات افتراضية للمنتجات إذا لم تكن موجودة + if 'local_content_products' not in st.session_state: + st.session_state.local_content_products = pd.DataFrame({ + 'المنتج': [ + "خرسانة مسلحة", + "حديد تسليح", + "بلوك خرساني", + "عزل مائي", + "دهانات" + ], + 'الكمية': [250, 25, 400, 500, 600], + 'سعر_الوحدة': [1200, 6000, 200, 100, 50], + 'التكلفة_الإجمالية': [300000, 150000, 80000, 50000, 30000], + 'نسبة_المحتوى_المحلي': [0.95, 0.70, 0.98, 0.60, 0.80] + }) + + # حساب التكلفة الإجمالية + st.session_state.local_content_products['التكلفة_الإجمالية'] = st.session_state.local_content_products['الكمية'] * st.session_state.local_content_products['سعر_الوحدة'] + + # نموذج إضافة منتج جديد + st.markdown("#### إضافة منتج جديد") + col1, col2, col3, col4 = st.columns(4) + + with col1: + new_product_name = st.text_input("اسم المنتج", key="new_product_name", value="") + with col2: + new_product_quantity = st.number_input("الكمية", key="new_product_quantity", min_value=0, value=0) + with col3: + new_product_price = st.number_input("سعر الوحدة", key="new_product_price", min_value=0, value=0) + with col4: + new_product_local_content = st.slider("نسبة المحتوى المحلي", key="new_product_local_content", min_value=0.0, max_value=1.0, value=0.8, step=0.01, format="%.2f") + + if st.button("إضافة المنتج"): + if new_product_name: + # حساب التكلفة الإجمالية + total_cost = new_product_quantity * new_product_price + + # إضافة منتج جديد للجدول + new_product = pd.DataFrame({ + 'المنتج': [new_product_name], + 'الكمية': [new_product_quantity], + 'سعر_الوحدة': [new_product_price], + 'التكلفة_الإجمالية': [total_cost], + 'نسبة_المحتوى_المحلي': [new_product_local_content] + }) + + # إضافة المنتج الجديد للجدول الحالي + st.session_state.local_content_products = pd.concat([st.session_state.local_content_products, new_product], ignore_index=True) + st.success(f"تم إضافة المنتج {new_product_name} بنجاح!") else: - direct_subcategory_totals[subcategory] = item['amount'] - - direct_subcategory_df = pd.DataFrame({ - 'الفئة الفرعية': list(direct_subcategory_totals.keys()), - 'المبلغ': list(direct_subcategory_totals.values()) - }) - - fig = px.bar( - direct_subcategory_df, - x='الفئة الفرعية', - y='المبلغ', - title='توزيع التكاليف المباشرة', - color='الفئة الفرعية', - text_auto='.2s' - ) - - st.plotly_chart(fig, use_container_width=True) - - def _render_pricing_scenarios_report(self): - """عرض نموذج تقرير سيناريوهات التسعير""" - - st.markdown("### تقرير سيناريوهات التسعير") - st.markdown("**تاريخ التقرير:** " + time.strftime("%Y-%m-%d")) - st.markdown("**اسم المشروع:** مشروع إنشاء مبنى إداري") - st.markdown("**رقم المناقصة:** T-2024-001") - - st.markdown("#### سيناريوهات التسعير") - - # عرض سيناريوهات التسعير - scenarios_df = pd.DataFrame(st.session_state.price_scenarios) - st.dataframe(scenarios_df, use_container_width=True, hide_index=True) - - # عرض السيناريو النشط - active_scenario = next((item for item in st.session_state.price_scenarios if item['is_active']), None) - if active_scenario: - st.markdown(f"**السيناريو النشط:** {active_scenario['name']}") - st.markdown(f"**السعر الإجمالي:** {active_scenario['total_price']:,.2f} ريال") - st.markdown(f"**هامش الربح:** {active_scenario['profit_margin']:.1f}%") - - # عرض مقارنة السيناريوهات - st.markdown("#### مقارنة السيناريوهات") - - # إنشاء DataFrame للرسم البياني - scenarios_comparison_df = pd.DataFrame({ - 'السيناريو': [item['name'] for item in st.session_state.price_scenarios], - 'التكلفة الإجمالية': [item['total_cost'] for item in st.session_state.price_scenarios], - 'هامش الربح (%)': [item['profit_margin'] for item in st.session_state.price_scenarios], - 'السعر الإجمالي': [item['total_price'] for item in st.session_state.price_scenarios], - 'الحالة': ['نشط' if item['is_active'] else 'غير نشط' for item in st.session_state.price_scenarios] - }) - - # إنشاء رسم بياني شريطي مزدوج - fig = go.Figure() - - # إضافة شريط للتكلفة الإجمالية - fig.add_trace(go.Bar( - x=scenarios_comparison_df['السيناريو'], - y=scenarios_comparison_df['التكلفة الإجمالية'], - name='التكلفة الإجمالية', - marker_color='indianred' - )) - - # إضافة شريط للسعر الإجمالي - fig.add_trace(go.Bar( - x=scenarios_comparison_df['السيناريو'], - y=scenarios_comparison_df['السعر الإجمالي'], - name='السعر الإجمالي', - marker_color='lightsalmon' - )) - - # إضافة خط لهامش الربح - fig.add_trace(go.Scatter( - x=scenarios_comparison_df['السيناريو'], - y=scenarios_comparison_df['هامش الربح (%)'] * 10000, # تكبير القيم لتظهر على الرسم البياني - name='هامش الربح (%)', - yaxis='y2', - line=dict(color='royalblue', width=4) - )) - - # تعديل تخطيط الرسم البياني - fig.update_layout( - title='مقارنة سيناريوهات التسعير', - xaxis_title='السيناريو', - yaxis_title='المبلغ (ريال)', - yaxis2=dict( - title='هامش الربح (%)', - titlefont=dict(color='royalblue'), - tickfont=dict(color='royalblue'), - overlaying='y', - side='right', - range=[0, 20] - ), - barmode='group', - legend=dict( - x=0, - y=1.2, - orientation='h' + st.warning("يرجى إدخال اسم المنتج") + + # عرض جدول البنود مع إمكانية التعديل + st.markdown("#### جدول المنتجات") + edited_products = st.data_editor( + st.session_state.local_content_products, + use_container_width=True, + hide_index=True, + key="products_editor" ) - ) - - # تعديل النص على الأشرطة - fig.update_traces( - texttemplate='%{y:,.0f}', - textposition='outside' - ) - - st.plotly_chart(fig, use_container_width=True) - - def _render_competitive_analysis_report(self): - """عرض نموذج تقرير المقارنة التنافسية""" - - st.markdown("### تقرير المقارنة التنافسية") - st.markdown("**تاريخ التقرير:** " + time.strftime("%Y-%m-%d")) - st.markdown("**اسم المشروع:** مشروع إنشاء مبنى إداري") - st.markdown("**رقم المناقصة:** T-2024-001") - - # بيانات افتراضية للمنافسين - competitors_data = [ - { - 'name': 'شركتنا', - 'price': 670000, - 'quality': 4.5, - 'delivery_time': 180, - 'experience': 8, - 'local_content': 85 - }, - { - 'name': 'المنافس أ', - 'price': 700000, - 'quality': 4.2, - 'delivery_time': 200, - 'experience': 10, - 'local_content': 75 - }, - { - 'name': 'المنافس ب', - 'price': 650000, - 'quality': 3.8, - 'delivery_time': 160, - 'experience': 5, - 'local_content': 90 - }, - { - 'name': 'المنافس ج', - 'price': 680000, - 'quality': 4.0, - 'delivery_time': 190, - 'experience': 12, - 'local_content': 80 - } - ] - - # عرض بيانات المنافسين - st.markdown("#### بيانات المنافسين") - - competitors_df = pd.DataFrame(competitors_data) - st.dataframe(competitors_df, use_container_width=True, hide_index=True) - - # مقارنة الأسعار - st.markdown("#### مقارنة الأسعار") - - fig = px.bar( - competitors_df, - x='name', - y='price', - title='مقارنة الأسعار بين المنافسين', - color='price', - text_auto='.2s' - ) - - fig.update_layout( - xaxis_title='المنافس', - yaxis_title='السعر (ريال)' - ) - - st.plotly_chart(fig, use_container_width=True) - - # مقارنة متعددة الأبعاد - st.markdown("#### مقارنة متعددة الأبعاد") - - # تحويل البيانات إلى تنسيق مناسب للرسم البياني الراداري - categories = ['price', 'quality', 'delivery_time', 'experience', 'local_content'] + st.session_state.local_content_products = edited_products + + # زر لحذف المنتجات المحددة + if st.button("حذف المنتجات المحددة"): + st.session_state.local_content_products = pd.DataFrame({ + 'المنتج': [], + 'الكمية': [], + 'سعر_الوحدة': [], + 'التكلفة_الإجمالية': [], + 'نسبة_المحتوى_المحلي': [] + }) + st.success("تم حذف جميع المنتجات!") + st.rerun() + + # عرض ملخص المنتجات + total_products_cost = edited_products['التكلفة_الإجمالية'].sum() + avg_local_content = (edited_products['التكلفة_الإجمالية'] * edited_products['نسبة_المحتوى_المحلي']).sum() / total_products_cost if total_products_cost > 0 else 0 + + st.markdown(f""" + **إجمالي تكلفة المنتجات**: {total_products_cost:,.2f} ريال + + **متوسط نسبة المحتوى المحلي للمنتجات**: {avg_local_content*100:.2f}% + + **المستهدف**: 70% + + **الحالة**: {"✅ ملتزم" if avg_local_content >= 0.7 else "❌ غير ملتزم"} + """) - # تطبيع البيانات (لجعل القيم بين 0 و 1) - normalized_data = {} + with lc_tabs[1]: # الخدمات + st.markdown("#### بيانات الخدمات") + + # إنشاء بيانات افتراضية للخدمات إذا لم تكن موجودة + if 'local_content_services' not in st.session_state: + st.session_state.local_content_services = pd.DataFrame({ + 'الخدمة': [ + "تصميم معماري", + "إشراف هندسي", + "خدمات نقل", + "خدمات أمن وسلامة", + "صيانة ونظافة" + ], + 'التكلفة': [100000, 120000, 50000, 30000, 20000], + 'نسبة_المحتوى_المحلي': [0.90, 0.85, 0.90, 0.95, 0.95] + }) + + # نموذج إضافة خدمة جديدة + st.markdown("#### إضافة خدمة جديدة") + col1, col2, col3 = st.columns(3) + + with col1: + new_service_name = st.text_input("اسم الخدمة", key="new_service_name", value="") + with col2: + new_service_cost = st.number_input("التكلفة", key="new_service_cost", min_value=0, value=0) + with col3: + new_service_local_content = st.slider("نسبة المحتوى المحلي", key="new_service_local_content", min_value=0.0, max_value=1.0, value=0.8, step=0.01, format="%.2f") + + if st.button("إضافة الخدمة"): + if new_service_name: + # إضافة خدمة جديدة للجدول + new_service = pd.DataFrame({ + 'الخدمة': [new_service_name], + 'التكلفة': [new_service_cost], + 'نسبة_المحتوى_المحلي': [new_service_local_content] + }) + + # إضافة الخدمة الجديدة للجدول الحالي + st.session_state.local_content_services = pd.concat([st.session_state.local_content_services, new_service], ignore_index=True) + st.success(f"تم إضافة الخدمة {new_service_name} بنجاح!") + else: + st.warning("يرجى إدخال اسم الخدمة") + + # عرض جدول الخدمات مع إمكانية التعديل + st.markdown("#### جدول الخدمات") + edited_services = st.data_editor( + st.session_state.local_content_services, + use_container_width=True, + hide_index=True, + key="services_editor" + ) + st.session_state.local_content_services = edited_services + + # زر لحذف الخدمات المحددة + if st.button("حذف الخدمات المحددة"): + st.session_state.local_content_services = pd.DataFrame({ + 'الخدمة': [], + 'التكلفة': [], + 'نسبة_المحتوى_المحلي': [] + }) + st.success("تم حذف جميع الخدمات!") + st.rerun() + + # عرض ملخص الخدمات + total_services_cost = edited_services['التكلفة'].sum() + avg_local_content = (edited_services['التكلفة'] * edited_services['نسبة_المحتوى_المحلي']).sum() / total_services_cost if total_services_cost > 0 else 0 + + st.markdown(f""" + **إجمالي تكلفة الخدمات**: {total_services_cost:,.2f} ريال + + **متوسط نسبة المحتوى المحلي للخدمات**: {avg_local_content*100:.2f}% + + **المستهدف**: 60% + + **الحالة**: {"✅ ملتزم" if avg_local_content >= 0.6 else "❌ غير ملتزم"} + """) - for category in categories: - if category == 'price' or category == 'delivery_time': - # للسعر ووقت التسليم، القيمة الأقل أفضل - min_val = min(item[category] for item in competitors_data) - max_val = max(item[category] for item in competitors_data) - normalized_data[category] = [(max_val - item[category]) / (max_val - min_val) if max_val != min_val else 0.5 for item in competitors_data] - else: - # للجودة والخبرة والمحتوى المحلي، القيمة الأعلى أفضل - min_val = min(item[category] for item in competitors_data) - max_val = max(item[category] for item in competitors_data) - normalized_data[category] = [(item[category] - min_val) / (max_val - min_val) if max_val != min_val else 0.5 for item in competitors_data] - - # إنشاء الرسم البياني الراداري - fig = go.Figure() - - for i, competitor in enumerate(competitors_data): - fig.add_trace(go.Scatterpolar( - r=[normalized_data[category][i] for category in categories], - theta=['السعر', 'الجودة', 'وقت التسليم', 'الخبرة', 'المحتوى المحلي'], - fill='toself', - name=competitor['name'] - )) + with lc_tabs[2]: # القوى العاملة + st.markdown("#### بيانات القوى العاملة") + + # إنشاء بيانات افتراضية للقوى العاملة إذا لم تكن موجودة + if 'local_content_labor' not in st.session_state: + st.session_state.local_content_labor = pd.DataFrame({ + 'فئة_العمالة': [ + "مهندسون", + "فنيون", + "عمال بناء", + "إداريون", + "مشرفون" + ], + 'العدد': [5, 10, 30, 3, 4], + 'الراتب_الشهري': [15000, 8000, 3000, 10000, 12000], + 'المدة_بالأشهر': [12, 12, 12, 12, 12], + 'نسبة_المحتوى_المحلي': [0.75, 0.65, 0.60, 0.90, 0.80] + }) + + # حساب التكلفة الإجمالية + st.session_state.local_content_labor['التكلفة_الإجمالية'] = st.session_state.local_content_labor['العدد'] * st.session_state.local_content_labor['الراتب_الشهري'] * st.session_state.local_content_labor['المدة_بالأشهر'] + + # نموذج إضافة فئة عمالة جديدة + st.markdown("#### إضافة فئة عمالة جديدة") + col1, col2 = st.columns(2) + + with col1: + new_labor_category = st.text_input("فئة العمالة", key="new_labor_category", value="") + new_labor_count = st.number_input("العدد", key="new_labor_count", min_value=0, value=0) + + with col2: + new_labor_salary = st.number_input("الراتب الشهري", key="new_labor_salary", min_value=0, value=0) + new_labor_months = st.number_input("المدة بالأشهر", key="new_labor_months", min_value=1, value=12) + + new_labor_local_content = st.slider("نسبة المحتوى المحلي", key="new_labor_local_content", min_value=0.0, max_value=1.0, value=0.8, step=0.01, format="%.2f") + + if st.button("إضافة فئة العمالة"): + if new_labor_category: + # حساب التكلفة الإجمالية + total_cost = new_labor_count * new_labor_salary * new_labor_months + + # إضافة فئة عمالة جديدة للجدول + new_labor = pd.DataFrame({ + 'فئة_العمالة': [new_labor_category], + 'العدد': [new_labor_count], + 'الراتب_الشهري': [new_labor_salary], + 'المدة_بالأشهر': [new_labor_months], + 'نسبة_المحتوى_المحلي': [new_labor_local_content], + 'التكلفة_الإجمالية': [total_cost] + }) + + # إضافة فئة العمالة الجديدة للجدول الحالي + st.session_state.local_content_labor = pd.concat([st.session_state.local_content_labor, new_labor], ignore_index=True) + st.success(f"تم إضافة فئة العمالة {new_labor_category} بنجاح!") + else: + st.warning("يرجى إدخال اسم فئة العمالة") + + # عرض جدول القوى العاملة مع إمكانية التعديل + st.markdown("#### جدول القوى العاملة") + edited_labor = st.data_editor( + st.session_state.local_content_labor, + use_container_width=True, + hide_index=True, + key="labor_editor" + ) + + # إعادة حساب التكلفة الإجمالية بعد التعديل + edited_labor['التكلفة_الإجمالية'] = edited_labor['العدد'] * edited_labor['الراتب_الشهري'] * edited_labor['المدة_بالأشهر'] + st.session_state.local_content_labor = edited_labor + + # زر لحذف فئات العمالة المحددة + if st.button("حذف فئات العمالة المحددة"): + st.session_state.local_content_labor = pd.DataFrame({ + 'فئة_العمالة': [], + 'العدد': [], + 'الراتب_الشهري': [], + 'المدة_بالأشهر': [], + 'نسبة_المحتوى_المحلي': [], + 'التكلفة_الإجمالية': [] + }) + st.success("تم حذف جميع فئات العمالة!") + st.rerun() + + # عرض ملخص القوى العاملة + total_labor_cost = edited_labor['التكلفة_الإجمالية'].sum() + avg_local_content = (edited_labor['التكلفة_الإجمالية'] * edited_labor['نسبة_المحتوى_المحلي']).sum() / total_labor_cost if total_labor_cost > 0 else 0 + + st.markdown(f""" + **إجمالي تكلفة القوى العاملة**: {total_labor_cost:,.2f} ريال + + **متوسط نسبة المحتوى المحلي للقوى العاملة**: {avg_local_content*100:.2f}% + + **المستهدف**: 80% + + **الحالة**: {"✅ ملتزم" if avg_local_content >= 0.8 else "❌ غير ملتزم"} + """) - fig.update_layout( - polar=dict( - radialaxis=dict( - visible=True, - range=[0, 1] + with lc_tabs[3]: # التحليل + st.markdown("#### تحليل المحتوى المحلي") + + # حساب المحتوى المحلي الإجمالي + try: + # تجميع بيانات تحليل المحتوى المحلي + products_cost = st.session_state.local_content_products['التكلفة_الإجمالية'].sum() + products_local_content = (st.session_state.local_content_products['التكلفة_الإجمالية'] * st.session_state.local_content_products['نسبة_المحتوى_المحلي']).sum() / products_cost if products_cost > 0 else 0 + + services_cost = st.session_state.local_content_services['التكلفة'].sum() + services_local_content = (st.session_state.local_content_services['التكلفة'] * st.session_state.local_content_services['نسبة_المحتوى_المحلي']).sum() / services_cost if services_cost > 0 else 0 + + labor_cost = st.session_state.local_content_labor['التكلفة_الإجمالية'].sum() + labor_local_content = (st.session_state.local_content_labor['التكلفة_الإجمالية'] * st.session_state.local_content_labor['نسبة_المحتوى_المحلي']).sum() / labor_cost if labor_cost > 0 else 0 + + # حساب الوزن النسبي لكل مكون + total_cost = products_cost + services_cost + labor_cost + products_weight = products_cost / total_cost if total_cost > 0 else 0 + services_weight = services_cost / total_cost if total_cost > 0 else 0 + labor_weight = labor_cost / total_cost if total_cost > 0 else 0 + + # حساب المحتوى المحلي الإجمالي + total_local_content = (products_local_content * products_weight) + (services_local_content * services_weight) + (labor_local_content * labor_weight) + + # عرض ملخص المحتوى المحلي + st.markdown("### ملخص المحتوى المحلي") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التكاليف", f"{total_cost:,.2f} ريال") + + with col2: + st.metric("نسبة المحتوى المحلي الإجمالية", f"{total_local_content*100:.2f}%") + + with col3: + target_local_content = 0.7 # 70% + st.metric("الحالة", "ملتزم" if total_local_content >= target_local_content else "غير ملتزم", delta=f"{(total_local_content - target_local_content)*100:.2f}%") + + # عرض رسم بياني للمقارنة + st.markdown("### تحليل بصري للمحتوى المحلي") + + # رسم بياني شريطي لنسب المحتوى المحلي + categories = ['المنتجات', 'الخدمات', 'القوى العاملة', 'الإجمالي'] + actual_values = [products_local_content * 100, services_local_content * 100, labor_local_content * 100, total_local_content * 100] + target_values = [70, 60, 80, 70] # المستهدفات + + # تهيئة البيانات للرسم البياني + chart_data = pd.DataFrame({ + 'الفئة': categories, + 'النسبة الفعلية': actual_values, + 'النسبة المستهدفة': target_values + }) + + # رسم بياني شريطي للمقارنة + fig = go.Figure() + + fig.add_trace(go.Bar( + x=chart_data['الفئة'], + y=chart_data['النسبة الفعلية'], + name='النسبة الفعلية', + marker_color='rgb(26, 118, 255)' + )) + + fig.add_trace(go.Bar( + x=chart_data['الفئة'], + y=chart_data['النسبة المستهدفة'], + name='النسبة المستهدفة', + marker_color='rgb(55, 83, 109)' + )) + + fig.update_layout( + title='مقارنة بين النسب الفعلية والمستهدفة للمحتوى المحلي', + xaxis_tickfont_size=14, + yaxis=dict( + title='النسبة %', + titlefont_size=16, + tickfont_size=14, + ), + legend=dict( + x=0, + y=1.0, + bgcolor='rgba(255, 255, 255, 0)', + bordercolor='rgba(255, 255, 255, 0)' + ), + barmode='group', + bargap=0.15, + bargroupgap=0.1 ) - ), - title='مقارنة متعددة الأبعاد بين المنافسين', - showlegend=True - ) - - st.plotly_chart(fig, use_container_width=True) - - def _render_pricing_summary_report(self): - """عرض نموذج تقرير ملخص التسعير""" - - st.markdown("### تقرير ملخص التسعير") - st.markdown("**تاريخ التقرير:** " + time.strftime("%Y-%m-%d")) - st.markdown("**اسم المشروع:** مشروع إنشاء مبنى إداري") - st.markdown("**رقم المناقصة:** T-2024-001") - - # عرض ملخص التسعير - st.markdown("#### ملخص التسعير") - - # حساب إجمالي التكاليف - total_direct_cost = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'تكاليف مباشرة') - total_indirect_cost = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'تكاليف غير مباشرة') - total_profit = sum(item['amount'] for item in st.session_state.cost_analysis if item['category'] == 'أرباح') - total_cost = total_direct_cost + total_indirect_cost - total_price = total_cost + total_profit - - # إنشاء جدول ملخص - summary_data = { - 'البند': ['التكاليف المباشرة', 'التكاليف غير المباشرة', 'إجمالي التكاليف', 'هامش الربح', 'السعر الإجمالي'], - 'المبلغ (ريال)': [total_direct_cost, total_indirect_cost, total_cost, total_profit, total_price], - 'النسبة (%)': [ - (total_direct_cost / total_price) * 100, - (total_indirect_cost / total_price) * 100, - (total_cost / total_price) * 100, - (total_profit / total_price) * 100, - 100.0 - ] - } - - summary_df = pd.DataFrame(summary_data) - st.dataframe(summary_df, use_container_width=True, hide_index=True) - - # عرض توزيع التكاليف - st.markdown("#### توزيع التكاليف") - - # إنشاء DataFrame للرسم البياني - cost_distribution_df = pd.DataFrame({ - 'البند': ['التكاليف المباشرة', 'التكاليف غير المباشرة', 'هامش الربح'], - 'المبلغ': [total_direct_cost, total_indirect_cost, total_profit] - }) + + st.plotly_chart(fig, use_container_width=True) + + # عرض توصيات لتحسين نسبة المحتوى المحلي + st.markdown("### توصيات لتحسين نسبة المحتوى المحلي") + + recommendations = [] + + if products_local_content < 0.7: + recommendations.append("- زيادة نسبة المحتوى المحلي للمنتجات من خلال:") + recommendations.append(" - البحث عن موردين محليين للمنتجات ذات النسبة المنخفضة") + recommendations.append(" - استبدال المنتجات المستوردة ببدائل محلية") + recommendations.append(" - التعاون مع المصانع المحلية لتوطين صناعة المنتجات") + + if services_local_content < 0.6: + recommendations.append("- زيادة نسبة المحتوى المحلي للخدمات من خلال:") + recommendations.append(" - التعاقد مع شركات خدمات محلية") + recommendations.append(" - تحويل الخدمات المستعان بها من الخارج إلى شركات محلية") + recommendations.append(" - تأهيل الشركات المحلية لتقديم الخدمات المطلوبة") + + if labor_local_content < 0.8: + recommendations.append("- زيادة نسبة المحتوى المحلي للقوى العاملة من خلال:") + recommendations.append(" - زيادة توظيف الكوادر المحلية") + recommendations.append(" - تدريب وتأهيل العمالة المحلية") + recommendations.append(" - استبدال العمالة الأجنبية بكوادر محلية تدريجياً") + + if total_local_content < 0.7: + recommendations.append("- زيادة نسبة المحتوى المحلي الإجمالية من خلال:") + recommendations.append(" - إعادة توزيع الميزانية لصالح المكونات ذات النسبة العالية من المحتوى المحلي") + recommendations.append(" - وضع خطة مرحلية لزيادة المحتوى المحلي") + recommendations.append(" - التعاون مع اللجنة المحلية لزيادة المحتوى المحلي") + + if recommendations: + for rec in recommendations: + st.markdown(rec) + else: + st.success("تهانينا! نسبة المحتوى المحلي متوافقة مع المتطلبات.") + + # حساب تأثير المحتوى المحلي على التسعير + st.markdown("### تأثير المحتوى المحلي على التسعير") + + # تحديد عامل تعديل السعر بناءً على نسبة المحتوى المحلي + price_adjustment_factor = 1.0 + + if total_local_content >= 0.9: + price_adjustment_factor = 0.92 # خصم 8% للمحتوى المحلي العالي جداً + price_discount = "8%" + elif total_local_content >= 0.8: + price_adjustment_factor = 0.94 # خصم 6% للمحتوى المحلي العالي + price_discount = "6%" + elif total_local_content >= 0.7: + price_adjustment_factor = 0.96 # خصم 4% للمحتوى المحلي المتوسط + price_discount = "4%" + elif total_local_content >= 0.6: + price_adjustment_factor = 0.98 # خصم 2% للمحتوى المحلي المنخفض + price_discount = "2%" + else: + price_adjustment_factor = 1.0 # لا خصم + price_discount = "0%" + + # عرض تأثير المحتوى المحلي على التسعير + original_total = st.session_state.current_pricing['items']['الإجمالي'].sum() + adjusted_total = original_total * price_adjustment_factor + discount_amount = original_total - adjusted_total + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التسعير الأصلي", f"{original_total:,.2f} ريال") + + with col2: + st.metric("نسبة الخصم بسبب المحتوى المحلي", price_discount) + + with col3: + st.metric("إجمالي التسعير بعد الخصم", f"{adjusted_total:,.2f} ريال", delta=f"-{discount_amount:,.2f} ريال") + + # أزرار العمليات + col1, col2 = st.columns(2) + + with col1: + if st.button("حفظ تحليل المحتوى المحلي"): + # حفظ بيانات المحتوى المحلي في التسعير الحالي + st.session_state.current_pricing['local_content'] = { + 'products': st.session_state.local_content_products.copy(), + 'services': st.session_state.local_content_services.copy(), + 'labor': st.session_state.local_content_labor.copy(), + 'total_local_content': total_local_content, + 'price_adjustment_factor': price_adjustment_factor + } + + st.success("تم حفظ تحليل المحتوى المحلي بنجاح!") + + with col2: + if st.button("تصدير تقرير المحتوى المحلي"): + st.success("تم تصدير تقرير المحتوى المحلي بنجاح!") + + except Exception as e: + st.error(f"حدث خطأ أثناء تحليل المحتوى المحلي: {str(e)}") + st.warning("تأكد من إدخال بيانات المحتوى المحلي بشكل صحيح في التبويبات السابقة.") + + def _render_utilities_tab(self): + """عرض تبويب الأدوات المساعدة""" + import json + import copy + from datetime import datetime + + st.markdown("## الأدوات المساعدة") + + utilities_tab1, utilities_tab2, utilities_tab3, utilities_tab4, utilities_tab5 = st.tabs([ + "الرسوم البيانية المتقدمة", + "استيراد/تصدير الإعدادات", + "النسخ الاحتياطي والاستعادة", + "مقارنة نماذج التسعير", + "إنشاء التقارير" + ]) - fig = px.pie( - cost_distribution_df, - values='المبلغ', - names='البند', - title='توزيع التكاليف والأرباح', - color_discrete_sequence=px.colors.qualitative.Set3 - ) + with utilities_tab1: + st.markdown("### الرسوم البيانية المتقدمة لتحليل التكلفة") + + if 'item_cost_result' in st.session_state: + result = st.session_state.item_cost_result + + # تبويب الرسوم البيانية + chart_tab1, chart_tab2, chart_tab3 = st.tabs(["توزيع التكلفة", "مقارنة المكونات", "تأثير العوامل"]) + + with chart_tab1: + # رسم بياني دائري لتوزيع التكلفة + fig = go.Figure(data=[go.Pie( + labels=["المواد", "العمالة", "المعدات", "المصاريف الإدارية", "هامش الربح"], + values=[ + result['تكاليف_مباشرة']['المواد']['الإجمالي'], + result['تكاليف_مباشرة']['العمالة']['الإجمالي'], + result['تكاليف_مباشرة']['المعدات']['الإجمالي'], + result['مصاريف_إدارية']['قيمة'], + result['هامش_ربح']['قيمة'] + ], + hole=.3, + marker_colors=['#36a2eb', '#ff6384', '#4bc0c0', '#ffcd56', '#9966ff'] + )]) + fig.update_layout(title_text="توزيع مكونات التكلفة") + st.plotly_chart(fig, use_container_width=True) + + with chart_tab2: + # رسم بياني شريطي للمكونات الرئيسية + categories = ["المواد", "العمالة", "المعدات"] + values = [ + result['تكاليف_مباشرة']['المواد']['الإجمالي'], + result['تكاليف_مباشرة']['العمالة']['الإجمالي'], + result['تكاليف_مباشرة']['المعدات']['الإجمالي'] + ] + + fig = go.Figure(data=[go.Bar(x=categories, y=values, marker_color=['#36a2eb', '#ff6384', '#4bc0c0'])]) + fig.update_layout(title_text="مقارنة بين المكونات الرئيسية للتكلفة") + st.plotly_chart(fig, use_container_width=True) + + with chart_tab3: + # مخطط شريطي يوضح تأثير عوامل التعديل على التكلفة + original_cost = result['التكلفة_الإجمالية'] + final_cost = result['السعر_المعدل']['إجمالي'] + + # التحقق من وجود العوامل، وإضافتها بقيم افتراضية إذا كانت غير موجودة + if 'عوامل_التعديل' not in result: + result['عوامل_التعديل'] = {} + + # إضافة المفاتيح الناقصة بقيم افتراضية + default_factors = { + 'location_factor': 1.0, + 'time_factor': 1.0, + 'risk_factor': 1.0, + 'market_factor': 1.0 + } + + for key, default_value in default_factors.items(): + if key not in result['عوامل_التعديل']: + result['عوامل_التعديل'][key] = default_value + + factors = { + "معامل الموقع": result['عوامل_التعديل']['location_factor'], + "معامل الوقت": result['عوامل_التعديل']['time_factor'], + "معامل المخاطر": result['عوامل_التعديل']['risk_factor'], + "معامل السوق": result['عوامل_التعديل']['market_factor'] + } + + # حساب القيمة المضافة من كل عامل + factor_effects = {} + for factor_name, factor_value in factors.items(): + factor_effects[factor_name] = original_cost * (factor_value - 1) + + fig = go.Figure() + fig.add_trace(go.Waterfall( + name="تأثير العوامل", + orientation="v", + measure=["absolute"] + ["relative"] * len(factor_effects) + ["total"], + x=["التكلفة الأصلية"] + list(factor_effects.keys()) + ["التكلفة النهائية"], + y=[original_cost] + list(factor_effects.values()) + [0], + text=[f"{original_cost:,.2f}"] + [f"{val:,.2f}" for val in factor_effects.values()] + [f"{final_cost:,.2f}"], + connector={"line": {"color": "rgb(63, 63, 63)"}}, + )) + + fig.update_layout(title_text="تأثير عوامل التعديل على التكلفة النهائية") + st.plotly_chart(fig, use_container_width=True) + else: + st.warning("لم يتم العثور على بيانات تحليل التكلفة. يرجى إجراء تحليل تكلفة في تبويب 'تحليل سعر البند' أولاً.") - st.plotly_chart(fig, use_container_width=True) + with utilities_tab2: + st.markdown("### استيراد/تصدير إعدادات التسعير") + + export_col, import_col = st.columns(2) + + with export_col: + st.markdown("#### تصدير الإعدادات الحالية") + if st.button("تصدير إعدادات التسعير", key="export_pricing_settings"): + pricing_settings = { + "construction_item": st.session_state.construction_item if 'construction_item' in st.session_state else None, + "current_pricing": st.session_state.current_pricing if 'current_pricing' in st.session_state else None + } + settings_json = json.dumps(pricing_settings, ensure_ascii=False, indent=2) + st.download_button( + label="تنزيل إعدادات التسعير", + data=settings_json, + file_name="pricing_settings.json", + mime="application/json", + key="download_settings_button" + ) + + with import_col: + st.markdown("#### استيراد إعدادات سابقة") + uploaded_file = st.file_uploader("استيراد إعدادات تسعير سابقة", type=["json"], key="upload_pricing_settings") + if uploaded_file is not None: + try: + settings_data = json.loads(uploaded_file.getvalue().decode('utf-8')) + # تحديث بيانات التسعير في الجلسة + if 'construction_item' in settings_data and settings_data['construction_item']: + st.session_state.construction_item = settings_data['construction_item'] + if 'current_pricing' in settings_data and settings_data['current_pricing']: + st.session_state.current_pricing = settings_data['current_pricing'] + st.success("تم استيراد الإعدادات بنجاح!") + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء استيراد الإعدادات: {str(e)}") + + with utilities_tab3: + st.markdown("### النسخ الاحتياطي والاستعادة") + backup_tab1, backup_tab2 = st.tabs(["إنشاء نسخة احتياطية", "استعادة من نسخة احتياطية"]) + + with backup_tab1: + st.markdown("#### إنشاء نسخة احتياطية كاملة") + st.markdown("تقوم هذه العملية بإنشاء نسخة احتياطية كاملة لجميع بيانات التسعير الحالية.") + + if st.button("إنشاء نسخة احتياطية كاملة", key="create_full_backup"): + backup_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "construction_item": st.session_state.construction_item if 'construction_item' in st.session_state else None, + "current_pricing": st.session_state.current_pricing if 'current_pricing' in st.session_state else None, + "pricing_models": st.session_state.pricing_models if 'pricing_models' in st.session_state else [], + "manual_items": st.session_state.manual_items.to_dict('records') if 'manual_items' in st.session_state else [] + } + + backup_json = json.dumps(backup_data, ensure_ascii=False, indent=2) + + filename = f"wahbi_pricing_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + st.download_button( + label="تنزيل النسخة الاحتياطية", + data=backup_json, + file_name=filename, + mime="application/json", + key="download_backup_button" + ) + st.success("تم إنشاء النسخة الاحتياطية بنجاح!") + + with backup_tab2: + st.markdown("#### استعادة من نسخة احتياطية") + st.markdown("يمكنك استعادة بيانات التسعير من نسخة احتياطية سابقة.") + + backup_file = st.file_uploader("استعادة من نسخة احتياطية", type=["json"], key="restore_backup_file") + if backup_file is not None: + if st.button("استعادة البيانات", key="restore_from_backup"): + try: + backup_data = json.loads(backup_file.getvalue().decode('utf-8')) + + # استعادة البيانات إلى الحالة الحالية + if 'construction_item' in backup_data and backup_data['construction_item']: + st.session_state.construction_item = backup_data['construction_item'] + + if 'current_pricing' in backup_data and backup_data['current_pricing']: + st.session_state.current_pricing = backup_data['current_pricing'] + + if 'pricing_models' in backup_data: + st.session_state.pricing_models = backup_data['pricing_models'] + + if 'manual_items' in backup_data and backup_data['manual_items']: + st.session_state.manual_items = pd.DataFrame(backup_data['manual_items']) + + st.success(f"تم استعادة البيانات من النسخة الاحتياطية بنجاح! (تاريخ النسخة: {backup_data.get('timestamp', 'غير معروف')})") + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء استعادة البيانات: {str(e)}") + + with utilities_tab4: + st.markdown("### مقارنة نماذج التسعير") + st.markdown("هذه الأداة تتيح لك مقارنة نماذج التسعير المختلفة واختيار الأفضل منها.") + + # تهيئة قائمة نماذج التسعير إذا لم تكن موجودة + if 'pricing_models' not in st.session_state: + st.session_state.pricing_models = [] + + # إضافة النموذج الحالي للمقارنة + if st.button("إضافة النموذج الحالي للمقارنة", key="add_current_model"): + if 'current_pricing' in st.session_state and st.session_state.current_pricing is not None: + model_name = st.session_state.current_pricing.get('name', 'نموذج بدون اسم') + model_data = copy.deepcopy(st.session_state.current_pricing) + # تحقق من عدم وجود نموذج بنفس الاسم + exists = False + for model in st.session_state.pricing_models: + if model.get('name') == model_name: + exists = True + break + + if not exists: + st.session_state.pricing_models.append(model_data) + st.success(f"تم إضافة نموذج '{model_name}' للمقارنة!") + else: + st.warning("يوجد نموذج بنفس الاسم في المقارنة بالفعل!") + else: + st.error("لا يوجد نموذج تسعير حالي للإضافة. يرجى إنشاء تسعير جديد أولاً.") + + # عرض جدول المقارنة إذا كان هناك نماذج مضافة + if len(st.session_state.pricing_models) > 0: + st.markdown("### جدول مقارنة نماذج التسعير") + + comparison_data = [] + for model in st.session_state.pricing_models: + # حساب إجمالي التكلفة + items_df = pd.DataFrame(model.get('items', {})) + total_price = 0 + if not items_df.empty and 'الإجمالي' in items_df.columns: + total_price = items_df['الإجمالي'].sum() + + comparison_data.append({ + "اسم النموذج": model.get('name', 'غير معروف'), + "طريقة التسعير": model.get('method', 'غير معروفة'), + "إجمالي التكلفة": f"{total_price:,.2f} ريال", + "عدد البنود": len(items_df) if not items_df.empty else 0, + }) + + comparison_df = pd.DataFrame(comparison_data) + st.dataframe(comparison_df, use_container_width=True, hide_index=True) + + # عرض رسم بياني للمقارنة + if len(comparison_data) > 1: + st.markdown("### رسم بياني للمقارنة") + + # استخراج البيانات للرسم البياني + models = [data["اسم النموذج"] for data in comparison_data] + values = [float(data["إجمالي التكلفة"].replace("ريال", "").replace(",", "")) for data in comparison_data] + + # رسم بياني شريطي + fig = go.Figure(data=[ + go.Bar(x=models, y=values, marker_color='rgb(26, 118, 255)') + ]) + + fig.update_layout( + title="مقارنة التكلفة الإجمالية بين نماذج التسعير", + xaxis_title="نموذج التسعير", + yaxis_title="التكلفة الإجمالية (ريال)" + ) + + st.plotly_chart(fig, use_container_width=True) + + # أزرار إدارة النماذج + col1, col2 = st.columns(2) + with col1: + if st.button("حذف جميع النماذج", key="clear_comparison"): + st.session_state.pricing_models = [] + st.success("تم مسح جميع نماذج المقارنة!") + st.rerun() + + with col2: + # تحديد نموذج للحذف + model_to_delete = st.selectbox( + "اختر نموذج للحذف من المقارنة", + options=[model.get('name', f"نموذج {i+1}") for i, model in enumerate(st.session_state.pricing_models)], + key="model_to_delete" + ) + + if st.button("حذف النموذج المحدد", key="delete_selected_model"): + for i, model in enumerate(st.session_state.pricing_models): + if model.get('name') == model_to_delete: + st.session_state.pricing_models.pop(i) + st.warning(f"تم حذف النموذج '{model_to_delete}'") + st.rerun() + break + else: + st.info("لا توجد نماذج للمقارنة حالياً. يرجى إضافة النموذج الحالي للمقارنة أولاً.") - # عرض ملخص السيناريو النشط - st.markdown("#### السيناريو النشط") - - active_scenario = next((item for item in st.session_state.price_scenarios if item['is_active']), None) - if active_scenario: - st.markdown(f"**اسم السيناريو:** {active_scenario['name']}") - st.markdown(f"**الوصف:** {active_scenario['description']}") - st.markdown(f"**إجمالي التكلفة:** {active_scenario['total_cost']:,.2f} ريال") - st.markdown(f"**هامش الربح:** {active_scenario['profit_margin']:.1f}%") - st.markdown(f"**السعر الإجمالي:** {active_scenario['total_price']:,.2f} ريال") - - # عرض توصيات التسعير - st.markdown("#### توصيات التسعير") - - st.markdown("- مراجعة هيكل التكاليف بشكل دوري للحفاظ على القدرة التنافسية.") - st.markdown("- التأكيد على جودة الخدمات في العروض التسويقية.") - st.markdown("- التأكيد على نسبة المحتوى المحلي العالية في العروض.") - st.markdown("- مراقبة أسعار المنافسين وتعديل الاستراتيجية التسعيرية عند الحاجة.") - st.markdown("- تحليل نقاط القوة والضعف بشكل مستمر لتحسين العروض المستقبلية.") + with utilities_tab5: + st.markdown("### إنشاء تقارير التسعير") + st.markdown("يمكنك استخدام هذه الأداة لإنشاء تقارير مفصلة عن التسعير.") + + report_type = st.selectbox( + "اختر نوع التقرير", + options=["تقرير ملخص", "تقرير تفصيلي", "تقرير المقارنة"], + key="report_type_selector" + ) + + if st.button("إنشاء التقرير", key="generate_report_button"): + if report_type == "تقرير ملخص" and 'current_pricing' in st.session_state and st.session_state.current_pricing is not None: + # إنشاء تقرير ملخص + if isinstance(st.session_state.current_pricing.get('items'), pd.DataFrame): + df = st.session_state.current_pricing['items'].copy() + + # حساب الإجماليات + total_price = df['الإجمالي'].sum() if 'الإجمالي' in df.columns else 0 + + # تقدير تكاليف المكونات + materials_cost = total_price * 0.6 # تقدير تقريبي للمواد + labor_cost = total_price * 0.25 # تقدير تقريبي للعمالة + equipment_cost = total_price * 0.15 # تقدير تقريبي للمعدات + admin_cost = total_price * 0.05 # تقدير تقريبي للمصاريف الإدارية + profit_margin = total_price * 0.1 # تقدير تقريبي لهامش الربح + final_total = total_price * 1.15 # إجمالي بعد إضافة المصاريف الإدارية وهامش الربح + + # إنشاء جدول الملخص + summary = pd.DataFrame({ + "بند التكلفة": ["إجمالي المواد", "إجمالي الأجور", "إجمالي المعدات", "المصاريف الإدارية", "هامش الربح", "الإجمالي النهائي"], + "القيمة": [ + materials_cost, + labor_cost, + equipment_cost, + admin_cost, + profit_margin, + final_total + ] + }) + + # تنسيق التقرير + styled_df = summary.style.format({ + "القيمة": "{:,.2f} ريال" + }) + + # تحويل الجدول إلى HTML + html = f""" + + + + تقرير ملخص التسعير + + + +
+

تقرير ملخص التسعير

+

{st.session_state.current_pricing.get('name', 'تسعير بدون اسم')}

+

تاريخ التقرير: {datetime.now().strftime('%Y-%m-%d %H:%M')}

+
+ +

ملخص التكاليف

+ {styled_df.to_html()} + +

البيانات الأساسية

+
    +
  • عدد البنود: {len(df)}
  • +
  • طريقة التسعير: {st.session_state.current_pricing.get('method', 'غير محددة')}
  • +
+ + + + + """ + + # تقديم زر التنزيل + st.download_button( + label="تنزيل التقرير الملخص", + data=html, + file_name="pricing_summary_report.html", + mime="text/html", + key="download_summary_report" + ) + + st.success("تم إنشاء التقرير الملخص بنجاح!") + else: + st.error("تعذر قراءة بيانات التسعير الحالي. يرجى التأكد من وجود تسعير صالح.") + + elif report_type == "تقرير تفصيلي" and 'current_pricing' in st.session_state and st.session_state.current_pricing is not None: + # سيتم تنفيذ التقرير التفصيلي + st.info("جاري إعداد التقرير التفصيلي...") + # هنا يمكن تنفيذ كود إنشاء التقرير التفصيلي + st.success("تم إنشاء التقرير التفصيلي. سيتم تطوير هذه الميزة قريباً.") + + elif report_type == "تقرير المقارنة" and 'pricing_models' in st.session_state and len(st.session_state.pricing_models) > 0: + # سيتم تنفيذ تقرير المقارنة + st.info("جاري إعداد تقرير المقارنة...") + # هنا يمكن تنفيذ كود إنشاء تقرير المقارنة + st.success("تم إنشاء تقرير المقارنة. سيتم تطوير هذه الميزة قريباً.") + + else: + st.error("لا توجد بيانات كافية لإنشاء التقرير المطلوب. يرجى التأكد من وجود تسعير أو نماذج مقارنة.") \ No newline at end of file diff --git a/modules/pricing/pricing_app.py.backup b/modules/pricing/pricing_app.py.backup new file mode 100644 index 0000000000000000000000000000000000000000..a3a67844c9b1aebbe11cb78b097f39389a221963 --- /dev/null +++ b/modules/pricing/pricing_app.py.backup @@ -0,0 +1,1242 @@ +""" +تطبيق وحدة التسعير المتكاملة +""" + +import streamlit as st +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import plotly.express as px +import plotly.graph_objects as go +from datetime import datetime +import random +import os +import time +import io + +from modules.pricing.services.standard_pricing import StandardPricing +from modules.pricing.services.unbalanced_pricing import UnbalancedPricing +from modules.pricing.services.local_content_calculator import LocalContentCalculator +from modules.pricing.services.price_prediction import PricePrediction +from utils.excel_handler import export_to_excel +from utils.helpers import format_number, format_currency + + +class PricingApp: + """وحدة التسعير المتكاملة""" + + def __init__(self): + """تهيئة وحدة التسعير المتكاملة""" + self.pricing_methods = [ + "التسعير القياسي", + "التسعير غير المتزن", + "التسعير التنافسي", + "التسعير الموجه بالربحية" + ] + + # تهيئة خدمات التسعير + self.standard_pricing = StandardPricing() + self.unbalanced_pricing = UnbalancedPricing() + self.local_content = LocalContentCalculator() + self.price_prediction = PricePrediction() + + def render(self): + """عرض واجهة وحدة التسعير""" + + st.markdown("

وحدة التسعير المتكاملة

", unsafe_allow_html=True) + + tabs = st.tabs([ + "إنشاء تسعير جديد", + "تحليل سعر البند", + "نموذج التسعير الشامل", + "التسعير غير المتزن", + "المحتوى المحلي" + ]) + + with tabs[0]: + self._render_new_pricing_tab() + + with tabs[1]: + self._render_item_analysis_tab() + + with tabs[2]: + self._render_comprehensive_pricing_tab() + + with tabs[3]: + self._render_unbalanced_pricing_tab() + + with tabs[4]: + self._render_local_content_tab() + + def _render_item_analysis_tab(self): + """عرض تبويب تحليل سعر البند""" + + st.markdown("### تحليل سعر البند") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # اختيار البند للتحليل + if 'current_pricing' in st.session_state and st.session_state.current_pricing is not None: + items = st.session_state.current_pricing['items'] + item_options = items['رقم البند'].tolist() + selected_item = st.selectbox("اختر البند للتحليل", item_options) + + if selected_item: + item_data = items[items['رقم البند'] == selected_item].iloc[0] + + st.markdown(f"### تحليل البند: {selected_item}") + st.markdown(f"**وصف البند**: {item_data['وصف البند']}") + st.markdown(f"**الوحدة**: {item_data['الوحدة']}") + st.markdown(f"**الكمية**: {item_data['الكمية']}") + st.markdown(f"**سعر الوحدة**: {item_data['سعر الوحدة']:,.2f} ريال") + + # تحليل مكونات السعر + st.markdown("### تحليل مكونات السعر") + + # عناصر التكلفة الافتراضية + cost_components = { + 'المواد': 0.6, # 60% من التكلفة + 'العمالة': 0.25, # 25% من التكلفة + 'المعدات': 0.1, # 10% من التكلفة + 'نفقات عامة': 0.05 # 5% من التكلفة + } + + # حساب تكلفة كل عنصر + unit_price = item_data['سعر الوحدة'] + component_values = {k: v * unit_price for k, v in cost_components.items()} + + # عرض مكونات التكلفة في جدول + components_df = pd.DataFrame({ + 'العنصر': component_values.keys(), + 'نسبة من التكلفة': [f"{v*100:.1f}%" for v in cost_components.values()], + 'القيمة (ريال)': [f"{v:,.2f}" for v in component_values.values()] + }) + + st.table(components_df) + + # رسم بياني لمكونات التكلفة + fig = px.pie( + names=list(component_values.keys()), + values=list(component_values.values()), + title='توزيع مكونات التكلفة' + ) + + st.plotly_chart(fig) + + # تحليل تاريخي للأسعار + st.markdown("### تحليل تاريخي للأسعار") + + # بيانات تاريخية افتراضية + historical_data = { + 'التاريخ': ['2020-01', '2020-07', '2021-01', '2021-07', '2022-01', '2022-07', '2023-01', '2023-07'], + 'السعر': [ + unit_price * 0.7, + unit_price * 0.75, + unit_price * 0.8, + unit_price * 0.85, + unit_price * 0.9, + unit_price * 0.95, + unit_price, + unit_price * 1.05 + ] + } + + hist_df = pd.DataFrame(historical_data) + + # رسم بياني للتحليل التاريخي + fig = px.line( + hist_df, + x='التاريخ', + y='السعر', + title='تطور سعر الوحدة عبر الزمن', + markers=True + ) + + st.plotly_chart(fig) + + # المقارنة مع الأسعار المرجعية + st.markdown("### المقارنة مع الأسعار المرجعية") + + # بيانات مرجعية افتراضية + reference_data = { + 'المصدر': ['قاعدة البيانات الداخلية', 'دليل الأسعار الاسترشادي', 'متوسط أسعار السوق', 'أسعار المشاريع المماثلة'], + 'السعر المرجعي': [ + unit_price * 0.95, + unit_price * 1.05, + unit_price * 1.1, + unit_price * 0.9 + ] + } + + ref_df = pd.DataFrame(reference_data) + ref_df['الفرق عن السعر الحالي'] = ref_df['السعر المرجعي'] - unit_price + ref_df['نسبة الفرق'] = (ref_df['الفرق عن السعر الحالي'] / unit_price * 100).round(2).astype(str) + '%' + + st.table(ref_df) + + def _render_new_pricing_tab(self): + """عرض تبويب إنشاء تسعير جديد""" + + st.markdown("### إنشاء تسعير جديد") + + col1, col2 = st.columns(2) + + with col1: + tender_name = st.text_input("اسم المناقصة") + client = st.text_input("الجهة المالكة") + pricing_method = st.selectbox("طريقة التسعير", self.pricing_methods) + + with col2: + tender_number = st.text_input("رقم المناقصة") + location = st.text_input("الموقع") + submission_date = st.date_input("تاريخ التقديم") + + # خيارات بيانات البنود + st.markdown("### بيانات البنود") + + data_source = st.radio( + "مصدر بيانات البنود", + ["إدخال يدوي", "استيراد من Excel", "استيراد من وحدة تحليل المستندات"] + ) + + if data_source == "إدخال يدوي": + # ضبط CSS لتحسين ظهور الواجهة العربية + st.markdown(""" + + """, unsafe_allow_html=True) + + # تهيئة قائمة الوحدات المتاحة + unit_options = ["م3", "م2", "طن", "متر طولي", "قطعة", "كجم", "لتر"] + + # إنشاء بيانات افتراضية إذا لم تكن موجودة + if 'manual_items' not in st.session_state: + # إنشاء DataFrame فارغ + manual_items = pd.DataFrame(columns=[ + 'رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'سعر الوحدة', 'الإجمالي' + ]) + + # إضافة بضعة صفوف افتراضية + default_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0] + }) + + manual_items = pd.concat([manual_items, default_items]) + st.session_state.manual_items = manual_items + + # عرض واجهة إدخال البنود + st.markdown("### إدخال تفاصيل البنود") + + # التحقق من استخدام طريقة الإدخال البسيطة + use_simple_input = st.checkbox("استخدام طريقة الإدخال البسيطة", value=True) + + if use_simple_input: + # عرض البنود الحالية كجدول للعرض فقط + st.markdown("### جدول البنود الحالية") + st.dataframe(st.session_state.manual_items, use_container_width=True, hide_index=True) + + # إضافة بند جديد + st.markdown("### إضافة بند جديد") + col1, col2 = st.columns(2) + + with col1: + new_id = st.text_input("رقم البند", value=f"A{len(st.session_state.manual_items)+1}") + new_desc = st.text_area("وصف البند", value="") + + with col2: + new_unit = st.selectbox("الوحدة", options=unit_options) + new_qty = st.number_input("الكمية", value=0.0, min_value=0.0, format="%.2f") + new_price = st.number_input("سعر الوحدة", value=0.0, min_value=0.0, format="%.2f") + + new_total = new_qty * new_price + st.info(f"إجمالي البند الجديد: {new_total:,.2f} ريال") + + if st.button("إضافة البند"): + # التحقق من صحة البيانات + if new_id and new_desc and new_qty > 0: + # إنشاء صف جديد + new_row = pd.DataFrame({ + 'رقم البند': [new_id], + 'وصف البند': [new_desc], + 'الوحدة': [new_unit], + 'الكمية': [float(new_qty)], + 'سعر الوحدة': [float(new_price)], + 'الإجمالي': [float(new_total)] + }) + + # إضافة الصف إلى DataFrame + st.session_state.manual_items = pd.concat([st.session_state.manual_items, new_row], ignore_index=True) + st.success("تم إضافة البند بنجاح!") + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة: رقم البند، الوصف، والكمية يجب أن تكون أكبر من صفر.") + + # تعديل البنود الحالية + st.markdown("### تعديل البنود الحالية") + + # تحديد البند المراد تعديله + item_to_edit = st.selectbox( + "اختر البند للتعديل", + options=st.session_state.manual_items['رقم البند'].tolist(), + format_func=lambda x: f"{x}: {st.session_state.manual_items[st.session_state.manual_items['رقم البند'] == x]['وصف البند'].values[0][:30]}..." + ) + + if item_to_edit: + # الحصول على مؤشر الصف للبند المحدد + idx = st.session_state.manual_items[st.session_state.manual_items['رقم البند'] == item_to_edit].index[0] + row = st.session_state.manual_items.loc[idx] + + # إنشاء نموذج تعديل + col1, col2 = st.columns(2) + + with col1: + edited_id = st.text_input("رقم البند (تعديل)", value=row['رقم البند'], key="edit_id") + edited_desc = st.text_area("وصف البند (تعديل)", value=row['وصف البند'], key="edit_desc") + + with col2: + edited_unit = st.selectbox( + "الوحدة (تعديل)", + options=unit_options, + index=unit_options.index(row['الوحدة']) if row['الوحدة'] in unit_options else 0, + key="edit_unit" + ) + edited_qty = st.number_input("الكمية (تعديل)", value=float(row['الكمية']), min_value=0.0, format="%.2f", key="edit_qty") + edited_price = st.number_input("سعر الوحدة (تعديل)", value=float(row['سعر الوحدة']), min_value=0.0, format="%.2f", key="edit_price") + + edited_total = edited_qty * edited_price + st.info(f"إجمالي البند بعد التعديل: {edited_total:,.2f} ريال") + + col1, col2 = st.columns(2) + with col1: + if st.button("حفظ التعديلات"): + # تحديث البند + st.session_state.manual_items.at[idx, 'رقم البند'] = edited_id + st.session_state.manual_items.at[idx, 'وصف البند'] = edited_desc + st.session_state.manual_items.at[idx, 'الوحدة'] = edited_unit + st.session_state.manual_items.at[idx, 'الكمية'] = edited_qty + st.session_state.manual_items.at[idx, 'سعر الوحدة'] = edited_price + st.session_state.manual_items.at[idx, 'الإجمالي'] = edited_total + + st.success("تم تحديث البند بنجاح!") + st.rerun() + + with col2: + if st.button("حذف هذا البند"): + st.session_state.manual_items = st.session_state.manual_items.drop(idx).reset_index(drop=True) + st.warning("تم حذف البند!") + st.rerun() + + # المجموع الكلي + total = st.session_state.manual_items['الإجمالي'].sum() + st.metric("المجموع الكلي", f"{total:,.2f} ريال") + + # جعل هذه البيانات متاحة للاستخدام في الخطوات التالية + edited_items = st.session_state.manual_items.copy() + + else: + # عرض رسالة توضح أن طريقة الإدخال البسيطة هي الأفضل + st.warning("لتجنب مشاكل عدم التوافق في أنواع البيانات، يُفضل استخدام طريقة الإدخال البسيطة.") + + # محاولة استخدام المحرر القياسي مع معالجة الأخطاء + try: + # تحويل البيانات إلى الأنواع المناسبة + for col in st.session_state.manual_items.columns: + if col in ['رقم البند', 'وصف البند', 'الوحدة']: + st.session_state.manual_items[col] = st.session_state.manual_items[col].astype(str) + + # عرض المحرر (للقراءة فقط) + st.dataframe(st.session_state.manual_items, use_container_width=True, hide_index=True) + + # إنشاء نظام تعديل منفصل + st.markdown("### تعديل أسعار الوحدات") + + for idx, row in st.session_state.manual_items.iterrows(): + col1, col2 = st.columns([3, 1]) + + with col1: + st.text(f"{row['رقم البند']}: {row['وصف البند'][:50]}") + + with col2: + price = st.number_input( + f"سعر الوحدة ({row['الوحدة']})", + value=float(row['سعر الوحدة']), + min_value=0.0, + key=f"price_{idx}" + ) + + # تحديث السعر والإجمالي + st.session_state.manual_items.at[idx, 'سعر الوحدة'] = price + st.session_state.manual_items.at[idx, 'الإجمالي'] = price * row['الكمية'] + + # المجموع الكلي + total = st.session_state.manual_items['الإجمالي'].sum() + st.metric("المجموع الكلي", f"{total:,.2f} ريال") + + # جعل هذه البيانات متاحة للاستخدام في الخطوات التالية + edited_items = st.session_state.manual_items.copy() + + except Exception as e: + st.error(f"حدث خطأ: {str(e)}") + st.info("يرجى استخدام طريقة الإدخال البسيطة لتجنب هذه المشكلة.") + + elif data_source == "استيراد من Excel": + uploaded_file = st.file_uploader("رفع ملف Excel", type=["xlsx", "xls"]) + + if uploaded_file is not None: + st.success("تم رفع الملف بنجاح") + # محاكاة قراءة الملف + st.markdown("### معاينة البيانات المستوردة") + + # إنشاء بيانات افتراضية + import_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5", "A6", "A7"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة", + "توريد وتركيب حديد التسليح للأعمدة", + "أعمال البلوك للجدران" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3", "طن", "م2"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0, 10.0, 400.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }) + + st.dataframe(import_items) + + if st.button("استيراد البيانات"): + st.session_state.manual_items = import_items.copy() + st.session_state.manual_items_modified = True + st.success("تم استيراد البيانات بنجاح!") + st.rerun() + + else: # استيراد من وحدة تحليل المستندات + available_documents = [ + "كراسة شروط مشروع توسعة مستشفى الملك فهد", + "جدول كميات صيانة محطات المياه", + "مخططات إنشاء مدرسة ثانوية" + ] + + selected_doc = st.selectbox("اختر المستند", available_documents) + + if st.button("استيراد البيانات من تحليل المستند"): + # محاكاة استيراد البيانات + with st.spinner("جاري استيراد البيانات..."): + time.sleep(2) + + # إنشاء بيانات افتراضية + doc_items = pd.DataFrame({ + 'رقم البند': ["A1", "A2", "A3", "A4", "A5", "A6", "A7"], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة", + "توريد وتركيب حديد التسليح للأعمدة", + "أعمال البلوك للجدران" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3", "طن", "م2"], + 'الكمية': [250.0, 25.0, 500.0, 300.0, 120.0, 10.0, 400.0], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }) + + st.session_state.manual_items = doc_items.copy() + st.success("تم استيراد البيانات من تحليل المستند بنجاح!") + st.dataframe(doc_items) + + # زر بدء التسعير + if st.button("بدء التسعير"): + # تحقق من صحة البيانات + if 'manual_items' in st.session_state and not st.session_state.manual_items.empty: + # التأكد من حساب الإجمالي قبل الحفظ + st.session_state.manual_items['الإجمالي'] = st.session_state.manual_items['الكمية'] * st.session_state.manual_items['سعر الوحدة'] + + # حفظ بيانات التسعير الحالي + st.session_state.current_pricing = { + 'name': tender_name, + 'number': tender_number, + 'client': client, + 'location': location, + 'method': pricing_method, + 'submission_date': submission_date, + 'items': st.session_state.manual_items.copy(), + 'status': 'جديد', + 'created_at': datetime.now() + } + + # الانتقال إلى تبويب نموذج التسعير الشامل + st.success("تم إنشاء التسعير بنجاح! يمكنك الانتقال إلى نموذج التسعير الشامل.") + else: + st.error("يرجى إدخال بيانات البنود أولاً.") + + def _render_comprehensive_pricing_tab(self): + """عرض تبويب نموذج التسعير الشامل""" + + st.markdown("### نموذج التسعير الشامل") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # عرض معلومات التسعير الحالي + pricing = st.session_state.current_pricing + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("اسم المناقصة", pricing['name']) + st.metric("الجهة المالكة", pricing['client']) + + with col2: + st.metric("رقم المناقصة", pricing['number']) + st.metric("تاريخ التقديم", pricing['submission_date'].strftime("%Y-%m-%d")) + + with col3: + st.metric("طريقة التسعير", pricing['method']) + st.metric("الموقع", pricing['location']) + + # عرض البنود والتسعير + st.markdown("### بنود التسعير") + + items = pricing['items'].copy() + + # إضافة أسعار الوحدة للمحاكاة + if 'سعر الوحدة' in items.columns and (items['سعر الوحدة'] == 0).all(): + items['سعر الوحدة'] = [ + round(random.uniform(1000, 3000), 2), # الخرسانة + round(random.uniform(5000, 7000), 2), # الحديد + round(random.uniform(100, 200), 2), # العزل + round(random.uniform(50, 100), 2), # الردم + round(random.uniform(1200, 3500), 2), # الخرسانة للأعمدة + ] + + if len(items) > 5: + for i in range(5, len(items)): + items.at[i, 'سعر الوحدة'] = round(random.uniform(500, 5000), 2) + + # حساب الإجمالي + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + # عرض البنود + st.dataframe(items, use_container_width=True, hide_index=True) + + + # ✅ التوصية الذكية باستخدام OpenAI + with st.expander("🔍 توليد توصية ذكية باستخدام AI"): + if st.button("🔍 توليد توصية ذكية باستخدام AI", use_container_width=True): + import openai + import os + + client = openai.OpenAI(api_key=os.environ.get("ai")) + + items_df = items.copy() + prompt = f"""قم بتحليل الجدول التالي للبنود في مشروع إنشاء، وقدم توصية ذكية لتحسين التسعير وضمان التوازن المالي. الجدول يحتوي على البنود، الكميات، الأسعار، والإجماليات:\n\n{items_df.to_string(index=False)}\n\nالتوصية:\n""" + + try: + with st.spinner("جاري توليد التوصية..."): + response = client.chat.completions.create( + model="gpt-4", + messages=[ + {"role": "system", "content": "أنت خبير في تسعير مشاريع البناء والبنية التحتية."}, + {"role": "user", "content": prompt} + ], + temperature=0.4, + max_tokens=500 + ) + + recommendation = response.choices[0].message.content + st.success("تم توليد التوصية بنجاح!") + st.markdown("#### التوصية الذكية:") + st.info(recommendation) + + except Exception as e: + st.error(f"حدث خطأ أثناء الاتصال بنموذج OpenAI: {e}") + st.info("يجب التأكد من تثبيت أحدث إصدار من مكتبة OpenAI: `pip install openai --upgrade`") + + # واجهة تعديل أسعار الوحدات + st.markdown("### تعديل أسعار الوحدات") + + # تقسيم البنود إلى مجموعتين للعرض + col1, col2 = st.columns(2) + half = len(items) // 2 + len(items) % 2 + + with col1: + for idx in range(half): + if idx < len(items): + row = items.iloc[idx] + price = st.number_input( + f"{row['رقم البند']}: {row['وصف البند'][:30]}... ({row['الوحدة']})", + value=float(row['سعر الوحدة']), + min_value=0.0, + key=f"price1_{idx}" + ) + items.at[idx, 'سعر الوحدة'] = price + items.at[idx, 'الإجمالي'] = price * items.at[idx, 'الكمية'] + + with col2: + for idx in range(half, len(items)): + row = items.iloc[idx] + price = st.number_input( + f"{row['رقم البند']}: {row['وصف البند'][:30]}... ({row['الوحدة']})", + value=float(row['سعر الوحدة']), + min_value=0.0, + key=f"price2_{idx}" + ) + items.at[idx, 'سعر الوحدة'] = price + items.at[idx, 'الإجمالي'] = price * items.at[idx, 'الكمية'] + + # حساب وعرض إجماليات التسعير + total_price = items['الإجمالي'].sum() + + st.markdown("### إجماليات التسعير") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التكاليف المباشرة", f"{total_price:,.2f} ريال") + + with col2: + overhead_percentage = st.slider("نسبة المصاريف العامة والأرباح (%)", 5, 30, 15) + overhead_value = total_price * overhead_percentage / 100 + st.metric("المصاريف العامة والأرباح", f"{overhead_value:,.2f} ريال") + + with col3: + grand_total = total_price + overhead_value + st.metric("الإجمالي النهائي", f"{grand_total:,.2f} ريال") + + # رسم بياني لتوزيع التكاليف + st.markdown("### تحليل التكاليف") + + # حساب النسب المئوية لكل بند + pie_data = items.copy() + pie_data['نسبة من إجمالي التكاليف'] = pie_data['الإجمالي'] / total_price * 100 + + fig = px.pie( + pie_data, + values='نسبة من إجمالي التكاليف', + names='وصف البند', + title='توزيع التكاليف حسب البنود', + hole=0.4 + ) + + st.plotly_chart(fig, use_container_width=True) + + # أزرار العمليات + col1, col2, col3 = st.columns(3) + + with col1: + if st.button("حفظ التسعير"): + # تحديث بيانات التسعير الحالي + st.session_state.current_pricing['items'] = items.copy() + st.success("تم حفظ التسعير بنجاح!") + + with col2: + if st.button("تصدير إلى Excel"): + st.success("تم تصدير التسعير إلى Excel بنجاح!") + + with col3: + if st.button("تحليل المخاطر المالية"): + st.success("تم إرسال الطلب إلى وحدة تحليل المخاطر!") + + def _render_unbalanced_pricing_tab(self): + """عرض تبويب التسعير غير المتزن""" + + st.markdown("### التسعير غير المتزن") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # شرح التسعير غير المتزن + with st.expander("ما هو التسعير غير المتزن؟", expanded=False): + st.markdown(""" + **التسعير غير المتزن** هو استراتيجية تسعير تقوم على توزيع التكاليف بين بنود المناقصة بشكل غير متساوٍ، مع الحفاظ على إجمالي قيمة العطاء. + + ### استراتيجيات التسعير غير المتزن: + + 1. **التحميل الأمامي (Front Loading)**: زيادة أسعار البنود المبكرة في المشروع للحصول على تدفق نقدي أفضل في بداية المشروع. + 2. **التحميل الخلفي (Back Loading)**: زيادة أسعار البنود المتأخرة في المشروع. + 3. **تحميل البنود المؤكدة**: زيادة أسعار البنود التي من المؤكد تنفيذها بالكميات المحددة. + 4. **تخفيض أسعار البنود المحتملة**: تخفيض أسعار البنود التي قد تزيد كمياتها أثناء التنفيذ. + + ### مزايا التسعير غير المتزن: + + - تحسين التدفق النقدي للمشروع. + - تعظيم الربحية في حالة التغييرات والأوامر التغييرية. + - زيادة فرص الفوز بالمناقصة. + + ### مخاطر التسعير غير المتزن: + + - قد يتم رفض العطاء إذا كان عدم التوازن واضحاً. + - قد تتأثر السمعة سلباً إذا تم استخدامه بشكل مفرط. + - قد يؤدي إلى خسائر إذا لم يتم تنفيذ البنود ذات الأسعار العالية. + """) + + # عرض بنود التسعير الحالي + items = st.session_state.current_pricing['items'].copy() + + # إضافة عمود إستراتيجية التسعير + if 'إستراتيجية التسعير' not in items.columns: + items['إستراتيجية التسعير'] = 'متوازن' + + st.markdown("### إستراتيجية التسعير غير المتزن") + + # اختيار الإستراتيجية + strategy = st.selectbox( + "اختر إستراتيجية التسعير", + [ + "تحميل أمامي (Front Loading)", + "تحميل البنود المؤكدة", + "تخفيض البنود المحتمل زيادتها", + "إستراتيجية مخصصة" + ] + ) + + # تطبيق الإستراتيجية المختارة + if strategy == "تحميل أمامي (Front Loading)": + # محاكاة تحميل أمامي + items_count = len(items) + early_items = items.iloc[:items_count//3].index + middle_items = items.iloc[items_count//3:2*items_count//3].index + late_items = items.iloc[2*items_count//3:].index + + # تطبيق الزيادة والنقصان + for idx in early_items: + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.3 # زيادة 30% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in middle_items: + items.at[idx, 'إستراتيجية التسعير'] = 'متوازن' + + for idx in late_items: + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.7 # نقص 30% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + elif strategy == "تحميل البنود المؤكدة": + # محاكاة - اعتبار بعض البنود مؤكدة + confirmed_items = [0, 2, 4] # الأصفار-مستندة + variable_items = [idx for idx in range(len(items)) if idx not in confirmed_items] + + # تطبيق الزيادة والنقصان + for idx in confirmed_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.25 # زيادة 25% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in variable_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.85 # نقص 15% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + elif strategy == "تخفيض البنود المحتمل زيادتها": + # محاكاة - اعتبار بعض البنود محتمل زيادتها + variable_items = [1, 3] # الأصفار-مستندة + other_items = [idx for idx in range(len(items)) if idx not in variable_items] + + # تطبيق الزيادة والنقصان + for idx in variable_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.7 # نقص 30% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + for idx in other_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.15 # زيادة 15% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + else: # إستراتيجية مخصصة + st.markdown("### تعديل أسعار البنود يدوياً") + st.markdown("قم بتعديل أسعار البنود وإستراتيجية التسعير يدوياً.") + + # حساب الإجمالي بعد التعديل + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + # تعيين ألوان للإستراتيجيات + def highlight_strategy(val): + if val == 'زيادة': + return 'background-color: #a8e6cf' + elif val == 'نقص': + return 'background-color: #ff9aa2' + return '' + + # عرض الجدول مع تنسيق + st.markdown("### بنود التسعير غير المتزن") + styled_items = items.style.applymap(highlight_strategy, subset=['إستراتيجية التسعير']) + st.dataframe(styled_items, use_container_width=True) + + # المقارنة بين التسعير المتوازن وغير المتوازن + st.markdown("### مقارنة التسعير المتوازن وغير المتوازن") + + original_items = st.session_state.current_pricing['items'].copy() + original_total = original_items['الإجمالي'].sum() + unbalanced_total = items['الإجمالي'].sum() + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التسعير المتوازن", f"{original_total:,.2f} ريال") + + with col2: + st.metric("إجمالي التسعير غير المتوازن", f"{unbalanced_total:,.2f} ريال") + + with col3: + diff = unbalanced_total - original_total + st.metric("الفرق", f"{diff:,.2f} ريال", delta=f"{diff/original_total*100:.1f}%") + + # المعايرة للحفاظ على إجمالي التسعير + if abs(diff) > 1: # إذا كان هناك فرق كبير + if st.button("معايرة الأسعار للحفاظ على إجمالي التسعير"): + # تعديل الأسعار للحفاظ على إجمالي التكلفة + adjustment_factor = original_total / unbalanced_total + items['سعر الوحدة'] = items['سعر الوحدة'] * adjustment_factor + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + st.success(f"تم تعديل الأسعار للحفاظ على إجمالي التسعير الأصلي ({original_total:,.2f} ريال)") + st.dataframe(items, use_container_width=True) + + # رسم بياني للمقارنة + st.markdown("### تحليل بصري للتسعير غير المتوازن") + + # إعداد البيانات للرسم البياني + chart_data = pd.DataFrame({ + 'وصف البند': original_items['وصف البند'], + 'التسعير المتوازن': original_items['الإجمالي'], + 'التسعير غير المتوازن': items['الإجمالي'] + }) + + # رسم بياني شريطي للمقارنة + fig = go.Figure() + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير المتوازن'], + name='التسعير المتوازن', + marker_color='rgb(55, 83, 109)' + )) + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير غير المتوازن'], + name='التسعير غير المتوازن', + marker_color='rgb(26, 118, 255)' + )) + + fig.update_layout( + title='مقارنة بين التسعير المتوازن وغير المتوازن', + xaxis_tickfont_size=14, + yaxis=dict( + title='الإجمالي (ريال)', + titlefont_size=16, + tickfont_size=14, + ), + legend=dict( + x=0, + y=1.0, + bgcolor='rgba(255, 255, 255, 0)', + bordercolor='rgba(255, 255, 255, 0)' + ), + barmode='group', + bargap=0.15, + bargroupgap=0.1 + ) + + st.plotly_chart(fig, use_container_width=True) + + # زر حفظ التسعير غير المتوازن + if st.button("حفظ التسعير غير المتوازن"): + st.session_state.current_pricing['items'] = items.copy() + st.session_state.current_pricing['method'] = "التسعير غير المتزن" + st.success("تم حفظ التسعير غير المتوازن بنجاح!") + + def _render_local_content_tab(self): + """عرض تبويب المحتوى المحلي""" + + st.markdown("### تحليل المحتوى المحلي") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # شرح المحتوى المحلي + with st.expander("ما هو المحتوى المحلي؟", expanded=False): + st.markdown(""" + **المحتوى المحلي** هو نسبة المنتجات والخدمات والقوى العاملة المحلية المستخدمة في المشروع. يهدف إلى زيادة مساهمة المنتجات والخدمات المحلية في المشاريع. + + ### مكونات المحتوى المحلي: + + 1. **المنتجات**: المنتجات والمواد المصنعة محلياً. + 2. **الخدمات**: الخدمات المقدمة من شركات محلية. + 3. **القوى العاملة**: العمالة والكوادر الفنية والإدارية المحلية. + + ### أهمية المحتوى المحلي: + + - تعزيز الاقتصاد المحلي وخلق فرص عمل. + - تحقيق أهداف رؤية 2030 في زيادة المحتوى المحلي. + - التأهل للمشاريع الحكومية التي تتطلب نسبة محتوى محلي محددة. + - الحصول على حوافز وأفضلية في المناقصات الحكومية. + + ### متطلبات المحتوى المحلي: + + - نسبة المحتوى المحلي للقوى العاملة: 80% + - نسبة المحتوى المحلي للمنتجات: 70% + - نسبة المحتوى المحلي للخدمات: 60% + """) + + # عرض لوحة إدخال بيانات المحتوى المحلي + st.markdown("### بيانات المحتوى المحلي") + + # التبويبات لأنواع المحتوى المحلي + lc_tabs = st.tabs(["المنتجات", "الخدمات", "القوى العاملة", "التحليل"]) + + with lc_tabs[0]: # المنتجات + st.markdown("#### بيانات المنتجات") + + # إنشاء بيانات افتراضية للمنتجات إذا لم تكن موجودة + if 'local_content_products' not in st.session_state: + st.session_state.local_content_products = pd.DataFrame({ + 'المنتج': [ + "خرسانة مسلحة", + "حديد تسليح", + "بلوك خرساني", + "عزل مائي", + "دهانات" + ], + 'الكمية': [250, 25, 400, 500, 600], + 'سعر_الوحدة': [1200, 6000, 200, 100, 50], + 'التكلفة_الإجمالية': [300000, 150000, 80000, 50000, 30000], + 'نسبة_المحتوى_المحلي': [0.95, 0.70, 0.98, 0.60, 0.80] + }) + + # حساب التكلفة الإجمالية + st.session_state.local_content_products['التكلفة_الإجمالية'] = st.session_state.local_content_products['الكمية'] * st.session_state.local_content_products['سعر_الوحدة'] + + # عرض جدول البنود مع إمكانية التعديل + edited_products = st.data_editor( + st.session_state.local_content_products, + use_container_width=True, + hide_index=True, + num_rows="dynamic" + ) + st.session_state.local_content_products = edited_products + + # عرض ملخص المنتجات + total_products_cost = edited_products['التكلفة_الإجمالية'].sum() + avg_local_content = (edited_products['التكلفة_الإجمالية'] * edited_products['نسبة_المحتوى_المحلي']).sum() / total_products_cost if total_products_cost > 0 else 0 + + st.markdown(f""" + **إجمالي تكلفة المنتجات**: {total_products_cost:,.2f} ريال + + **متوسط نسبة المحتوى المحلي للمنتجات**: {avg_local_content*100:.2f}% + + **المستهدف**: 70% + + **الحالة**: {"✅ ملتزم" if avg_local_content >= 0.7 else "❌ غير ملتزم"} + """) + + with lc_tabs[1]: # الخدمات + st.markdown("#### بيانات الخدمات") + + # إنشاء بيانات افتراضية للخدمات إذا لم تكن موجودة + if 'local_content_services' not in st.session_state: + st.session_state.local_content_services = pd.DataFrame({ + 'الخدمة': [ + "تصميم معماري", + "إشراف هندسي", + "خدمات نقل", + "خدمات أمن وسلامة", + "صيانة ونظافة" + ], + 'التكلفة': [100000, 120000, 50000, 30000, 20000], + 'نسبة_المحتوى_المحلي': [0.90, 0.85, 0.90, 0.95, 0.95] + }) + + # عرض جدول الخدمات مع إمكانية التعديل + edited_services = st.data_editor( + st.session_state.local_content_services, + use_container_width=True, + hide_index=True, + num_rows="dynamic" + ) + st.session_state.local_content_services = edited_services + + # عرض ملخص الخدمات + total_services_cost = edited_services['التكلفة'].sum() + avg_local_content = (edited_services['التكلفة'] * edited_services['نسبة_المحتوى_المحلي']).sum() / total_services_cost if total_services_cost > 0 else 0 + + st.markdown(f""" + **إجمالي تكلفة الخدمات**: {total_services_cost:,.2f} ريال + + **متوسط نسبة المحتوى المحلي للخدمات**: {avg_local_content*100:.2f}% + + **المستهدف**: 60% + + **الحالة**: {"✅ ملتزم" if avg_local_content >= 0.6 else "❌ غير ملتزم"} + """) + + with lc_tabs[2]: # القوى العاملة + st.markdown("#### بيانات القوى العاملة") + + # إنشاء بيانات افتراضية للقوى العاملة إذا لم تكن موجودة + if 'local_content_labor' not in st.session_state: + st.session_state.local_content_labor = pd.DataFrame({ + 'فئة_العمالة': [ + "مهندسون", + "فنيون", + "عمال بناء", + "إداريون", + "مشرفون" + ], + 'العدد': [5, 10, 30, 3, 4], + 'الراتب_الشهري': [15000, 8000, 3000, 10000, 12000], + 'المدة_بالأشهر': [12, 12, 12, 12, 12], + 'نسبة_المحتوى_المحلي': [0.75, 0.65, 0.60, 0.90, 0.80] + }) + + # حساب التكلفة الإجمالية + st.session_state.local_content_labor['التكلفة_الإجمالية'] = st.session_state.local_content_labor['العدد'] * st.session_state.local_content_labor['الراتب_الشهري'] * st.session_state.local_content_labor['المدة_بالأشهر'] + + # عرض جدول القوى العاملة مع إمكانية التعديل + edited_labor = st.data_editor( + st.session_state.local_content_labor, + use_container_width=True, + hide_index=True, + num_rows="dynamic" + ) + + # إعادة حساب التكلفة الإجمالية بعد التعديل + edited_labor['التكلفة_الإجمالية'] = edited_labor['العدد'] * edited_labor['الراتب_الشهري'] * edited_labor['المدة_بالأشهر'] + st.session_state.local_content_labor = edited_labor + + # عرض ملخص القوى العاملة + total_labor_cost = edited_labor['التكلفة_الإجمالية'].sum() + avg_local_content = (edited_labor['التكلفة_الإجمالية'] * edited_labor['نسبة_المحتوى_المحلي']).sum() / total_labor_cost if total_labor_cost > 0 else 0 + + st.markdown(f""" + **إجمالي تكلفة القوى العاملة**: {total_labor_cost:,.2f} ريال + + **متوسط نسبة المحتوى المحلي للقوى العاملة**: {avg_local_content*100:.2f}% + + **المستهدف**: 80% + + **الحالة**: {"✅ ملتزم" if avg_local_content >= 0.8 else "❌ غير ملتزم"} + """) + + with lc_tabs[3]: # التحليل + st.markdown("#### تحليل المحتوى المحلي") + + # حساب المحتوى المحلي الإجمالي + try: + # تجميع بيانات تحليل المحتوى المحلي + products_cost = st.session_state.local_content_products['التكلفة_الإجمالية'].sum() + products_local_content = (st.session_state.local_content_products['التكلفة_الإجمالية'] * st.session_state.local_content_products['نسبة_المحتوى_المحلي']).sum() / products_cost if products_cost > 0 else 0 + + services_cost = st.session_state.local_content_services['التكلفة'].sum() + services_local_content = (st.session_state.local_content_services['التكلفة'] * st.session_state.local_content_services['نسبة_المحتوى_المحلي']).sum() / services_cost if services_cost > 0 else 0 + + labor_cost = st.session_state.local_content_labor['التكلفة_الإجمالية'].sum() + labor_local_content = (st.session_state.local_content_labor['التكلفة_الإجمالية'] * st.session_state.local_content_labor['نسبة_المحتوى_المحلي']).sum() / labor_cost if labor_cost > 0 else 0 + + # حساب الوزن النسبي لكل مكون + total_cost = products_cost + services_cost + labor_cost + products_weight = products_cost / total_cost if total_cost > 0 else 0 + services_weight = services_cost / total_cost if total_cost > 0 else 0 + labor_weight = labor_cost / total_cost if total_cost > 0 else 0 + + # حساب المحتوى المحلي الإجمالي + total_local_content = (products_local_content * products_weight) + (services_local_content * services_weight) + (labor_local_content * labor_weight) + + # عرض ملخص المحتوى المحلي + st.markdown("### ملخص المحتوى المحلي") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التكاليف", f"{total_cost:,.2f} ريال") + + with col2: + st.metric("نسبة المحتوى المحلي الإجمالية", f"{total_local_content*100:.2f}%") + + with col3: + target_local_content = 0.7 # 70% + st.metric("الحالة", "ملتزم" if total_local_content >= target_local_content else "غير ملتزم", delta=f"{(total_local_content - target_local_content)*100:.2f}%") + + # عرض رسم بياني للمقارنة + st.markdown("### تحليل بصري للمحتوى المحلي") + + # رسم بياني شريطي لنسب المحتوى المحلي + categories = ['المنتجات', 'الخدمات', 'القوى العاملة', 'الإجمالي'] + actual_values = [products_local_content * 100, services_local_content * 100, labor_local_content * 100, total_local_content * 100] + target_values = [70, 60, 80, 70] # المستهدفات + + # تهيئة البيانات للرسم البياني + chart_data = pd.DataFrame({ + 'الفئة': categories, + 'النسبة الفعلية': actual_values, + 'النسبة المستهدفة': target_values + }) + + # رسم بياني شريطي للمقارنة + fig = go.Figure() + + fig.add_trace(go.Bar( + x=chart_data['الفئة'], + y=chart_data['النسبة الفعلية'], + name='النسبة الفعلية', + marker_color='rgb(26, 118, 255)' + )) + + fig.add_trace(go.Bar( + x=chart_data['الفئة'], + y=chart_data['النسبة المستهدفة'], + name='النسبة المستهدفة', + marker_color='rgb(55, 83, 109)' + )) + + fig.update_layout( + title='مقارنة بين النسب الفعلية والمستهدفة للمحتوى المحلي', + xaxis_tickfont_size=14, + yaxis=dict( + title='النسبة %', + titlefont_size=16, + tickfont_size=14, + ), + legend=dict( + x=0, + y=1.0, + bgcolor='rgba(255, 255, 255, 0)', + bordercolor='rgba(255, 255, 255, 0)' + ), + barmode='group', + bargap=0.15, + bargroupgap=0.1 + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض توصيات لتحسين نسبة المحتوى المحلي + st.markdown("### توصيات لتحسين نسبة المحتوى المحلي") + + recommendations = [] + + if products_local_content < 0.7: + recommendations.append("- زيادة نسبة المحتوى المحلي للمنتجات من خلال:") + recommendations.append(" - البحث عن موردين محليين للمنتجات ذات النسبة المنخفضة") + recommendations.append(" - استبدال المنتجات المستوردة ببدائل محلية") + recommendations.append(" - التعاون مع المصانع المحلية لتوطين صناعة المنتجات") + + if services_local_content < 0.6: + recommendations.append("- زيادة نسبة المحتوى المحلي للخدمات من خلال:") + recommendations.append(" - التعاقد مع شركات خدمات محلية") + recommendations.append(" - تحويل الخدمات المستعان بها من الخارج إلى شركات محلية") + recommendations.append(" - تأهيل الشركات المحلية لتقديم الخدمات المطلوبة") + + if labor_local_content < 0.8: + recommendations.append("- زيادة نسبة المحتوى المحلي للقوى العاملة من خلال:") + recommendations.append(" - زيادة توظيف الكوادر المحلية") + recommendations.append(" - تدريب وتأهيل العمالة المحلية") + recommendations.append(" - استبدال العمالة الأجنبية بكوادر محلية تدريجياً") + + if total_local_content < 0.7: + recommendations.append("- زيادة نسبة المحتوى المحلي الإجمالية من خلال:") + recommendations.append(" - إعادة توزيع الميزانية لصالح المكونات ذات النسبة العالية من المحتوى المحلي") + recommendations.append(" - وضع خطة مرحلية لزيادة المحتوى المحلي") + recommendations.append(" - التعاون مع اللجنة المحلية لزيادة المحتوى المحلي") + + if recommendations: + for rec in recommendations: + st.markdown(rec) + else: + st.success("تهانينا! نسبة المحتوى المحلي متوافقة مع المتطلبات.") + + # حساب تأثير المحتوى المحلي على التسعير + st.markdown("### تأثير المحتوى المحلي على التسعير") + + # تحديد عامل تعديل السعر بناءً على نسبة المحتوى المحلي + price_adjustment_factor = 1.0 + + if total_local_content >= 0.9: + price_adjustment_factor = 0.92 # خصم 8% للمحتوى المحلي العالي جداً + price_discount = "8%" + elif total_local_content >= 0.8: + price_adjustment_factor = 0.94 # خصم 6% للمحتوى المحلي العالي + price_discount = "6%" + elif total_local_content >= 0.7: + price_adjustment_factor = 0.96 # خصم 4% للمحتوى المحلي المتوسط + price_discount = "4%" + elif total_local_content >= 0.6: + price_adjustment_factor = 0.98 # خصم 2% للمحتوى المحلي المنخفض + price_discount = "2%" + else: + price_adjustment_factor = 1.0 # لا خصم + price_discount = "0%" + + # عرض تأثير المحتوى المحلي على التسعير + original_total = st.session_state.current_pricing['items']['الإجمالي'].sum() + adjusted_total = original_total * price_adjustment_factor + discount_amount = original_total - adjusted_total + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التسعير الأصلي", f"{original_total:,.2f} ريال") + + with col2: + st.metric("نسبة الخصم بسبب المحتوى المحلي", price_discount) + + with col3: + st.metric("إجمالي التسعير بعد الخصم", f"{adjusted_total:,.2f} ريال", delta=f"-{discount_amount:,.2f} ريال") + + # أزرار العمليات + col1, col2 = st.columns(2) + + with col1: + if st.button("حفظ تحليل المحتوى المحلي"): + # حفظ بيانات المحتوى المحلي في التسعير الحالي + st.session_state.current_pricing['local_content'] = { + 'products': st.session_state.local_content_products.copy(), + 'services': st.session_state.local_content_services.copy(), + 'labor': st.session_state.local_content_labor.copy(), + 'total_local_content': total_local_content, + 'price_adjustment_factor': price_adjustment_factor + } + + st.success("تم حفظ تحليل المحتوى المحلي بنجاح!") + + with col2: + if st.button("تصدير تقرير المحتوى المحلي"): + st.success("تم تصدير تقرير المحتوى المحلي بنجاح!") + + except Exception as e: + st.error(f"حدث خطأ أثناء تحليل المحتوى المحلي: {str(e)}") + st.warning("تأكد من إدخال بيانات المحتوى المحلي بشكل صحيح في التبويبات السابقة.") \ No newline at end of file diff --git a/modules/pricing/services/construction_cost_calculator.py b/modules/pricing/services/construction_cost_calculator.py new file mode 100644 index 0000000000000000000000000000000000000000..60c49b9505c8e5137b78c6794c4103e2f8114559 --- /dev/null +++ b/modules/pricing/services/construction_cost_calculator.py @@ -0,0 +1,1006 @@ +""" +خدمة حاسبة تكاليف البناء +تقوم هذه الخدمة بحساب تكاليف البناء بشكل تفصيلي بناءً على المكونات المختلفة: +- المواد الخام +- العمالة +- المعدات +- المصاريف الإدارية +- هامش الربح +""" + +import pandas as pd +import numpy as np +from datetime import datetime +import os +import json +import sys +from typing import Dict, List, Optional, Union, Any + +# إضافة مسار النظام للوصول لملفات التكوين +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))) +try: + import config +except ImportError: + # إذا لم يتم العثور على ملف التكوين، نستخدم قيم افتراضية + class DefaultConfig: + DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../data")) + config = DefaultConfig + + # إنشاء مجلد البيانات إذا لم يكن موجودًا + if not os.path.exists(config.DATA_DIR): + os.makedirs(config.DATA_DIR) + +class ConstructionCostCalculator: + """خدمة حاسبة تكاليف البناء""" + + def __init__(self): + """تهيئة حاسبة تكاليف البناء""" + # تحميل بيانات المواد والأسعار المرجعية + self.material_rates = self._load_material_rates() + self.labor_rates = self._load_labor_rates() + self.equipment_rates = self._load_equipment_rates() + + # النسب الافتراضية للمصاريف الإدارية وهامش الربح + self.default_admin_expenses_percentage = 0.05 # 5% + self.default_profit_margin_percentage = 0.10 # 10% + + # معاملات التعديل الافتراضية + self.default_adjustment_factors = { + 'location_factor': 1.0, # معامل الموقع + 'time_factor': 1.0, # معامل الوقت + 'risk_factor': 1.0, # معامل المخاطر + 'market_factor': 1.0 # معامل السوق + } + + def _load_material_rates(self) -> Dict[str, Dict[str, Any]]: + """تحميل أسعار المواد""" + # محاكاة تحميل البيانات من مصدر بيانات + material_rates = { + # مواد الخرسانة + 'خرسانة جاهزة': { + 'وحدة': 'م3', + 'سعر_الوحدة': 750.0, + 'وصف': 'خرسانة جاهزة بقوة 350 كجم/سم2', + 'فئة': 'أعمال خرسانية' + }, + 'حديد تسليح': { + 'وحدة': 'طن', + 'سعر_الوحدة': 5500.0, + 'وصف': 'حديد تسليح قطر 8-32 مم', + 'فئة': 'أعمال خرسانية' + }, + 'أسمنت': { + 'وحدة': 'كيس', + 'سعر_الوحدة': 30.0, + 'وصف': 'أسمنت بورتلاندي عادي', + 'فئة': 'أعمال خرسانية' + }, + 'رمل': { + 'وحدة': 'م3', + 'سعر_الوحدة': 120.0, + 'وصف': 'رمل خشن للخرسانة', + 'فئة': 'أعمال خرسانية' + }, + 'زلط': { + 'وحدة': 'م3', + 'سعر_الوحدة': 150.0, + 'وصف': 'زلط مقاس 10-20 مم للخرسانة', + 'فئة': 'أعمال خرسانية' + }, + + # مواد البناء + 'طوب أحمر': { + 'وحدة': '1000 قطعة', + 'سعر_الوحدة': 900.0, + 'وصف': 'طوب أحمر مقاس 25×12×6 سم', + 'فئة': 'أعمال بناء' + }, + 'طوب أسمنتي': { + 'وحدة': 'قطعة', + 'سعر_الوحدة': 4.5, + 'وصف': 'بلوك أسمنتي مقاس 20×20×40 سم', + 'فئة': 'أعمال بناء' + }, + 'مونة بناء': { + 'وحدة': 'م3', + 'سعر_الوحدة': 350.0, + 'وصف': 'مونة أسمنتية للبناء', + 'فئة': 'أعمال بناء' + }, + + # مواد التشطيبات + 'بلاط سيراميك': { + 'وحدة': 'م2', + 'سعر_الوحدة': 120.0, + 'وصف': 'بلاط سيراميك للأرضيات مقاس 40×40 سم', + 'فئة': 'تشطيبات' + }, + 'بلاط بورسلين': { + 'وحدة': 'م2', + 'سعر_الوحدة': 180.0, + 'وصف': 'بلاط بورسلين للأرضيات مقاس 60×60 سم', + 'فئة': 'تشطيبات' + }, + 'دهانات بلاستيك': { + 'وحدة': 'لتر', + 'سعر_الوحدة': 35.0, + 'وصف': 'دهان بلاستيك أساس وتشطيب', + 'فئة': 'تشطيبات' + }, + 'جبس بورد': { + 'وحدة': 'م2', + 'سعر_الوحدة': 95.0, + 'وصف': 'ألواح جبس بورد سمك 12 مم', + 'فئة': 'تشطيبات' + }, + + # مواد العزل + 'عزل مائي': { + 'وحدة': 'م2', + 'سعر_الوحدة': 45.0, + 'وصف': 'عزل مائي من البيتومين المؤكسد', + 'فئة': 'أعمال عزل' + }, + 'عزل حراري': { + 'وحدة': 'م2', + 'سعر_الوحدة': 65.0, + 'وصف': 'ألواح عزل حراري من البوليسترين سمك 5 سم', + 'فئة': 'أعمال عزل' + } + } + + # محاولة تحميل البيانات من ملف إذا كان متاحًا + try: + file_path = os.path.join(config.DATA_DIR, 'material_rates.json') + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + loaded_data = json.load(f) + material_rates.update(loaded_data) + except Exception as e: + print(f"خطأ في تحميل بيانات أسعار المواد: {str(e)}") + + return material_rates + + def _load_labor_rates(self) -> Dict[str, Dict[str, Any]]: + """تحميل أسعار العمالة""" + # محاكاة تحميل البيانات من مصدر بيانات + labor_rates = { + # عمالة الخرسانات + 'نجار مسلح': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 250.0, + 'وصف': 'نجار مسلح لأعمال الشدات والفرم', + 'فئة': 'أعمال خرسانية', + 'إنتاجية_يومية': { + 'شدة أساسات': 12, # متر مربع + 'شدة أعمدة': 10, # متر مربع + 'شدة أسقف': 12 # متر مربع + } + }, + 'حداد مسلح': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 250.0, + 'وصف': 'حداد مسلح لأعمال حديد التسليح', + 'فئة': 'أعمال خرسانية', + 'إنتاجية_يومية': { + 'تجهيز وتركيب حديد أساسات': 700, # كجم + 'تجهيز وتركيب حديد أعمدة': 600, # كجم + 'تجهيز وتركيب حديد أسقف': 650 # كجم + } + }, + 'عامل خرسانة': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 150.0, + 'وصف': 'عامل لصب وتسوية الخرسانة', + 'فئة': 'أعمال خرسانية', + 'إنتاجية_يومية': { + 'صب خرسانة': 15 # متر مكعب + } + }, + + # عمالة البناء + 'بناء': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 200.0, + 'وصف': 'عامل بناء للطوب والبلوك', + 'فئة': 'أعمال بناء', + 'إنتاجية_يومية': { + 'بناء طوب أحمر': 500, # قطعة + 'بناء بلوك أسمنتي': 80 # قطعة + } + }, + 'مساعد بناء': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 120.0, + 'وصف': 'مساعد عامل بناء', + 'فئة': 'أعمال بناء', + 'إنتاجية_يومية': {} + }, + + # عمالة التشطيبات + 'مبلط': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 250.0, + 'وصف': 'عامل تركيب بلاط وسيراميك', + 'فئة': 'تشطيبات', + 'إنتاجية_يومية': { + 'تركيب سيراميك أرضيات': 15, # متر مربع + 'تركيب سيراميك حوائط': 12, # متر مربع + 'تركيب بورسلين': 12 # متر مربع + } + }, + 'نقاش': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 200.0, + 'وصف': 'عامل دهانات', + 'فئة': 'تشطيبات', + 'إنتاجية_يومية': { + 'دهانات بلاستيك': 35, # متر مربع + 'دهانات زيتية': 25 # متر مربع + } + }, + 'كهربائي': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 270.0, + 'وصف': 'فني كهرباء', + 'فئة': 'تشطيبات', + 'إنتاجية_يومية': { + 'تأسيس نقاط كهرباء': 15, # نقطة + 'تركيب لوحات توزيع': 2 # لوحة + } + }, + 'سباك': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 250.0, + 'وصف': 'فني سباكة', + 'فئة': 'تشطيبات', + 'إنتاجية_يومية': { + 'تأسيس نقاط صرف': 8, # نقطة + 'تأسيس نقاط تغذية': 10, # نقطة + 'تركيب أطقم حمامات': 2 # طقم + } + }, + + # مراقبة وإشراف + 'مهندس موقع': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 500.0, + 'وصف': 'مهندس إشراف موقع', + 'فئة': 'إشراف' + }, + 'مراقب فني': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 300.0, + 'وصف': 'مراقب فني للتنفيذ', + 'فئة': 'إشراف' + } + } + + # محاولة تحميل البيانات من ملف إذا كان متاحًا + try: + file_path = os.path.join(config.DATA_DIR, 'labor_rates.json') + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + loaded_data = json.load(f) + labor_rates.update(loaded_data) + except Exception as e: + print(f"خطأ في تحميل بيانات أسعار العمالة: {str(e)}") + + return labor_rates + + def _load_equipment_rates(self) -> Dict[str, Dict[str, Any]]: + """تحميل أسعار المعدات""" + # محاكاة تحميل البيانات من مصدر بيانات + equipment_rates = { + # معدات الحفر والتسوية + 'حفار صغير': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 1200.0, + 'وصف': 'حفار صغير (بوبكات) بقدرة 70 حصان', + 'فئة': 'معدات حفر', + 'إنتاجية_يومية': { + 'حفر في تربة عادية': 60 # متر مكعب + } + }, + 'حفار متوسط': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 2500.0, + 'وصف': 'حفار متوسط الحجم بقدرة 150 حصان', + 'فئة': 'معدات حفر', + 'إنتاجية_يومية': { + 'حفر في تربة عادية': 200 # متر مكعب + } + }, + 'لودر': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 2000.0, + 'وصف': 'لودر أمامي لنقل التربة', + 'فئة': 'معدات حفر', + 'إنتاجية_يومية': { + 'تحميل تربة': 300, # متر مكعب + 'تسوية موقع': 1500 # متر مربع + } + }, + 'جريدر': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 2200.0, + 'وصف': 'جريدر لتسوية الموقع', + 'فئة': 'معدات حفر', + 'إنتاجية_يومية': { + 'تسوية طرق': 3000 # متر مربع + } + }, + + # معدات الخرسانة + 'خلاطة خرسانة': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 350.0, + 'وصف': 'خلاطة خرسانة بسعة 0.5 متر مكعب', + 'فئة': 'معدات خرسانة', + 'إنتاجية_يومية': { + 'خلط خرسانة': 15 # متر مكعب + } + }, + 'هزاز خرسانة': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 150.0, + 'وصف': 'هزاز خرسانة كهربائي', + 'فئة': 'معدات خرسانة', + 'إنتاجية_يومية': { + 'دمك خرسانة': 40 # متر مكعب + } + }, + 'شاحنة خرسانة جاهزة': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 3000.0, + 'وصف': 'شاحنة خرسانة جاهزة (مكسر) سعة 8 متر مكعب', + 'فئة': 'معدات خرسانة', + 'إنتاجية_يومية': { + 'نقل وصب خرسانة': 50 # متر مكعب + } + }, + 'مضخة خرسانة': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 5000.0, + 'وصف': 'مضخة خرسانة بذراع 42 متر', + 'فئة': 'معدات خرسانة', + 'إنتاجية_يومية': { + 'ضخ خرسانة': 120 # متر مكعب + } + }, + + # معدات رفع ونقل + 'رافعة برجية': { + 'وحدة': 'شهر', + 'سعر_الوحدة': 35000.0, + 'وصف': 'رافعة برجية بارتفاع 40 متر', + 'فئة': 'معدات رفع', + }, + 'ونش شوكة': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 1500.0, + 'وصف': 'ونش شوكة لرفع مواد البناء', + 'فئة': 'معدات رفع', + 'إنتاجية_يومية': { + 'رفع ونقل مواد': 100 # طن + } + }, + 'شاحنة نقل': { + 'وحدة': 'يوم', + 'سعر_الوحدة': 1200.0, + 'وصف': 'شاحنة نقل حمولة 20 طن', + 'فئة': 'معدات نقل', + 'إنتاجية_يومية': { + 'نقل مواد': 80 # طن + } + } + } + + # محاولة تحميل البيانات من ملف إذا كان متاحًا + try: + file_path = os.path.join(config.DATA_DIR, 'equipment_rates.json') + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + loaded_data = json.load(f) + equipment_rates.update(loaded_data) + except Exception as e: + print(f"خطأ في تحميل بيانات أسعار المعدات: {str(e)}") + + return equipment_rates + + def calculate_item_cost(self, item_data: Dict[str, Any]) -> Dict[str, Any]: + """ + حساب تكلفة بند محدد بكافة مكوناته + + المعلمات: + item_data (dict): بيانات البند، تتضمن: + - وصف_البند (str): وصف البند + - الكمية (float): كمية البند + - الوحدة (str): وحدة القياس + - المواد (list): قائمة المواد المستخدمة وكمياتها + - العمالة (list): قائمة العمالة المستخدمة وعددها + - المعدات (list): قائمة المعدات المستخدمة وساعات عملها + - المصاريف_الإدارية (float, optional): نسبة المصاريف الإدارية (افتراضياً 5%) + - هامش_الربح (float, optional): نسبة هامش الربح (افتراضياً 10%) + - عوامل_التعديل (dict, optional): عوامل تعديل التكلفة + + العوائد: + dict: تفاصيل تكلفة البند بكافة عناصرها + """ + # استخراج البيانات الأساسية للبند + item_description = item_data.get('وصف_البند', 'بند غير محدد') + quantity = item_data.get('الكمية', 0.0) + unit = item_data.get('الوحدة', 'وحدة') + + # حساب تكلفة المواد + materials_cost = self._calculate_materials_cost(item_data.get('المواد', [])) + + # حساب تكلفة العمالة + labor_cost = self._calculate_labor_cost(item_data.get('العمالة', [])) + + # حساب تكلفة المعدات + equipment_cost = self._calculate_equipment_cost(item_data.get('المعدات', [])) + + # حساب التكلفة المباشرة الإجمالية + direct_cost = materials_cost['الإجمالي'] + labor_cost['الإجمالي'] + equipment_cost['الإجمالي'] + + # حساب المصاريف الإدارية + admin_percentage = item_data.get('المصاريف_الإدارية', self.default_admin_expenses_percentage) + admin_cost = direct_cost * admin_percentage + + # حساب هامش الربح + profit_percentage = item_data.get('هامش_الربح', self.default_profit_margin_percentage) + profit_margin = (direct_cost + admin_cost) * profit_percentage + + # حساب التكلفة الإجمالية + total_cost = direct_cost + admin_cost + profit_margin + + # حساب سعر الوحدة + unit_price = total_cost / quantity if quantity > 0 else 0.0 + + # تطبيق عوامل التعديل إذا وجدت + adjustment_factors = item_data.get('عوامل_التعديل', self.default_adjustment_factors) + adjustment_factor = self._calculate_adjustment_factor(adjustment_factors) + + adjusted_unit_price = unit_price * adjustment_factor + adjusted_total_cost = total_cost * adjustment_factor + + # إعداد النتائج + result = { + 'وصف_البند': item_description, + 'الكمية': quantity, + 'الوحدة': unit, + 'تكاليف_مباشرة': { + 'المواد': materials_cost, + 'العمالة': labor_cost, + 'المعدات': equipment_cost, + 'إجمالي_تكاليف_مباشرة': direct_cost + }, + 'مصاريف_إدارية': { + 'نسبة': admin_percentage * 100, + 'قيمة': admin_cost + }, + 'هامش_ربح': { + 'نسبة': profit_percentage * 100, + 'قيمة': profit_margin + }, + 'التكلفة_الإجمالية': total_cost, + 'سعر_الوحدة': unit_price, + 'عوامل_التعديل': { + 'المعامل_الإجمالي': adjustment_factor, + 'التفاصيل': adjustment_factors + }, + 'السعر_المعدل': { + 'سعر_الوحدة': adjusted_unit_price, + 'إجمالي': adjusted_total_cost + } + } + + return result + + def _calculate_materials_cost(self, materials: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + حساب تكلفة المواد + + المعلمات: + materials (list): قائمة المواد المستخدمة وكمياتها + - الاسم (str): اسم المادة + - الكمية (float): الكمية المستخدمة + - الوحدة (str, optional): وحدة القياس + - سعر_الوحدة (float, optional): سعر الوحدة (يستخدم السعر من البيانات المرجعية إذا لم يتم تحديده) + + العوائد: + dict: تفاصيل تكلفة المواد + """ + materials_details = [] + total_cost = 0.0 + + for material in materials: + material_name = material.get('الاسم', '') + quantity = material.get('الكمية', 0.0) + + # البحث عن سعر المادة من البيانات المرجعية إذا لم يتم تحديده + if 'سعر_الوحدة' in material: + unit_price = material.get('سعر_الوحدة', 0.0) + unit = material.get('الوحدة', 'وحدة') + elif material_name in self.material_rates: + ref_material = self.material_rates[material_name] + unit_price = ref_material.get('سعر_الوحدة', 0.0) + unit = ref_material.get('وحدة', 'وحدة') + else: + unit_price = 0.0 + unit = material.get('الوحدة', 'وحدة') + + # حساب التكلفة + cost = quantity * unit_price + total_cost += cost + + # إضافة التفاصيل + materials_details.append({ + 'الاسم': material_name, + 'الكمية': quantity, + 'الوحدة': unit, + 'سعر_الوحدة': unit_price, + 'التكلفة': cost + }) + + return { + 'التفاصيل': materials_details, + 'الإجمالي': total_cost + } + + def _calculate_labor_cost(self, labor: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + حساب تكلفة العمالة + + المعلمات: + labor (list): قائمة العمالة المستخدمة وعددها + - النوع (str): نوع العامل + - العدد (int): عدد العمال + - المدة (float): مدة العمل بالأيام + - سعر_اليوم (float, optional): أجر اليوم (يستخدم السعر من البيانات المرجعية إذا لم يتم تحديده) + + العوائد: + dict: تفاصيل تكلفة العمالة + """ + labor_details = [] + total_cost = 0.0 + + for worker in labor: + worker_type = worker.get('النوع', '') + count = worker.get('العدد', 0) + duration = worker.get('المدة', 0.0) + + # البحث عن سعر العامل من البيانات المرجعية إذا لم يتم تحديده + if 'سعر_اليوم' in worker: + daily_rate = worker.get('سعر_اليوم', 0.0) + elif worker_type in self.labor_rates: + daily_rate = self.labor_rates[worker_type].get('سعر_الوحدة', 0.0) + else: + daily_rate = 0.0 + + # حساب التكلفة + cost = count * duration * daily_rate + total_cost += cost + + # إضافة التفاصيل + labor_details.append({ + 'النوع': worker_type, + 'العدد': count, + 'المدة': duration, + 'سعر_اليوم': daily_rate, + 'التكلفة': cost + }) + + return { + 'التفاصيل': labor_details, + 'الإجمالي': total_cost + } + + def _calculate_equipment_cost(self, equipment: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + حساب تكلفة المعدات + + المعلمات: + equipment (list): قائمة المعدات المستخدمة وساعات عملها + - النوع (str): نوع المعدة + - العدد (int): عدد المعدات + - المدة (float): مدة الاستخدام بالأيام + - سعر_اليوم (float, optional): أجر اليوم (يستخدم السعر من البيانات المرجعية إذا لم يتم تحديده) + + العوائد: + dict: تفاصيل تكلفة المعدات + """ + equipment_details = [] + total_cost = 0.0 + + for equip in equipment: + equip_type = equip.get('النوع', '') + count = equip.get('العدد', 0) + duration = equip.get('المدة', 0.0) + + # البحث عن سعر المعدة من البيانات المرجعية إذا لم يتم تحديده + if 'سعر_اليوم' in equip: + daily_rate = equip.get('سعر_اليوم', 0.0) + elif equip_type in self.equipment_rates: + daily_rate = self.equipment_rates[equip_type].get('سعر_الوحدة', 0.0) + else: + daily_rate = 0.0 + + # حساب التكلفة + cost = count * duration * daily_rate + total_cost += cost + + # إضافة التفاصيل + equipment_details.append({ + 'النوع': equip_type, + 'العدد': count, + 'المدة': duration, + 'سعر_اليوم': daily_rate, + 'التكلفة': cost + }) + + return { + 'التفاصيل': equipment_details, + 'الإجمالي': total_cost + } + + def _calculate_adjustment_factor(self, factors: Dict[str, float]) -> float: + """ + حساب المعامل الإجمالي لتعديل التكلفة + + المعلمات: + factors (dict): عوامل التعديل + + العوائد: + float: المعامل الإجمالي + """ + # دمج العوامل المحددة مع العوامل الافتراضية + effective_factors = self.default_adjustment_factors.copy() + effective_factors.update(factors) + + # حساب المعامل الإجمالي + total_factor = 1.0 + for factor in effective_factors.values(): + total_factor *= factor + + return total_factor + + def calculate_project_cost(self, project_data: Dict[str, Any]) -> Dict[str, Any]: + """ + حساب التكلفة الإجمالية لمشروع بناء كامل + + المعلمات: + project_data (dict): بيانات المشروع، تتضمن: + - اسم_المشروع (str): اسم المشروع + - وصف_المشروع (str): وصف المشروع + - البنود (list): قائمة بنود المشروع + - المصاريف_الإدارية (float, optional): نسبة المصاريف الإدارية الإجمالية (افتراضياً 5%) + - هامش_الربح (float, optional): نسبة هامش الربح الإجمالي (افتراضياً 10%) + - عوامل_التعديل (dict, optional): عوامل تعديل التكلفة للمشروع + + العوائد: + dict: تفاصيل تكلفة المشروع بكافة عناصرها + """ + # استخراج البيانات الأساسية للمشروع + project_name = project_data.get('اسم_المشروع', 'مشروع غير محدد') + project_description = project_data.get('وصف_المشروع', '') + items = project_data.get('البنود', []) + + # استخراج النسب الإجمالية + admin_percentage = project_data.get('المصاريف_الإدارية', self.default_admin_expenses_percentage) + profit_percentage = project_data.get('هامش_الربح', self.default_profit_margin_percentage) + + # حساب تكلفة كل بند + items_costs = [] + total_direct_cost = 0.0 + total_materials_cost = 0.0 + total_labor_cost = 0.0 + total_equipment_cost = 0.0 + + for item_data in items: + # تحديث نسب المصاريف والربح للبند إذا لم تكن محددة + if 'المصاريف_الإدارية' not in item_data: + item_data['المصاريف_الإدارية'] = admin_percentage + + if 'هامش_الربح' not in item_data: + item_data['هامش_الربح'] = profit_percentage + + # حساب تكلفة البند + item_cost = self.calculate_item_cost(item_data) + items_costs.append(item_cost) + + # تحديث الإجماليات + total_materials_cost += item_cost['تكاليف_مباشرة']['المواد']['الإجمالي'] + total_labor_cost += item_cost['تكاليف_مباشرة']['العمالة']['الإجمالي'] + total_equipment_cost += item_cost['تكاليف_مباشرة']['المعدات']['الإجمالي'] + total_direct_cost += item_cost['تكاليف_مباشرة']['إجمالي_تكاليف_مباشرة'] + + # حساب المصاريف الإدارية + admin_cost = total_direct_cost * admin_percentage + + # حساب هامش الربح + profit_margin = (total_direct_cost + admin_cost) * profit_percentage + + # حساب التكلفة الإجمالية + total_cost = total_direct_cost + admin_cost + profit_margin + + # تطبيق عوامل التعديل إذا وجدت + adjustment_factors = project_data.get('عوامل_التعديل', self.default_adjustment_factors) + adjustment_factor = self._calculate_adjustment_factor(adjustment_factors) + + adjusted_total_cost = total_cost * adjustment_factor + + # إعداد النتائج + result = { + 'اسم_المشروع': project_name, + 'وصف_المشروع': project_description, + 'تكاليف_مباشرة': { + 'المواد': { + 'الإجمالي': total_materials_cost, + 'النسبة_المئوية': (total_materials_cost / total_direct_cost * 100) if total_direct_cost > 0 else 0 + }, + 'العمالة': { + 'الإجمالي': total_labor_cost, + 'النسبة_المئوية': (total_labor_cost / total_direct_cost * 100) if total_direct_cost > 0 else 0 + }, + 'المعدات': { + 'الإجمالي': total_equipment_cost, + 'النسبة_المئوية': (total_equipment_cost / total_direct_cost * 100) if total_direct_cost > 0 else 0 + }, + 'إجمالي_تكاليف_مباشرة': total_direct_cost + }, + 'مصاريف_إدارية': { + 'نسبة': admin_percentage * 100, + 'قيمة': admin_cost + }, + 'هامش_ربح': { + 'نسبة': profit_percentage * 100, + 'قيمة': profit_margin + }, + 'التكلفة_الإجمالية': total_cost, + 'عوامل_التعديل': { + 'المعامل_الإجمالي': adjustment_factor, + 'التفاصيل': adjustment_factors + }, + 'التكلفة_النهائية_المعدلة': adjusted_total_cost, + 'تفاصيل_البنود': items_costs, + 'عدد_البنود': len(items) + } + + return result + + def get_rate_info(self, item_type: str, item_name: str) -> Dict[str, Any]: + """ + الحصول على معلومات تفصيلية عن معدل وسعر عنصر محدد (مادة، عمالة، معدة) + + المعلمات: + item_type (str): نوع العنصر - 'مادة'، 'عمالة'، 'معدة' + item_name (str): اسم العنصر + + العوائد: + dict: معلومات تفصيلية عن العنصر + """ + # تحديد القاموس المناسب حسب نوع العنصر + if item_type == 'مادة': + rates_dict = self.material_rates + elif item_type == 'عمالة': + rates_dict = self.labor_rates + elif item_type == 'معدة': + rates_dict = self.equipment_rates + else: + return {'خطأ': 'نوع العنصر غير صحيح'} + + # البحث عن العنصر في القاموس + if item_name in rates_dict: + return rates_dict[item_name] + else: + return {'خطأ': 'العنصر غير موجود'} + + def get_all_rates(self, item_type: str = None, category: str = None) -> Dict[str, Any]: + """ + الحصول على قوائم معدلات الأسعار (لجميع المواد أو العمالة أو المعدات) + + المعلمات: + item_type (str, optional): نوع العنصر - 'مادة'، 'عمالة'، 'معدة'، أو None لجميع الأنواع + category (str, optional): فئة محددة للتصفية + + العوائد: + dict: قوائم معدلات الأسعار + """ + result = {} + + # جمع المواد حسب الفئة + if item_type is None or item_type == 'مادة': + materials = {} + for name, info in self.material_rates.items(): + if category is None or info.get('فئة') == category: + materials[name] = info + result['المواد'] = materials + + # جمع العمالة حسب الفئة + if item_type is None or item_type == 'عمالة': + labor = {} + for name, info in self.labor_rates.items(): + if category is None or info.get('فئة') == category: + labor[name] = info + result['العمالة'] = labor + + # جمع المعدات حسب الفئة + if item_type is None or item_type == 'معدة': + equipment = {} + for name, info in self.equipment_rates.items(): + if category is None or info.get('فئة') == category: + equipment[name] = info + result['المعدات'] = equipment + + return result + + def generate_sample_project_data(self) -> Dict[str, Any]: + """ + توليد بيانات نموذجية لمشروع بناء صغير للاختبار + + العوائد: + dict: بيانات المشروع النموذجية + """ + # إنشاء بيانات المشروع + project_data = { + 'اسم_المشروع': 'مبنى سكني صغير', + 'وصف_المشروع': 'مبنى سكني مكون من دور أرضي بمساحة 250 متر مربع', + 'المصاريف_الإدارية': 0.05, # 5% + 'هامش_الربح': 0.10, # 10% + 'عوامل_التعديل': { + 'location_factor': 1.2, # معامل الموقع (منطقة مرتفعة التكلفة) + 'time_factor': 1.0, # معامل الوقت + 'risk_factor': 1.05, # معامل المخاطر + 'market_factor': 1.0 # معامل السوق + }, + 'البنود': [ + # الأساسات + { + 'وصف_البند': 'حفر الأساسات بعمق 2 متر', + 'الكمية': 150.0, + 'الوحدة': 'م3', + 'المواد': [], + 'العمالة': [ + {'النوع': 'عامل خرسانة', 'العدد': 4, 'المدة': 3} + ], + 'المعدات': [ + {'النوع': 'حفار متوسط', 'العدد': 1, 'المدة': 2} + ] + }, + { + 'وصف_البند': 'توريد وصب خرسانة عادية للأساسات', + 'الكمية': 25.0, + 'الوحدة': 'م3', + 'المواد': [ + {'الاسم': 'خرسانة جاهزة', 'الكمية': 25.0} + ], + 'العمالة': [ + {'النوع': 'عامل خرسانة', 'العدد': 6, 'المدة': 1} + ], + 'المعدات': [ + {'النوع': 'مضخة خرسانة', 'العدد': 1, 'المدة': 0.5} + ] + }, + { + 'وصف_البند': 'توريد وتركيب حديد تسليح للأساسات', + 'الكمية': 3.5, + 'الوحدة': 'طن', + 'المواد': [ + {'الاسم': 'حديد تسليح', 'الكمية': 3.5} + ], + 'العمالة': [ + {'النوع': 'حداد مسلح', 'العدد': 4, 'المدة': 3} + ], + 'المعدات': [] + }, + { + 'وصف_البند': 'نجارة وفك شدة الأساسات', + 'الكمية': 120.0, + 'الوحدة': 'م2', + 'المواد': [], + 'العمالة': [ + {'النوع': 'نجار مسلح', 'العدد': 4, 'المدة': 3} + ], + 'المعدات': [] + }, + { + 'وصف_البند': 'توريد وصب خرسانة مسلحة للأساسات', + 'الكمية': 30.0, + 'الوحدة': 'م3', + 'المواد': [ + {'الاسم': 'خرسانة جاهزة', 'الكمية': 30.0} + ], + 'العمالة': [ + {'النوع': 'عامل خرسانة', 'العدد': 6, 'المدة': 1} + ], + 'المعدات': [ + {'النوع': 'مضخة خرسانة', 'العدد': 1, 'المدة': 0.5}, + {'النوع': 'هزاز خرسانة', 'العدد': 2, 'المدة': 1} + ] + }, + + # الأعمدة والأسقف + { + 'وصف_البند': 'توريد وتركيب حديد تسليح للأعمدة', + 'الكمية': 2.8, + 'الوحدة': 'طن', + 'المواد': [ + {'الاسم': 'حديد تسليح', 'الكمية': 2.8} + ], + 'العمالة': [ + {'النوع': 'حداد مسلح', 'العدد': 4, 'المدة': 3} + ], + 'المعدات': [] + }, + { + 'وصف_البند': 'نجارة وفك شدة الأعمدة', + 'الكمية': 85.0, + 'الوحدة': 'م2', + 'المواد': [], + 'العمالة': [ + {'النوع': 'نجار مسلح', 'العدد': 3, 'المدة': 3} + ], + 'المعدات': [] + }, + { + 'وصف_البند': 'توريد وصب خرسانة مسلحة للأعمدة', + 'الكمية': 12.0, + 'الوحدة': 'م3', + 'المواد': [ + {'الاسم': 'خرسانة جاهزة', 'الكمية': 12.0} + ], + 'العمالة': [ + {'النوع': 'عامل خرسانة', 'العدد': 4, 'المدة': 1} + ], + 'المعدات': [ + {'النوع': 'مضخة خرسانة', 'العدد': 1, 'المدة': 0.5}, + {'النوع': 'هزاز خرسانة', 'العدد': 2, 'المدة': 1} + ] + }, + + # أعمال البناء + { + 'وصف_البند': 'توريد وبناء حوائط من الطوب الأحمر', + 'الكمية': 220.0, + 'الوحدة': 'م2', + 'المواد': [ + {'الاسم': 'طوب أحمر', 'الكمية': 16.5} # بالألف + ], + 'العمالة': [ + {'النوع': 'بناء', 'العدد': 4, 'المدة': 8}, + {'النوع': 'مساعد بناء', 'العدد': 4, 'المدة': 8} + ], + 'المعدات': [] + }, + + # أعمال التشطيبات + { + 'وصف_البند': 'توريد وتركيب بلاط سيراميك للأرضيات', + 'الكمية': 250.0, + 'الوحدة': 'م2', + 'المواد': [ + {'الاسم': 'بلاط سيراميك', 'الكمية': 250.0} + ], + 'العمالة': [ + {'النوع': 'مبلط', 'العدد': 4, 'المدة': 7} + ], + 'المعدات': [] + }, + { + 'وصف_البند': 'توريد وتنفيذ دهانات للحوائط', + 'الكمية': 450.0, + 'الوحدة': 'م2', + 'المواد': [ + {'الاسم': 'دهانات بلاستيك', 'الكمية': 90.0} # بالتر + ], + 'العمالة': [ + {'النوع': 'نقاش', 'العدد': 3, 'المدة': 8} + ], + 'المعدات': [] + } + ] + } + + return project_data \ No newline at end of file diff --git a/modules/pricing/services/construction_templates.py b/modules/pricing/services/construction_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..bdbdd1586dcb54cb9d806ecb016bac7f6dfc0f91 --- /dev/null +++ b/modules/pricing/services/construction_templates.py @@ -0,0 +1,748 @@ +""" +كتالوج بنود نموذجية للمقاولات +يحتوي هذا الملف على قائمة كاملة من النماذج الجاهزة للبنود الشائعة في مشاريع المقاولات، مثل: +- أعمال الخرسانة بأنواعها +- المناهل وأنواع المواسير +- التركيبات المختلفة +- الطرق والأسفلت +- وغيرها من أعمال المقاولات +""" + +import os +import json +import sys +from typing import Dict, List, Any, Optional +from datetime import datetime + +# إضافة مسار النظام للوصول لملفات التكوين +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))) +try: + import config +except ImportError: + # إذا لم يتم العثور على ملف التكوين، نستخدم قيم افتراضية + class DefaultConfig: + DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../data")) + config = DefaultConfig + + # إنشاء مجلد البيانات إذا لم يكن موجودًا + if not os.path.exists(config.DATA_DIR): + os.makedirs(config.DATA_DIR) + + +class ConstructionTemplates: + """كتالوج بنود نموذجية للمقاولات""" + + def __init__(self): + """تهيئة كتالوج البنود النموذجية""" + self.templates_file = os.path.join(config.DATA_DIR, 'construction_templates.json') + self.market_prices_file = os.path.join(config.DATA_DIR, 'saudi_market_prices.json') + + # تحميل قوالب البنود النموذجية + self.templates = self._load_templates() + + # تحميل أسعار السوق السعودي + self.market_prices = self._load_market_prices() + + def _load_templates(self) -> Dict[str, Dict[str, Any]]: + """تحميل قوالب البنود النموذجية من الملف""" + if os.path.exists(self.templates_file): + try: + with open(self.templates_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"خطأ في تحميل قوالب البنود النموذجية: {str(e)}") + + # إنشاء بيانات افتراضية إذا لم يتم العثور على الملف + default_templates = self._create_default_templates() + + # حفظ البيانات الافتراضية + self._save_templates(default_templates) + + return default_templates + + def _save_templates(self, templates: Dict[str, Dict[str, Any]]) -> None: + """حفظ قوالب البنود النموذجية إلى الملف""" + try: + with open(self.templates_file, 'w', encoding='utf-8') as f: + json.dump(templates, f, ensure_ascii=False, indent=4) + except Exception as e: + print(f"خطأ في حفظ قوالب البنود النموذجية: {str(e)}") + + def _load_market_prices(self) -> Dict[str, Dict[str, Any]]: + """تحميل أسعار السوق السعودي من الملف""" + if os.path.exists(self.market_prices_file): + try: + with open(self.market_prices_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"خطأ في تحميل أسعار السوق السعودي: {str(e)}") + + # إنشاء بيانات افتراضية إذا لم يتم العثور على الملف + default_prices = self._create_default_market_prices() + + # حفظ البيانات الافتراضية + self._save_market_prices(default_prices) + + return default_prices + + def _save_market_prices(self, prices: Dict[str, Dict[str, Any]]) -> None: + """حفظ أسعار السوق السعودي إلى الملف""" + try: + with open(self.market_prices_file, 'w', encoding='utf-8') as f: + json.dump(prices, f, ensure_ascii=False, indent=4) + except Exception as e: + print(f"خطأ في حفظ أسعار السوق السعودي: {str(e)}") + + def _create_default_templates(self) -> Dict[str, Dict[str, Any]]: + """إنشاء قوالب افتراضية للبنود النموذجية""" + templates = { + "categories": { + "أعمال_خرسانية": { + "name": "أعمال خرسانية", + "description": "بنود أعمال الخرسانة المسلحة والعادية", + "icon": "building" + }, + "أعمال_صحية": { + "name": "أعمال صحية", + "description": "بنود أعمال المناهل والمواسير والتركيبات الصحية", + "icon": "pipe" + }, + "أعمال_طرق": { + "name": "أعمال طرق", + "description": "بنود أعمال الطرق والأسفلت والرصف", + "icon": "road" + }, + "أعمال_كهربائية": { + "name": "أعمال كهربائية", + "description": "بنود أعمال الكهرباء والإنارة", + "icon": "zap" + }, + "أعمال_ميكانيكية": { + "name": "أعمال ميكانيكية", + "description": "بنود أعمال التكييف والتهوية والتبريد", + "icon": "thermometer" + } + }, + "templates": { + # نماذج أعمال خرسانية + "خرسانة_مسلحة_أساسات": { + "category": "أعمال_خرسانية", + "name": "خرسانة مسلحة للأساسات", + "description": "توريد وصب خرسانة مسلحة للأساسات بقوة لا تقل عن 300 كجم/سم2", + "unit": "م3", + "components": { + "materials": [ + {"الاسم": "خرسانة جاهزة", "الكمية": 1.0, "الوحدة": "م3", "سعر_الوحدة": 750.0}, + {"الاسم": "حديد تسليح", "الكمية": 0.12, "الوحدة": "طن", "سعر_الوحدة": 5500.0} + ], + "labor": [ + {"النوع": "عامل خرسانة", "العدد": 4, "المدة": 0.3, "سعر_اليوم": 150.0}, + {"النوع": "نجار مسلح", "العدد": 2, "المدة": 0.5, "سعر_اليوم": 250.0}, + {"النوع": "حداد مسلح", "العدد": 2, "المدة": 0.5, "سعر_اليوم": 250.0} + ], + "equipment": [ + {"النوع": "هزاز خرسانة", "العدد": 1, "المدة": 0.3, "سعر_اليوم": 150.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.10, + "tags": ["خرسانة", "أساسات", "مسلحة"] + }, + "خرسانة_مسلحة_أعمدة": { + "category": "أعمال_خرسانية", + "name": "خرسانة مسلحة للأعمدة", + "description": "توريد وصب خرسانة مسلحة للأعمدة بقوة لا تقل عن 350 كجم/سم2", + "unit": "م3", + "components": { + "materials": [ + {"الاسم": "خرسانة جاهزة", "الكمية": 1.0, "الوحدة": "م3", "سعر_الوحدة": 750.0}, + {"الاسم": "حديد تسليح", "الكمية": 0.18, "الوحدة": "طن", "سعر_الوحدة": 5500.0} + ], + "labor": [ + {"النوع": "عامل خرسانة", "العدد": 4, "المدة": 0.4, "سعر_اليوم": 150.0}, + {"النوع": "نجار مسلح", "العدد": 2, "المدة": 0.7, "سعر_اليوم": 250.0}, + {"النوع": "حداد مسلح", "العدد": 2, "المدة": 0.7, "سعر_اليوم": 250.0} + ], + "equipment": [ + {"النوع": "هزاز خرسانة", "العدد": 2, "المدة": 0.4, "سعر_اليوم": 150.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.10, + "tags": ["خرسانة", "أعمدة", "مسلحة"] + }, + "خرسانة_مسلحة_أسقف": { + "category": "أعمال_خرسانية", + "name": "خرسانة مسلحة للأسقف", + "description": "توريد وصب خرسانة مسلحة للأسقف والبلاطات بقوة لا تقل عن 350 كجم/سم2", + "unit": "م3", + "components": { + "materials": [ + {"الاسم": "خرسانة جاهزة", "الكمية": 1.0, "الوحدة": "م3", "سعر_الوحدة": 750.0}, + {"الاسم": "حديد تسليح", "الكمية": 0.16, "الوحدة": "طن", "سعر_الوحدة": 5500.0} + ], + "labor": [ + {"النوع": "عامل خرسانة", "العدد": 5, "المدة": 0.5, "سعر_اليوم": 150.0}, + {"النوع": "نجار مسلح", "العدد": 3, "المدة": 0.8, "سعر_اليوم": 250.0}, + {"النوع": "حداد مسلح", "العدد": 3, "المدة": 0.8, "سعر_اليوم": 250.0} + ], + "equipment": [ + {"النوع": "هزاز خرسانة", "العدد": 2, "المدة": 0.5, "سعر_اليوم": 150.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.10, + "tags": ["خرسانة", "أسقف", "بلاطات", "مسلحة"] + }, + + # نماذج أعمال صحية + "منهل_تفتيش_خرساني": { + "category": "أعمال_صحية", + "name": "منهل تفتيش خرساني", + "description": "توريد وتركيب منهل تفتيش خرساني قطر 1 متر وعمق 2 متر", + "unit": "عدد", + "components": { + "materials": [ + {"الاسم": "خرسانة جاهزة", "الكمية": 1.5, "الوحدة": "م3", "سعر_الوحدة": 750.0}, + {"الاسم": "حديد تسليح", "الكمية": 0.15, "الوحدة": "طن", "سعر_الوحدة": 5500.0}, + {"الاسم": "غطاء منهل حديد", "الكمية": 1, "الوحدة": "عدد", "سعر_الوحدة": 1500.0} + ], + "labor": [ + {"النوع": "عامل خرسانة", "العدد": 3, "المدة": 1, "سعر_اليوم": 150.0}, + {"النوع": "نجار مسلح", "العدد": 2, "المدة": 1, "سعر_اليوم": 250.0}, + {"النوع": "حداد مسلح", "العدد": 1, "المدة": 1, "سعر_اليوم": 250.0}, + {"النوع": "سباك", "العدد": 2, "المدة": 1, "سعر_اليوم": 250.0} + ], + "equipment": [ + {"النوع": "حفار صغير", "العدد": 1, "المدة": 0.5, "سعر_اليوم": 1200.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.12, + "tags": ["صرف صحي", "منهل", "تفتيش"] + }, + "مواسير_بلاستيك_قطر_200_مم": { + "category": "أعمال_صحية", + "name": "مواسير بلاستيك قطر 200 مم", + "description": "توريد وتركيب مواسير بلاستيك UPVC قطر 200 مم لشبكات الصرف الصحي", + "unit": "م.ط", + "components": { + "materials": [ + {"الاسم": "مواسير بلاستيك UPVC قطر 200 مم", "الكمية": 1.05, "الوحدة": "م.ط", "سعر_الوحدة": 180.0}, + {"الاسم": "وصلات ومثبتات", "الكمية": 1, "الوحدة": "مجموعة", "سعر_الوحدة": 35.0}, + {"الاسم": "مواد لاصقة", "الكمية": 0.1, "الوحدة": "لتر", "سعر_الوحدة": 120.0} + ], + "labor": [ + {"النوع": "سباك", "العدد": 2, "المدة": 0.2, "سعر_اليوم": 250.0}, + {"النوع": "مساعد سباك", "العدد": 2, "المدة": 0.2, "سعر_اليوم": 120.0} + ], + "equipment": [ + {"النوع": "حفار صغير", "العدد": 1, "المدة": 0.1, "سعر_اليوم": 1200.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.12, + "tags": ["صرف صحي", "مواسير", "بلاستيك"] + }, + + # نماذج أعمال طرق + "طبقة_أساس_للطرق": { + "category": "أعمال_طرق", + "name": "طبقة أساس للطرق", + "description": "توريد وفرد ودمك طبقة أساس للطرق سمك 20 سم، درجة دمك 98%", + "unit": "م3", + "components": { + "materials": [ + {"الاسم": "مواد طبقة أساس", "الكمية": 1.25, "الوحدة": "م3", "سعر_الوحدة": 90.0}, + {"الاسم": "مياه للدمك", "الكمية": 0.2, "الوحدة": "م3", "سعر_الوحدة": 10.0} + ], + "labor": [ + {"النوع": "عامل طرق", "العدد": 4, "المدة": 0.05, "سعر_اليوم": 150.0}, + {"النوع": "مراقب فني", "العدد": 1, "المدة": 0.05, "سعر_اليوم": 300.0} + ], + "equipment": [ + {"النوع": "جريدر", "العدد": 1, "المدة": 0.05, "سعر_اليوم": 2200.0}, + {"النوع": "رصاصة دمك", "العدد": 1, "المدة": 0.05, "سعر_اليوم": 1800.0}, + {"النوع": "شاحنة نقل", "العدد": 2, "المدة": 0.05, "سعر_اليوم": 1200.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.12, + "tags": ["طرق", "أساس", "دمك"] + }, + "طبقة_إسفلت_سطحية": { + "category": "أعمال_طرق", + "name": "طبقة إسفلت سطحية", + "description": "توريد وفرد ودمك طبقة إسفلت سطحية سمك 5 سم", + "unit": "م2", + "components": { + "materials": [ + {"الاسم": "خلطة إسفلتية ساخنة", "الكمية": 0.125, "الوحدة": "طن", "سعر_الوحدة": 400.0}, + {"الاسم": "مواد رش تأسيسي", "الكمية": 0.5, "الوحدة": "لتر", "سعر_الوحدة": 8.0} + ], + "labor": [ + {"النوع": "عامل طرق", "العدد": 6, "المدة": 0.01, "سعر_اليوم": 150.0}, + {"النوع": "مراقب فني", "العدد": 1, "المدة": 0.01, "سعر_اليوم": 300.0} + ], + "equipment": [ + {"النوع": "فرادة إسفلت", "العدد": 1, "المدة": 0.01, "سعر_اليوم": 4000.0}, + {"النوع": "رصاصة دمك", "العدد": 2, "المدة": 0.01, "سعر_اليوم": 1800.0}, + {"النوع": "سيارة رش إسفلت", "العدد": 1, "المدة": 0.01, "سعر_اليوم": 2000.0}, + {"النوع": "شاحنة نقل", "العدد": 4, "المدة": 0.01, "سعر_اليوم": 1200.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.12, + "tags": ["طرق", "إسفلت", "سطحية"] + }, + + # نماذج أعمال كهربائية + "عمود_إنارة_10_متر": { + "category": "أعمال_كهربائية", + "name": "عمود إنارة 10 متر", + "description": "توريد وتركيب عمود إنارة جلفانيزي بارتفاع 10 متر مع ذراع مفردة وكشاف LED بقدرة 150 واط", + "unit": "عدد", + "components": { + "materials": [ + {"الاسم": "عمود إنارة جلفانيزي 10 متر", "الكمية": 1, "الوحدة": "عدد", "سعر_الوحدة": 3500.0}, + {"الاسم": "ذراع إنارة مفردة", "الكمية": 1, "الوحدة": "عدد", "سعر_الوحدة": 450.0}, + {"الاسم": "كشاف LED 150 واط", "الكمية": 1, "الوحدة": "عدد", "سعر_الوحدة": 850.0}, + {"الاسم": "كابل كهرباء 3×4 مم²", "الكمية": 15, "الوحدة": "م.ط", "سعر_الوحدة": 32.0}, + {"الاسم": "قاعدة خرسانية مسلحة", "الكمية": 0.25, "الوحدة": "م3", "سعر_الوحدة": 750.0} + ], + "labor": [ + {"النوع": "كهربائي", "العدد": 2, "المدة": 1, "سعر_اليوم": 270.0}, + {"النوع": "مساعد كهربائي", "العدد": 2, "المدة": 1, "سعر_اليوم": 120.0}, + {"النوع": "عامل خرسانة", "العدد": 2, "المدة": 0.5, "سعر_اليوم": 150.0} + ], + "equipment": [ + {"النوع": "ونش شوكة", "العدد": 1, "المدة": 0.5, "سعر_اليوم": 1500.0}, + {"النوع": "حفار صغير", "العدد": 1, "المدة": 0.2, "سعر_اليوم": 1200.0} + ] + }, + "admin_expenses": 0.05, + "profit_margin": 0.12, + "tags": ["كهرباء", "إنارة", "LED"] + } + } + } + + return templates + + def _create_default_market_prices(self) -> Dict[str, Dict[str, Any]]: + """إنشاء بيانات افتراضية لأسعار السوق السعودي""" + current_date = datetime.now().strftime("%Y-%m-%d") + + prices = { + "metadata": { + "last_update": current_date, + "source": "أسعار السوق السعودي الافتراضية", + "disclaimer": "هذه الأسعار تقريبية وقد تختلف حسب المنطقة والكميات والموردين" + }, + "materials": { + # مواد الخرسانة + "خرسانة_جاهزة": { + "name": "خرسانة جاهزة", + "unit": "م3", + "current_price": 750.0, + "previous_price": 730.0, + "price_trend": "up", + "category": "أعمال خرسانية", + "specifications": "خرسانة جاهزة بقوة 350 كجم/سم2", + "note": "السعر يشمل توريد فقط، الضخ بتكلفة إضافية", + "price_history": [ + {"date": "2023-06-01", "price": 700.0}, + {"date": "2023-09-01", "price": 715.0}, + {"date": "2023-12-01", "price": 730.0}, + {"date": current_date, "price": 750.0} + ] + }, + "حديد_تسليح": { + "name": "حديد تسليح", + "unit": "طن", + "current_price": 5500.0, + "previous_price": 5200.0, + "price_trend": "up", + "category": "أعمال خرسانية", + "specifications": "حديد تسليح قطر 8-32 مم، انتاج سابك", + "note": "السعر يتغير بشكل دوري حسب أسعار الحديد العالمية", + "price_history": [ + {"date": "2023-06-01", "price": 4800.0}, + {"date": "2023-09-01", "price": 5000.0}, + {"date": "2023-12-01", "price": 5200.0}, + {"date": current_date, "price": 5500.0} + ] + }, + "أسمنت": { + "name": "أسمنت", + "unit": "كيس", + "current_price": 30.0, + "previous_price": 28.0, + "price_trend": "up", + "category": "أعمال خرسانية", + "specifications": "أسمنت بورتلاندي عادي، كيس 50 كجم", + "note": "السعر للكميات الكبيرة", + "price_history": [ + {"date": "2023-06-01", "price": 25.0}, + {"date": "2023-09-01", "price": 27.0}, + {"date": "2023-12-01", "price": 28.0}, + {"date": current_date, "price": 30.0} + ] + }, + + # مواد الطرق والإسفلت + "خلطة_إسفلتية_ساخنة": { + "name": "خلطة إسفلتية ساخنة", + "unit": "طن", + "current_price": 400.0, + "previous_price": 380.0, + "price_trend": "up", + "category": "أعمال طرق", + "specifications": "خلطة إسفلتية ساخنة للطبقة السطحية", + "note": "السعر يشمل التوريد من المصنع، النقل بتكلفة إضافية", + "price_history": [ + {"date": "2023-06-01", "price": 350.0}, + {"date": "2023-09-01", "price": 370.0}, + {"date": "2023-12-01", "price": 380.0}, + {"date": current_date, "price": 400.0} + ] + }, + + # مواد صحية + "مواسير_بلاستيك_UPVC": { + "name": "مواسير بلاستيك UPVC قطر 200 مم", + "unit": "م.ط", + "current_price": 180.0, + "previous_price": 165.0, + "price_trend": "up", + "category": "أعمال صحية", + "specifications": "مواسير بلاستيك UPVC قطر 200 مم لشبكات الصرف الصحي", + "note": "السعر للكميات الكبيرة", + "price_history": [ + {"date": "2023-06-01", "price": 150.0}, + {"date": "2023-09-01", "price": 160.0}, + {"date": "2023-12-01", "price": 165.0}, + {"date": current_date, "price": 180.0} + ] + }, + + # مواد كهربائية + "كشاف_LED": { + "name": "كشاف LED 150 واط", + "unit": "عدد", + "current_price": 850.0, + "previous_price": 820.0, + "price_trend": "up", + "category": "أعمال كهربائية", + "specifications": "كشاف إنارة LED بقدرة 150 واط للاستخدام الخارجي، IP65", + "note": "السعر شامل الضريبة", + "price_history": [ + {"date": "2023-06-01", "price": 780.0}, + {"date": "2023-09-01", "price": 800.0}, + {"date": "2023-12-01", "price": 820.0}, + {"date": current_date, "price": 850.0} + ] + } + }, + "labor": { + "عامل_خرسانة": { + "name": "عامل خرسانة", + "unit": "يوم", + "current_price": 150.0, + "previous_price": 140.0, + "price_trend": "up", + "category": "عمالة", + "specifications": "عامل لصب وتسوية الخرسانة", + "note": "أجرة اليوم الواحد لا تشمل السكن والمواصلات", + "price_history": [ + {"date": "2023-06-01", "price": 130.0}, + {"date": "2023-09-01", "price": 135.0}, + {"date": "2023-12-01", "price": 140.0}, + {"date": current_date, "price": 150.0} + ] + }, + "مهندس_موقع": { + "name": "مهندس موقع", + "unit": "يوم", + "current_price": 500.0, + "previous_price": 480.0, + "price_trend": "up", + "category": "إشراف", + "specifications": "مهندس إشراف موقع", + "note": "أجرة اليوم الواحد لا تشمل السكن والمواصلات", + "price_history": [ + {"date": "2023-06-01", "price": 450.0}, + {"date": "2023-09-01", "price": 470.0}, + {"date": "2023-12-01", "price": 480.0}, + {"date": current_date, "price": 500.0} + ] + } + }, + "equipment": { + "حفار_صغير": { + "name": "حفار صغير", + "unit": "يوم", + "current_price": 1200.0, + "previous_price": 1150.0, + "price_trend": "up", + "category": "معدات حفر", + "specifications": "حفار صغير (بوبكات) بقدرة 70 حصان", + "note": "السعر يشمل المشغل والوقود", + "price_history": [ + {"date": "2023-06-01", "price": 1100.0}, + {"date": "2023-09-01", "price": 1120.0}, + {"date": "2023-12-01", "price": 1150.0}, + {"date": current_date, "price": 1200.0} + ] + }, + "فرادة_إسفلت": { + "name": "فرادة إسفلت", + "unit": "يوم", + "current_price": 4000.0, + "previous_price": 3800.0, + "price_trend": "up", + "category": "معدات طرق", + "specifications": "فرادة إسفلت بعرض 3 متر", + "note": "السعر يشمل المشغل والوقود", + "price_history": [ + {"date": "2023-06-01", "price": 3500.0}, + {"date": "2023-09-01", "price": 3650.0}, + {"date": "2023-12-01", "price": 3800.0}, + {"date": current_date, "price": 4000.0} + ] + } + } + } + + return prices + + def get_all_templates(self) -> Dict[str, Dict[str, Any]]: + """الحصول على جميع القوالب النموذجية""" + return self.templates + + def get_templates_by_category(self, category_id: str) -> List[Dict[str, Any]]: + """الحصول على القوالب النموذجية حسب الفئة""" + result = [] + + # التحقق من وجود الفئة + if category_id not in self.templates["categories"]: + return result + + # جمع القوالب التي تنتمي إلى الفئة المحددة + for template_id, template in self.templates["templates"].items(): + if template["category"] == category_id: + template_copy = template.copy() + template_copy["id"] = template_id + result.append(template_copy) + + return result + + def get_template_by_id(self, template_id: str) -> Optional[Dict[str, Any]]: + """الحصول على قالب نموذجي بواسطة المعرف""" + if template_id in self.templates["templates"]: + template = self.templates["templates"][template_id].copy() + template["id"] = template_id + return template + + return None + + def add_template(self, template_data: Dict[str, Any]) -> str: + """إضافة قالب نموذجي جديد""" + # إنشاء معرف فريد للقالب + template_name = template_data.get("name", "").strip() + if not template_name: + raise ValueError("يجب تحديد اسم القالب") + + # تحويل الاسم إلى معرف (باستبدال المسافات بالشرطات السفلية وإزالة الأحرف الخاصة) + import re + template_id = re.sub(r'[^\w\s]', '', template_name) + template_id = template_id.replace(" ", "_") + + # إضافة رقم عشوائي لتجنب التكرار + import random + if template_id in self.templates["templates"]: + template_id = f"{template_id}_{random.randint(1000, 9999)}" + + # إضافة القالب إلى القائمة + self.templates["templates"][template_id] = template_data + + # حفظ التغييرات + self._save_templates(self.templates) + + return template_id + + def update_template(self, template_id: str, template_data: Dict[str, Any]) -> bool: + """تحديث قالب نموذجي موجود""" + if template_id not in self.templates["templates"]: + return False + + # تحديث القالب + self.templates["templates"][template_id] = template_data + + # حفظ التغييرات + self._save_templates(self.templates) + + return True + + def delete_template(self, template_id: str) -> bool: + """حذف قالب نموذجي""" + if template_id not in self.templates["templates"]: + return False + + # حذف القالب + del self.templates["templates"][template_id] + + # حفظ التغييرات + self._save_templates(self.templates) + + return True + + def get_market_prices(self, category: Optional[str] = None, item_type: Optional[str] = None) -> Dict[str, Any]: + """الحصول على أسعار السوق السعودي""" + result = { + "metadata": self.market_prices["metadata"] + } + + # تحديد نوع العناصر المطلوبة + sections = [] + if item_type: + if item_type in ["materials", "المواد"]: + sections = ["materials"] + elif item_type in ["labor", "العمالة"]: + sections = ["labor"] + elif item_type in ["equipment", "المعدات"]: + sections = ["equipment"] + else: + sections = ["materials", "labor", "equipment"] + + # جمع العناصر + for section in sections: + result[section] = {} + for item_id, item_data in self.market_prices[section].items(): + if not category or (item_data.get("category", "") == category): + result[section][item_id] = item_data + + return result + + def update_market_price(self, item_type: str, item_id: str, new_price: float) -> bool: + """تحديث سعر في قائمة أسعار السوق""" + section = "" + if item_type in ["materials", "المواد"]: + section = "materials" + elif item_type in ["labor", "العمالة"]: + section = "labor" + elif item_type in ["equipment", "المعدات"]: + section = "equipment" + else: + return False + + if item_id not in self.market_prices[section]: + return False + + # تحديث السعر + current_price = self.market_prices[section][item_id]["current_price"] + self.market_prices[section][item_id]["previous_price"] = current_price + self.market_prices[section][item_id]["current_price"] = new_price + + # تحديد اتجاه السعر + if new_price > current_price: + self.market_prices[section][item_id]["price_trend"] = "up" + elif new_price < current_price: + self.market_prices[section][item_id]["price_trend"] = "down" + else: + self.market_prices[section][item_id]["price_trend"] = "stable" + + # إضافة السعر الجديد إلى تاريخ الأسعار + current_date = datetime.now().strftime("%Y-%m-%d") + self.market_prices[section][item_id]["price_history"].append({ + "date": current_date, + "price": new_price + }) + + # تحديث تاريخ آخر تحديث + self.market_prices["metadata"]["last_update"] = current_date + + # حفظ التغييرات + self._save_market_prices(self.market_prices) + + return True + + def add_market_price_item(self, item_type: str, item_data: Dict[str, Any]) -> str: + """إضافة عنصر جديد إلى قائمة أسعار السوق""" + section = "" + if item_type in ["materials", "المواد"]: + section = "materials" + elif item_type in ["labor", "العمالة"]: + section = "labor" + elif item_type in ["equipment", "المعدات"]: + section = "equipment" + else: + raise ValueError("نوع العنصر غير صحيح") + + # التحقق من البيانات الأساسية + if "name" not in item_data or "current_price" not in item_data or "unit" not in item_data: + raise ValueError("يجب تحديد الاسم والسعر الحالي والوحدة") + + # إنشاء معرف فريد للعنصر + item_name = item_data["name"].strip() + import re + item_id = re.sub(r'[^\w\s]', '', item_name) + item_id = item_id.replace(" ", "_") + + # إضافة رقم عشوائي لتجنب التكرار + import random + if item_id in self.market_prices[section]: + item_id = f"{item_id}_{random.randint(1000, 9999)}" + + # إعداد بيانات العنصر + current_date = datetime.now().strftime("%Y-%m-%d") + new_item = { + "name": item_name, + "unit": item_data["unit"], + "current_price": item_data["current_price"], + "previous_price": item_data.get("previous_price", item_data["current_price"]), + "price_trend": "stable", + "category": item_data.get("category", ""), + "specifications": item_data.get("specifications", ""), + "note": item_data.get("note", ""), + "price_history": [ + {"date": current_date, "price": item_data["current_price"]} + ] + } + + # إضافة العنصر إلى القائمة + self.market_prices[section][item_id] = new_item + + # تحديث تاريخ آخر تحديث + self.market_prices["metadata"]["last_update"] = current_date + + # حفظ التغييرات + self._save_market_prices(self.market_prices) + + return item_id + + def convert_template_to_item(self, template_id: str) -> Dict[str, Any]: + """تحويل قالب نموذجي إلى بند للاستخدام في حاسبة تكاليف البناء""" + template = self.get_template_by_id(template_id) + if not template: + raise ValueError("القالب غير موجود") + + # تحويل القالب إلى صيغة بند + item = { + "وصف_البند": template["description"], + "الكمية": 1.0, + "الوحدة": template["unit"], + "المواد": template["components"]["materials"], + "العمالة": template["components"]["labor"], + "المعدات": template["components"]["equipment"], + "المصاريف_الإدارية": template["admin_expenses"], + "هامش_الربح": template["profit_margin"], + "عوامل_التعديل": { + "location_factor": 1.0, + "time_factor": 1.0, + "risk_factor": 1.0, + "market_factor": 1.0 + } + } + + return item \ No newline at end of file diff --git a/modules/pricing/services/local_content_calculator.py b/modules/pricing/services/local_content_calculator.py new file mode 100644 index 0000000000000000000000000000000000000000..859c3d004512fc247e2a801608da335bc4c07f29 --- /dev/null +++ b/modules/pricing/services/local_content_calculator.py @@ -0,0 +1,577 @@ +""" +خدمة حساب المحتوى المحلي +""" +import pandas as pd +import numpy as np +from datetime import datetime +import os +import config + +class LocalContentCalculator: + """خدمة حساب وتحسين المحتوى المحلي""" + + def __init__(self): + """تهيئة خدمة حساب المحتوى المحلي""" + # تحميل بيانات المواد المحلية ونسب المحتوى المحلي + self.local_products = self._load_local_products() + self.local_services = self._load_local_services() + self.local_labor = self._load_local_labor() + + # تحديد الأوزان النسبية لمكونات المحتوى المحلي + self.component_weights = { + 'القوى العاملة': 0.3, # 30% من وزن المحتوى المحلي + 'المنتجات': 0.5, # 50% من وزن المحتوى المحلي + 'الخدمات': 0.2 # 20% من وزن المحتوى المحلي + } + + # تحديد المستهدفات (متطلبات المحتوى المحلي) + self.targets = { + 'القوى العاملة': 0.8, # 80% محتوى محلي للقوى العاملة + 'المنتجات': 0.7, # 70% محتوى محلي للمنتجات + 'الخدمات': 0.6 # 60% محتوى محلي للخدمات + } + + def _load_local_products(self): + """تحميل بيانات المنتجات المحلية ونسب المحتوى المحلي""" + # محاكاة تحميل البيانات من مصدر بيانات + local_products = { + 'خرسانة': { + 'نسبة_المحتوى_المحلي': 0.95, # 95% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'منتج محلي بالكامل' + }, + 'حديد تسليح': { + 'نسبة_المحتوى_المحلي': 0.70, # 70% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/مستورد', + 'ملاحظات': 'متوفر من مصانع محلية ومستورد' + }, + 'عزل مائي': { + 'نسبة_المحتوى_المحلي': 0.60, # 60% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/مستورد', + 'ملاحظات': 'منتج محلي متوفر بجودة معقولة' + }, + 'بلوك خرساني': { + 'نسبة_المحتوى_المحلي': 0.98, # 98% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'منتج محلي بالكامل' + }, + 'رخام': { + 'نسبة_المحتوى_المحلي': 0.80, # 80% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'متوفر من محاجر محلية' + }, + 'أثاث مكتبي': { + 'نسبة_المحتوى_المحلي': 0.75, # 75% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'يُصنع محليًا ويستخدم بعض المكونات المستوردة' + }, + 'أجهزة تكييف': { + 'نسبة_المحتوى_المحلي': 0.40, # 40% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/مستورد', + 'ملاحظات': 'تجميع محلي مع مكونات مستوردة' + }, + 'أنظمة إضاءة': { + 'نسبة_المحتوى_المحلي': 0.55, # 55% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/مستورد', + 'ملاحظات': 'متوفر محليًا وبجودة متفاوتة' + }, + 'زجاج': { + 'نسبة_المحتوى_المحلي': 0.65, # 65% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/مستورد', + 'ملاحظات': 'إنتاج محلي بمواصفات جيدة' + }, + 'أسلاك كهربائية': { + 'نسبة_المحتوى_المحلي': 0.85, # 85% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'تصنيع محلي بجودة عالية' + } + } + + # محاولة تحميل البيانات من ملف إذا كان متاحًا + try: + file_path = os.path.join(config.DATA_DIR, 'local_products.csv') + if os.path.exists(file_path): + df = pd.read_csv(file_path, encoding='utf-8') + local_products = {} + for _, row in df.iterrows(): + local_products[row['اسم_المنتج']] = { + 'نسبة_المحتوى_المحلي': row['نسبة_المحتوى_المحلي'], + 'بديل_محلي': row['بديل_محلي'], + 'مصدر': row['مصدر'], + 'ملاحظات': row['ملاحظات'] + } + except Exception as e: + print(f"خطأ في تحميل بيانات المنتجات المحلية: {str(e)}") + + return local_products + + def _load_local_services(self): + """تحميل بيانات الخدمات المحلية ونسب المحتوى المحلي""" + # محاكاة تحميل البيانات من مصدر بيانات + local_services = { + 'تصميم معماري': { + 'نسبة_المحتوى_المحلي': 0.90, # 90% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'متوفرة من مكاتب استشارية محلية' + }, + 'إشراف هندسي': { + 'نسبة_المحتوى_المحلي': 0.85, # 85% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'متوفر من شركات محلية' + }, + 'خدمات تنسيق المواقع': { + 'نسبة_المحتوى_المحلي': 0.80, # 80% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'شركات محلية متخصصة' + }, + 'خدمات أمن وسلامة': { + 'نسبة_المحتوى_المحلي': 0.95, # 95% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'شركات محلية متخصصة' + }, + 'استشارات بيئية': { + 'نسبة_المحتوى_المحلي': 0.65, # 65% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/دولي', + 'ملاحظات': 'متوفرة محليًا مع بعض الخبرات الأجنبية' + }, + 'دراسات جدوى': { + 'نسبة_المحتوى_المحلي': 0.70, # 70% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/دولي', + 'ملاحظات': 'متوفرة من مكاتب استشارية محلية' + }, + 'خدمات نقل': { + 'نسبة_المحتوى_المحلي': 0.90, # 90% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'شركات نقل محلية متعددة' + }, + 'صيانة ونظافة': { + 'نسبة_المحتوى_المحلي': 0.95, # 95% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'شركات محلية متخصصة' + } + } + + # محاولة تحميل البيانات من ملف إذا كان متاحًا + try: + file_path = os.path.join(config.DATA_DIR, 'local_services.csv') + if os.path.exists(file_path): + df = pd.read_csv(file_path, encoding='utf-8') + local_services = {} + for _, row in df.iterrows(): + local_services[row['اسم_الخدمة']] = { + 'نسبة_المحتوى_المحلي': row['نسبة_المحتوى_المحلي'], + 'بديل_محلي': row['بديل_محلي'], + 'مصدر': row['مصدر'], + 'ملاحظات': row['ملاحظات'] + } + except Exception as e: + print(f"خطأ في تحميل بيانات الخدمات المحلية: {str(e)}") + + return local_services + + def _load_local_labor(self): + """تحميل بيانات القوى العاملة المحلية ونسب المحتوى المحلي""" + # محاكاة تحميل البيانات من مصدر بيانات + local_labor = { + 'عمال بناء': { + 'نسبة_المحتوى_المحلي': 0.60, # 60% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/أجنبي', + 'ملاحظات': 'متوفر محليًا مع نسبة من العمالة الأجنبية' + }, + 'مهندسون': { + 'نسبة_المحتوى_المحلي': 0.75, # 75% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/أجنبي', + 'ملاحظات': 'كفاءات محلية متوفرة' + }, + 'فنيون': { + 'نسبة_المحتوى_المحلي': 0.65, # 65% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/أجنبي', + 'ملاحظات': 'متوفر محليًا بنسب متفاوتة' + }, + 'إداريون': { + 'نسبة_المحتوى_المحلي': 0.90, # 90% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'معظمهم من الكوادر المحلية' + }, + 'مشرفون': { + 'نسبة_المحتوى_المحلي': 0.80, # 80% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي', + 'ملاحظات': 'معظمهم من الكوادر المحلية' + }, + 'مصممون': { + 'نسبة_المحتوى_المحلي': 0.70, # 70% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/أجنبي', + 'ملاحظات': 'كفاءات محلية مع بعض الخبرات الأجنبية' + }, + 'عمال مهرة': { + 'نسبة_المحتوى_المحلي': 0.55, # 55% محتوى محلي + 'بديل_محلي': True, + 'مصدر': 'محلي/أجنبي', + 'ملاحظات': 'نسبة من العمالة الأجنبية ذات الخبرة' + } + } + + # محاولة تحميل البيانات من ملف إذا كان متاحًا + try: + file_path = os.path.join(config.DATA_DIR, 'local_labor.csv') + if os.path.exists(file_path): + df = pd.read_csv(file_path, encoding='utf-8') + local_labor = {} + for _, row in df.iterrows(): + local_labor[row['فئة_العمالة']] = { + 'نسبة_المحتوى_المحلي': row['نسبة_المحتوى_المحلي'], + 'بديل_محلي': row['بديل_محلي'], + 'مصدر': row['مصدر'], + 'ملاحظات': row['ملاحظات'] + } + except Exception as e: + print(f"خطأ في تحميل بيانات القوى العاملة المحلية: {str(e)}") + + return local_labor + + def calculate_project_local_content(self, project_data): + """ + حساب نسبة المحتوى المحلي للمشروع + + المعلمات: + project_data: بيانات المشروع، تتضمن مكونات المنتجات والخدمات والقوى العاملة + + إرجاع: + نسبة المحتوى المحلي الإجمالية، وتفاصيل حسب كل مكون + """ + # تهيئة نتائج الحساب + results = { + 'نسبة_المحتوى_المحلي_الإجمالية': 0, + 'تفاصيل_المكونات': { + 'المنتجات': {'نسبة': 0, 'تفاصيل': {}}, + 'الخدمات': {'نسبة': 0, 'تفاصيل': {}}, + 'القوى العاملة': {'نسبة': 0, 'تفاصيل': {}} + }, + 'ملخص_المحتوى_المحلي': {}, + 'توصيات_التحسين': [] + } + + # حساب نسبة المحتوى المحلي للمنتجات + if 'المنتجات' in project_data: + products_local_content = self._calculate_products_local_content(project_data['المنتجات']) + results['تفاصيل_المكونات']['المنتجات'] = products_local_content + + # حساب نسبة المحتوى المحلي للخدمات + if 'الخدمات' in project_data: + services_local_content = self._calculate_services_local_content(project_data['الخدمات']) + results['تفاصيل_المكونات']['الخدمات'] = services_local_content + + # حساب نسبة المحتوى المحلي للقوى العاملة + if 'القوى العاملة' in project_data: + labor_local_content = self._calculate_labor_local_content(project_data['القوى العاملة']) + results['تفاصيل_المكونات']['القوى العاملة'] = labor_local_content + + # حساب النسبة الإجمالية للمحتوى المحلي بناءً على الأوزان النسبية + total_local_content = 0 + for component, weight in self.component_weights.items(): + if component in results['تفاصيل_المكونات']: + component_percentage = results['تفاصيل_المكونات'][component]['نسبة'] + total_local_content += component_percentage * weight + + results['نسبة_المحتوى_المحلي_الإجمالية'] = total_local_content + + # تحديد ملخص المحتوى المحلي ومقارنته بالمستهدف + for component, target in self.targets.items(): + if component in results['تفاصيل_المكونات']: + actual = results['تفاصيل_المكونات'][component]['نسبة'] + status = 'مطابق' if actual >= target else 'غير مطابق' + gap = round((target - actual) * 100, 2) if actual < target else 0 + + results['ملخص_المحتوى_المحلي'][component] = { + 'المستهدف': target * 100, + 'الفعلي': round(actual * 100, 2), + 'الحالة': status, + 'الفجوة (%)': gap + } + + # توليد توصيات لتحسين نسبة المحتوى المحلي + results['توصيات_التحسين'] = self._generate_improvement_recommendations(results) + + return results + + def _calculate_products_local_content(self, products_data): + """ + حساب نسبة المحتوى المحلي للمنتجات + + المعلمات: + products_data: بيانات المنتجات المستخدمة في المشروع + + إرجاع: + تفاصيل نسبة المحتوى المحلي للمنتجات + """ + total_cost = 0 + local_content_value = 0 + details = {} + + for product_name, product_info in products_data.items(): + quantity = product_info.get('الكمية', 0) + unit_price = product_info.get('سعر_الوحدة', 0) + total_product_cost = quantity * unit_price + + # البحث عن نسبة المحتوى المحلي للمنتج + local_content_percentage = 0 + if product_name in self.local_products: + local_content_percentage = self.local_products[product_name]['نسبة_المحتوى_المحلي'] + + # حساب قيمة المحتوى المحلي للمنتج + product_local_content_value = total_product_cost * local_content_percentage + + # تحديث الإجماليات + total_cost += total_product_cost + local_content_value += product_local_content_value + + # تسجيل التفاصيل + details[product_name] = { + 'الكمية': quantity, + 'سعر_الوحدة': unit_price, + 'التكلفة_الإجمالية': total_product_cost, + 'نسبة_المحتوى_المحلي': local_content_percentage, + 'قيمة_المحتوى_المحلي': product_local_content_value, + 'مصدر': self.local_products.get(product_name, {}).get('مصدر', 'غير معروف'), + 'ملاحظات': self.local_products.get(product_name, {}).get('ملاحظات', '') + } + + # حساب النسبة الإجمالية للمحتوى المحلي للمنتجات + local_content_percentage = local_content_value / total_cost if total_cost > 0 else 0 + + return { + 'نسبة': local_content_percentage, + 'إجمالي_التكلفة': total_cost, + 'قيمة_المحتوى_المحلي': local_content_value, + 'تفاصيل': details + } + + def _calculate_services_local_content(self, services_data): + """ + حساب نسبة المحتوى المحلي للخدمات + + المعلمات: + services_data: بيانات الخدمات المستخدمة في المشروع + + إرجاع: + تفاصيل نسبة المحتوى المحلي للخدمات + """ + total_cost = 0 + local_content_value = 0 + details = {} + + for service_name, service_info in services_data.items(): + cost = service_info.get('التكلفة', 0) + + # البحث عن نسبة المحتوى المحلي للخدمة + local_content_percentage = 0 + if service_name in self.local_services: + local_content_percentage = self.local_services[service_name]['نسبة_المحتوى_المحلي'] + + # حساب قيمة المحتوى المحلي للخدمة + service_local_content_value = cost * local_content_percentage + + # تحديث الإجماليات + total_cost += cost + local_content_value += service_local_content_value + + # تسجيل التفاصيل + details[service_name] = { + 'التكلفة': cost, + 'نسبة_المحتوى_المحلي': local_content_percentage, + 'قيمة_المحتوى_المحلي': service_local_content_value, + 'مصدر': self.local_services.get(service_name, {}).get('مصدر', 'غير معروف'), + 'ملاحظات': self.local_services.get(service_name, {}).get('ملاحظات', '') + } + + # حساب النسبة الإجمالية للمحتوى المحلي للخدمات + local_content_percentage = local_content_value / total_cost if total_cost > 0 else 0 + + return { + 'نسبة': local_content_percentage, + 'إجمالي_التكلفة': total_cost, + 'قيمة_المحتوى_المحلي': local_content_value, + 'تفاصيل': details + } + + def _calculate_labor_local_content(self, labor_data): + """ + حساب نسبة المحتوى المحلي للقوى العاملة + + المعلمات: + labor_data: بيانات القوى العاملة المستخدمة في المشروع + + إرجاع: + تفاصيل نسبة المحتوى المحلي للقوى العاملة + """ + total_cost = 0 + local_content_value = 0 + details = {} + + for labor_type, labor_info in labor_data.items(): + count = labor_info.get('العدد', 0) + monthly_salary = labor_info.get('الراتب_الشهري', 0) + duration_months = labor_info.get('المدة_بالأشهر', 0) + + total_labor_cost = count * monthly_salary * duration_months + + # البحث عن نسبة المحتوى المحلي للقوى العاملة + local_content_percentage = 0 + if labor_type in self.local_labor: + local_content_percentage = self.local_labor[labor_type]['نسبة_المحتوى_المحلي'] + + # حساب قيمة المحتوى المحلي للقوى العاملة + labor_local_content_value = total_labor_cost * local_content_percentage + + # تحديث الإجماليات + total_cost += total_labor_cost + local_content_value += labor_local_content_value + + # تسجيل التفاصيل + details[labor_type] = { + 'العدد': count, + 'الراتب_الشهري': monthly_salary, + 'المدة_بالأشهر': duration_months, + 'التكلفة_الإجمالية': total_labor_cost, + 'نسبة_المحتوى_المحلي': local_content_percentage, + 'قيمة_المحتوى_المحلي': labor_local_content_value, + 'مصدر': self.local_labor.get(labor_type, {}).get('مصدر', 'غير معروف'), + 'ملاحظات': self.local_labor.get(labor_type, {}).get('ملاحظات', '') + } + + # حساب النسبة الإجمالية للمحتوى المحلي للقوى العاملة + local_content_percentage = local_content_value / total_cost if total_cost > 0 else 0 + + return { + 'نسبة': local_content_percentage, + 'إجمالي_التكلفة': total_cost, + 'قيمة_المحتوى_المحلي': local_content_value, + 'تفاصيل': details + } + + def _generate_improvement_recommendations(self, results): + """ + توليد توصيات لتحسين نسبة المحتوى المحلي + + المعلمات: + results: نتائج حساب المحتوى المحلي + + إرجاع: + قائمة بالتوصيات لتحسين نسبة المحتوى المحلي + """ + recommendations = [] + + # تحليل المكونات التي تحتاج إلى تحسين + for component, summary in results['ملخص_المحتوى_المحلي'].items(): + if summary['الحالة'] == 'غير مطابق': + if component == 'المنتجات': + # تحديد المنتجات ذات المحتوى المحلي المنخفض + low_content_products = [] + for product, details in results['تفاصيل_المكونات']['المنتجات']['تفاصيل'].items(): + if details['نسبة_المحتوى_المحلي'] < 0.5: # أقل من 50% + low_content_products.append({ + 'اسم': product, + 'نسبة_المحتوى_المحلي': details['نسبة_المحتوى_المحلي'], + 'التكلفة_الإجمالية': details['التكلفة_الإجمالية'] + }) + + elif component == 'الخدمات': + # تحديد البنود ذات المحتوى المحلي المنخفض + low_content_services = [] + for service, details in results['تفاصيل_المكونات']['الخدمات']['تفاصيل'].items(): + if details['نسبة_المحتوى_المحلي'] < 0.5: # أقل من 50% + low_content_services.append({ + 'اسم': service, + 'نسبة_المحتوى_المحلي': details['نسبة_المحتوى_المحلي'], + 'التكلفة': details['التكلفة'] + }) + + elif component == 'القوى العاملة': + # تحديد فئات العمالة ذات المحتوى المحلي المنخفض + low_content_labor = [] + for labor_type, details in results['تفاصيل_المكونات']['القوى العاملة']['تفاصيل'].items(): + if details['نسبة_المحتوى_المحلي'] < 0.5: # أقل من 50% + low_content_labor.append({ + 'اسم': labor_type, + 'نسبة_المحتوى_المحلي': details['نسبة_المحتوى_المحلي'], + 'التكلفة_الإجمالية': details['التكلفة_الإجمالية'] + }) + + # إنشاء توصيات لتحسين المحتوى المحلي + # توصيات للمنتجات + if 'المنتجات' in results['ملخص_المحتوى_المحلي'] and results['ملخص_المحتوى_المحلي']['المنتجات']['الحالة'] == 'غير مطابق': + low_content_products = [] + for product, details in results['تفاصيل_المكونات']['المنتجات']['تفاصيل'].items(): + if details['نسبة_المحتوى_المحلي'] < 0.5: + low_content_products.append({ + 'اسم': product, + 'نسبة_المحتوى_المحلي': details['نسبة_المحتوى_المحلي'] + }) + + if low_content_products: + recommendations.append(f"استبدال المنتجات ذات المحتوى المحلي المنخفض: {', '.join([p['اسم'] for p in low_content_products[:3]])}") + recommendations.append("البحث عن موردين محليين للمنتجات ذات الأولوية العالية") + + # توصيات للخدمات + if 'الخدمات' in results['ملخص_المحتوى_المحلي'] and results['ملخص_المحتوى_المحلي']['الخدمات']['الحالة'] == 'غير مطابق': + low_content_services = [] + for service, details in results['تفاصيل_المكونات']['الخدمات']['تفاصيل'].items(): + if details['نسبة_المحتوى_المحلي'] < 0.5: + low_content_services.append({ + 'اسم': service, + 'نسبة_المحتوى_المحلي': details['نسبة_المحتوى_المحلي'] + }) + + if low_content_services: + recommendations.append(f"تحسين نسبة المحتوى المحلي للخدمات: {', '.join([s['اسم'] for s in low_content_services[:3]])}") + recommendations.append("التعاقد مع شركات خدمية محلية") + + # توصيات للقوى العاملة + if 'القوى العاملة' in results['ملخص_المحتوى_المحلي'] and results['ملخص_المحتوى_المحلي']['القوى العاملة']['الحالة'] == 'غير مطابق': + low_content_labor = [] + for labor_type, details in results['تفاصيل_المكونات']['القوى العاملة']['تفاصيل'].items(): + if details['نسبة_المحتوى_المحلي'] < 0.5: + low_content_labor.append({ + 'اسم': labor_type, + 'نسبة_المحتوى_المحلي': details['نسبة_المحتوى_المحلي'] + }) + + if low_content_labor: + recommendations.append(f"زيادة توظيف العمالة المحلية في الفئات: {', '.join([l['اسم'] for l in low_content_labor[:3]])}") + recommendations.append("الاستثمار في برامج تدريب وتأهيل الكوادر المحلية") + + # توصيات عامة + if 'المنتجات' in results['ملخص_المحتوى_المحلي'] and results['ملخص_المحتوى_المحلي']['المنتجات'].get('الفجوة (%)', 0) > 10: + recommendations.append(f"خطة تطوير المحتوى المحلي للمنتجات لتقليل الفجوة البالغة {results['ملخص_المحتوى_المحلي']['المنتجات']['الفجوة (%)']}%") + + if 'الخدمات' in results['ملخص_المحتوى_المحلي'] and results['ملخص_المحتوى_المحلي']['الخدمات'].get('الفجوة (%)', 0) > 10: + recommendations.append(f"خطة تطوير المحتوى المحلي للخدمات لتقليل الفجوة البالغة {results['ملخص_المحتوى_المحلي']['الخدمات']['الفجوة (%)']}%") + + if 'القوى العاملة' in results['ملخص_المحتوى_المحلي'] and results['ملخص_المحتوى_المحلي']['القوى العاملة'].get('الفجوة (%)', 0) > 10: + recommendations.append(f"خطة تطوير المحتوى المحلي للقوى العاملة لتقليل الفجوة البالغة {results['ملخص_المحتوى_المحلي']['القوى العاملة']['الفجوة (%)']}%") + + return recommendations \ No newline at end of file diff --git a/modules/pricing/services/price_prediction.py b/modules/pricing/services/price_prediction.py new file mode 100644 index 0000000000000000000000000000000000000000..8579c10d5cb8f388dff85c08a6131a2e0dd44afe --- /dev/null +++ b/modules/pricing/services/price_prediction.py @@ -0,0 +1,444 @@ +""" +خدمة التنبؤ بالأسعار +""" + +import pandas as pd +import numpy as np +import joblib +import os +from datetime import datetime, timedelta +from sklearn.ensemble import RandomForestRegressor +from sklearn.model_selection import train_test_split +from sklearn.preprocessing import StandardScaler +from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score + +import config + + +class PricePrediction: + """خدمة التنبؤ بالأسعار باستخدام التعلم الآلي""" + + def __init__(self): + """تهيئة خدمة التنبؤ بالأسعار""" + self.model_path = config.PRICE_PREDICTION_MODEL + self.model = self._load_model() + self.scaler = None + self.materials_data = self._load_materials_data() + self.market_indices = self._load_market_indices() + + def _load_model(self): + """تحميل نموذج التنبؤ المدرب مسبقاً""" + try: + if os.path.exists(self.model_path): + model = joblib.load(self.model_path) + return model + else: + # إذا لم يكن النموذج موجوداً، قم بإنشاء نموذج جديد + model = RandomForestRegressor( + n_estimators=100, + max_depth=15, + min_samples_split=5, + min_samples_leaf=2, + random_state=42 + ) + return model + except Exception as e: + print(f"خطأ في تحميل نموذج التنبؤ: {str(e)}") + return RandomForestRegressor(random_state=42) + + def _load_materials_data(self): + """تحميل بيانات المواد وأسعارها التاريخية""" + # محاكاة تحميل البيانات من مصدر بيانات + materials_data = { + 'خرسانة': { + 'تاريخ': [datetime(2025, 1, 1) - timedelta(days=30*i) for i in range(12)], + 'سعر': [750, 740, 735, 730, 720, 715, 710, 700, 695, 690, 685, 680], + 'وحدة': 'م3' + }, + 'حديد تسليح': { + 'تاريخ': [datetime(2025, 1, 1) - timedelta(days=30*i) for i in range(12)], + 'سعر': [5500, 5450, 5400, 5350, 5300, 5250, 5200, 5150, 5100, 5050, 5000, 4950], + 'وحدة': 'طن' + }, + 'إسمنت': { + 'تاريخ': [datetime(2025, 1, 1) - timedelta(days=30*i) for i in range(12)], + 'سعر': [25, 25, 24.5, 24.5, 24, 24, 23.5, 23.5, 23, 23, 22.5, 22.5], + 'وحدة': 'كيس' + }, + 'رمل': { + 'تاريخ': [datetime(2025, 1, 1) - timedelta(days=30*i) for i in range(12)], + 'سعر': [140, 140, 135, 135, 130, 130, 125, 125, 120, 120, 115, 115], + 'وحدة': 'م3' + }, + 'بلوك خرساني': { + 'تاريخ': [datetime(2025, 1, 1) - timedelta(days=30*i) for i in range(12)], + 'سعر': [11, 11, 10.5, 10.5, 10, 10, 9.5, 9.5, 9, 9, 8.5, 8.5], + 'وحدة': 'قطعة' + } + } + return materials_data + + def _load_market_indices(self): + """تحميل مؤشرات السوق المؤثرة على الأسعار""" + # محاكاة تحميل البيانات من مصدر بيانات + market_indices = { + 'تاريخ': [datetime(2025, 1, 1) - timedelta(days=30*i) for i in range(12)], + 'مؤشر_البناء': [105, 104, 103, 102, 101, 100, 99, 98, 97, 96, 95, 94], + 'مؤشر_النفط': [80, 79, 78, 77, 76, 75, 74, 73, 72, 71, 70, 69], + 'مؤشر_سعر_الصرف': [3.75, 3.75, 3.75, 3.75, 3.75, 3.75, 3.75, 3.75, 3.75, 3.75, 3.75, 3.75], + 'مؤشر_التضخم': [2.5, 2.4, 2.3, 2.2, 2.1, 2.0, 1.9, 1.8, 1.7, 1.6, 1.5, 1.4] + } + return market_indices + + def train(self, training_data=None): + """ + تدريب نموذج التنبؤ بالأسعار + + المعلمات: + training_data: بيانات التدريب (اختياري)، إذا لم يتم توفيرها سيتم استخدام البيانات المتاحة + + إرجاع: + مؤشرات أداء النموذج + """ + # تجهيز بيانات التدريب + if training_data is None: + # استخدام البيانات المتاحة لتوليد مجموعة تدريب + X, y = self._prepare_training_data() + else: + X, y = self._extract_features_target(training_data) + + # تقسيم البيانات إلى تدريب واختبار + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42) + + # تطبيع البيانات + self.scaler = StandardScaler() + X_train_scaled = self.scaler.fit_transform(X_train) + X_test_scaled = self.scaler.transform(X_test) + + # تدريب النموذج + self.model.fit(X_train_scaled, y_train) + + # تقييم النموذج + y_pred = self.model.predict(X_test_scaled) + + # حساب مؤشرات الأداء + mae = mean_absolute_error(y_test, y_pred) + rmse = np.sqrt(mean_squared_error(y_test, y_pred)) + r2 = r2_score(y_test, y_pred) + + # حفظ النموذج + try: + joblib.dump(self.model, self.model_path) + joblib.dump(self.scaler, os.path.join(os.path.dirname(self.model_path), 'price_scaler.pkl')) + except Exception as e: + print(f"خطأ في حفظ النموذج: {str(e)}") + + return { + 'mae': mae, + 'rmse': rmse, + 'r2': r2 + } + + def _prepare_training_data(self): + """تجهيز بيانات التدريب من البيانات المتاحة""" + # توليد بيانات تدريب افتراضية + data = [] + target = [] + + # استخدام بيانات المواد وأسعارها التاريخية + for material_name, material_info in self.materials_data.items(): + for i in range(len(material_info['تاريخ'])): + # استخراج المؤشرات في التاريخ المقابل + date_index = self.market_indices['تاريخ'].index(material_info['تاريخ'][i]) if material_info['تاريخ'][i] in self.market_indices['تاريخ'] else 0 + + # تكوين ميزات التدريب (المؤشرات السوقية والشهر) + features = [ + material_info['تاريخ'][i].month, # الشهر + self.market_indices['مؤشر_البناء'][date_index], + self.market_indices['مؤشر_النفط'][date_index], + self.market_indices['مؤشر_سعر_الصرف'][date_index], + self.market_indices['مؤشر_التضخم'][date_index] + ] + + # إضافة معرّف للمادة (تمثيل رقمي) + material_id = list(self.materials_data.keys()).index(material_name) + features.append(material_id) + + data.append(features) + target.append(material_info['سعر'][i]) + + # إضافة ضوضاء عشوائية لزيادة حجم البيانات + for _ in range(5): + noisy_features = features.copy() + for j in range(1, 5): # إضافة ضوضاء للمؤشرات فقط + noisy_features[j] += np.random.normal(0, 0.5) + + noisy_price = material_info['سعر'][i] * (1 + np.random.normal(0, 0.02)) # ضوضاء 2% + + data.append(noisy_features) + target.append(noisy_price) + + return np.array(data), np.array(target) + + def _extract_features_target(self, training_data): + """استخراج الميزات والأهداف من بيانات التدريب""" + # استخراج الميزات والأهداف من البيانات المقدمة + features = [] + target = [] + + for item in training_data: + features.append([ + item['date'].month, # الشهر + item['building_index'], + item['oil_index'], + item['exchange_rate'], + item['inflation_rate'], + item['material_id'] + ]) + target.append(item['price']) + + return np.array(features), np.array(target) + + def predict_prices(self, materials, prediction_date=None, market_conditions=None): + """ + التنبؤ بأسعار المواد + + المعلمات: + materials: قائمة المواد المطلوب التنبؤ بأسعارها + prediction_date: تاريخ التنبؤ (اختياري)، إذا لم يتم توفيره سيتم استخدام التاريخ الحالي + market_conditions: ظروف السوق (اختياري)، إذا لم يتم توفيرها سيتم استخدام آخر قيم متاحة + + إرجاع: + قاموس بأسعار المواد المتنبأ بها + """ + if prediction_date is None: + prediction_date = datetime.now() + + if market_conditions is None: + # استخدام آخر قيم متاحة للمؤشرات + market_conditions = { + 'مؤشر_البناء': self.market_indices['مؤشر_البناء'][0], + 'مؤشر_النفط': self.market_indices['مؤشر_النفط'][0], + 'مؤشر_سعر_الصرف': self.market_indices['مؤشر_سعر_الصرف'][0], + 'مؤشر_التضخم': self.market_indices['مؤشر_التضخم'][0] + } + + # التحقق من وجود المواد في البيانات + material_names = list(self.materials_data.keys()) + valid_materials = [m for m in materials if m in material_names] + + if not valid_materials: + return {} + + # تحميل المعايير إذا كانت متوفرة + scaler_path = os.path.join(os.path.dirname(self.model_path), 'price_scaler.pkl') + if self.scaler is None and os.path.exists(scaler_path): + try: + self.scaler = joblib.load(scaler_path) + except Exception as e: + print(f"خطأ في تحميل المعايير: {str(e)}") + # إنشاء معايير جديدة + X, _ = self._prepare_training_data() + self.scaler = StandardScaler() + self.scaler.fit(X) + + # إعداد ميزات التنبؤ + features = [] + for material in valid_materials: + material_id = material_names.index(material) + + material_features = [ + prediction_date.month, # الشهر + market_conditions['مؤشر_البناء'], + market_conditions['مؤشر_النفط'], + market_conditions['مؤشر_سعر_الصرف'], + market_conditions['مؤشر_التضخم'], + material_id + ] + + features.append(material_features) + + # تطبيع الميزات + if self.scaler is not None: + features_scaled = self.scaler.transform(features) + else: + features_scaled = features + + # التنبؤ بالأسعار + predicted_prices = self.model.predict(features_scaled) + + # إرجاع النتائج + results = {} + for i, material in enumerate(valid_materials): + # تطبيق عامل تصحيح (2% عشوائية) + correction_factor = 1.0 + np.random.uniform(-0.02, 0.02) + price = max(0, predicted_prices[i] * correction_factor) + + results[material] = { + 'سعر': price, + 'وحدة': self.materials_data[material]['وحدة'], + 'تاريخ_التنبؤ': prediction_date.strftime('%Y-%m-%d'), + 'هامش_الخطأ': '±5%' # تقدير هامش الخطأ + } + + return results + + def get_price_trends(self, material, periods=6): + """ + الحصول على اتجاهات الأسعار المستقبلية + + المعلمات: + material: المادة المطلوب التنبؤ باتجاهات أسعارها + periods: عدد الفترات المستقبلية (الشهور) + + إرجاع: + قائمة بالأسعار المتوقعة للفترات المستقبلية + """ + if material not in self.materials_data: + return [] + + # الحصول على التاريخ الحالي + current_date = datetime.now() + + # التنبؤ بالأسعار للفترات المستقبلية + price_trends = [] + + for i in range(periods): + prediction_date = current_date + timedelta(days=30 * (i + 1)) + + # افتراض تغيرات طفيفة في المؤشرات مع مرور الوقت + market_conditions = { + 'مؤشر_البناء': self.market_indices['مؤشر_البناء'][0] * (1 + 0.01 * i), # زيادة 1% شهرياً + 'مؤشر_النفط': self.market_indices['مؤشر_النفط'][0] * (1 + 0.005 * i), # زيادة 0.5% شهرياً + 'مؤشر_سعر_الصرف': self.market_indices['مؤشر_سعر_الصرف'][0], # ثابت + 'مؤشر_التضخم': self.market_indices['مؤشر_التضخم'][0] * (1 + 0.01 * i) # زيادة 1% شهرياً + } + + # التنبؤ بالسعر + predicted_price = self.predict_prices([material], prediction_date, market_conditions) + + price_trends.append({ + 'تاريخ': prediction_date.strftime('%Y-%m'), + 'سعر': predicted_price[material]['سعر'] if material in predicted_price else 0 + }) + + return price_trends + + def analyze_factors(self, material): + """ + تحليل العوامل المؤثرة على سعر المادة + + المعلمات: + material: المادة المطلوب تحليلها + + إرجاع: + قاموس بالعوامل المؤثرة وأهميتها النسبية + """ + if material not in self.materials_data or not hasattr(self.model, 'feature_importances_'): + return {} + + # الحصول على أهمية الميزات من النموذج + feature_importances = self.model.feature_importances_ + + # أسماء الميزات + feature_names = ['الشهر', 'مؤشر البناء', 'مؤشر النفط', 'سعر الصرف', 'معدل التضخم', 'نوع المادة'] + + # ترتيب الميزات حسب الأهمية + importance_pairs = [(name, importance) for name, importance in zip(feature_names, feature_importances)] + importance_pairs.sort(key=lambda x: x[1], reverse=True) + + # إرجاع العوامل المؤثرة وأهميتها + factors = {} + for name, importance in importance_pairs: + factors[name] = round(importance * 100, 2) # تحويل إلى نسبة مئوية + + return { + 'العوامل_المؤثرة': factors, + 'المادة': material, + 'وحدة': self.materials_data[material]['وحدة'], + 'سعر_حالي': self.materials_data[material]['سعر'][0], + 'اتجاه_السعر': self._get_price_trend(material) + } + + def _get_price_trend(self, material): + """تحديد اتجاه سعر المادة بناءً على البيانات التاريخية""" + if material not in self.materials_data: + return "غير معروف" + + prices = self.materials_data[material]['سعر'] + if len(prices) < 2: + return "غير معروف" + + # حساب متوسط التغير الشهري + price_changes = [(prices[i] - prices[i+1]) / prices[i+1] * 100 for i in range(len(prices)-1)] + avg_monthly_change = sum(price_changes) / len(price_changes) + + if avg_monthly_change > 1: + return "ارتفاع حاد" + elif avg_monthly_change > 0.2: + return "ارتفاع معتدل" + elif avg_monthly_change > -0.2: + return "استقرار" + elif avg_monthly_change > -1: + return "انخفاض معتدل" + else: + return "انخفاض حاد" + + def export_price_forecast(self, materials, periods=6, output_file=None): + """ + تصدير توقعات الأسعار إلى ملف + + المعلمات: + materials: قائمة المواد المطلوب التنبؤ بأسعارها + periods: عدد الفترات المستقبلية (الشهور) + output_file: مسار ملف الإخراج (اختياري) + + إرجاع: + مسار الملف المصدر أو البيانات مباشرة إذا لم يتم تحديد ملف + """ + # التحقق من وجود المواد في البيانات + valid_materials = [m for m in materials if m in self.materials_data] + + if not valid_materials: + return None + + # إعداد بيانات التوقعات + forecast_data = [] + + for material in valid_materials: + # الحصول على اتجاهات الأسعار + price_trends = self.get_price_trends(material, periods) + + for trend in price_trends: + forecast_data.append({ + 'المادة': material, + 'الوحدة': self.materials_data[material]['وحدة'], + 'التاريخ': trend['تاريخ'], + 'السعر المتوقع': trend['سعر'], + 'هامش الخطأ': '±5%' + }) + + # تحويل البيانات إلى DataFrame + forecast_df = pd.DataFrame(forecast_data) + + # تصدير البيانات إلى ملف إذا تم تحديده + if output_file: + try: + ext = os.path.splitext(output_file)[1].lower() + + if ext == '.csv': + forecast_df.to_csv(output_file, index=False, encoding='utf-8-sig') + elif ext in ['.xlsx', '.xls']: + forecast_df.to_excel(output_file, index=False) + elif ext == '.json': + forecast_df.to_json(output_file, orient='records', force_ascii=False) + else: + print(f"تنسيق غير مدعوم: {ext}") + return None + + return output_file + except Exception as e: + print(f"خطأ في تصدير توقعات الأسعار: {str(e)}") + return None + + return forecast_df \ No newline at end of file diff --git a/modules/pricing/services/standard_pricing.py b/modules/pricing/services/standard_pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..ec89e61a101f89432b815718b8b615c5c00ecf45 --- /dev/null +++ b/modules/pricing/services/standard_pricing.py @@ -0,0 +1,232 @@ +""" +خدمة التسعير القياسي +""" + +import pandas as pd +import numpy as np +from datetime import datetime +import os +import config + + +class StandardPricing: + """خدمة التسعير القياسي للبنود""" + + def __init__(self): + """تهيئة خدمة التسعير القياسي""" + # تحميل بيانات المواد والأسعار المرجعية + self.material_prices = self._load_material_prices() + self.labor_rates = self._load_labor_rates() + self.equipment_rates = self._load_equipment_rates() + + def _load_material_prices(self): + """تحميل أسعار المواد""" + # محاكاة تحميل البيانات من مصدر بيانات + material_prices = { + 'خرسانة': { + 'م3': 750.0, # سعر المتر المكعب بالريال + 'وحدة_قياسية': 'م3', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'حديد تسليح': { + 'طن': 5500.0, # سعر الطن بالريال + 'وحدة_قياسية': 'طن', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'عزل مائي': { + 'م2': 80.0, # سعر المتر المربع بالريال + 'وحدة_قياسية': 'م2', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'بلوك خرساني': { + '20سم': 11.0, # سعر البلكة بالريال + 'وحدة_قياسية': 'عدد', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'رمل': { + 'م3': 140.0, # سعر المتر المكعب بالريال + 'وحدة_قياسية': 'م3', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'اسمنت': { + 'كيس': 25.0, # سعر الكيس بالريال + 'وحدة_قياسية': 'كيس', + 'آخر_تحديث': datetime(2025, 3, 1) + } + } + return material_prices + + def _load_labor_rates(self): + """تحميل معدلات أجور العمالة""" + # محاكاة تحميل البيانات من مصدر بيانات + labor_rates = { + 'عامل': { + 'يومي': 150.0, # الأجر اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'نجار': { + 'يومي': 250.0, # الأجر اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'حداد': { + 'يومي': 250.0, # الأجر اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'سباك': { + 'يومي': 300.0, # الأجر اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'كهربائي': { + 'يومي': 300.0, # الأجر اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'مراقب': { + 'يومي': 400.0, # الأجر اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + } + } + return labor_rates + + def _load_equipment_rates(self): + """تحميل معدلات تأجير المعدات""" + # محاكاة تحميل البيانات من مصدر بيانات + equipment_rates = { + 'خلاطة خرسانة': { + 'يومي': 800.0, # الإيجار اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'هزاز خرسانة': { + 'يومي': 150.0, # الإيجار اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'حفارة': { + 'يومي': 1500.0, # الإيجار اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'لودر': { + 'يومي': 1200.0, # الإيجار اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'رافعة': { + 'يومي': 2000.0, # الإيجار اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + }, + 'شاحنة نقل': { + 'يومي': 900.0, # الإيجار اليومي بالريال + 'وحدة_قياسية': 'يوم', + 'آخر_تحديث': datetime(2025, 3, 1) + } + } + return equipment_rates + + def calculate_prices(self, items_df): + """حساب الأسعار للبنود باستخدام التسعير القياسي""" + # نسخة من البيانات المدخلة للعمل عليها + df = items_df.copy() + + # التأكد من وجود العمود المطلوب + if 'سعر الوحدة' not in df.columns: + df['سعر الوحدة'] = 0.0 + + if 'الإجمالي' not in df.columns: + df['الإجمالي'] = 0.0 + + # حساب أسعار الوحدات لكل بند + for idx, row in df.iterrows(): + # حساب سعر الوحدة بناءً على وصف البند + unit_price = self._estimate_unit_price(row['وصف البند'], row['الوحدة']) + df.at[idx, 'سعر الوحدة'] = unit_price + + # حساب الإجمالي لكل بند + df['الإجمالي'] = df['الكمية'] * df['سعر الوحدة'] + + return df + + def _estimate_unit_price(self, description, unit): + """تقدير سعر الوحدة بناءً على وصف البند ووحدة القياس""" + description = description.lower() + + # تقدير سعر الوحدة بناءً على وصف البند + if 'خرسان' in description: + if 'أساسات' in description: + return 1200.0 if unit == 'م3' else 0.0 + elif 'أعمدة' in description: + return 1800.0 if unit == 'م3' else 0.0 + elif 'سقف' in description: + return 1500.0 if unit == 'م3' else 0.0 + else: + return 1400.0 if unit == 'م3' else 0.0 + + elif 'حديد' in description and 'تسليح' in description: + if 'أساسات' in description: + return 6000.0 if unit == 'طن' else 0.0 + elif 'أعمدة' in description or 'سقف' in description: + return 6500.0 if unit == 'طن' else 0.0 + else: + return 6200.0 if unit == 'طن' else 0.0 + + elif 'عزل' in description: + if 'مائي' in description: + return 120.0 if unit == 'م2' else 0.0 + elif 'حراري' in description: + return 90.0 if unit == 'م2' else 0.0 + else: + return 100.0 if unit == 'م2' else 0.0 + + elif 'ردم' in description or 'حفر' in description: + return 75.0 if unit == 'م3' else 0.0 + + elif 'بلوك' in description or 'طوب' in description: + return 250.0 if unit == 'م2' else 0.0 + + elif 'لياسة' in description or 'بياض' in description: + return 80.0 if unit == 'م2' else 0.0 + + elif 'دهان' in description or 'طلاء' in description: + return 65.0 if unit == 'م2' else 0.0 + + elif 'سيراميك' in description or 'بلاط' in description: + return 180.0 if unit == 'م2' else 0.0 + + elif 'كهرباء' in description: + return 150.0 if unit == 'نقطة' else 500.0 + + # قيمة افتراضية إذا لم تتطابق مع أي وصف + return 100.0 + + def adjust_prices_for_factors(self, items_df, factors=None): + """تعديل الأسعار بناءً على عوامل مؤثرة""" + # نسخة من البيانات المدخلة للعمل عليها + df = items_df.copy() + + # إذا لم يتم تحديد عوامل، استخدم العوامل الافتراضية + if factors is None: + factors = { + 'location_factor': 1.0, # معامل الموقع + 'time_factor': 1.0, # معامل الوقت + 'risk_factor': 1.1, # معامل المخاطر + 'market_factor': 1.05 # معامل السوق + } + + # حساب المعامل الإجمالي + total_factor = (factors['location_factor'] * factors['time_factor'] * + factors['risk_factor'] * factors['market_factor']) + + # تعديل سعر الوحدة بناءً على المعامل الإجمالي + df['سعر الوحدة'] = df['سعر الوحدة'] * total_factor + + # حساب الإجمالي بعد التعديل + df['الإجمالي'] = df['الكمية'] * df['سعر الوحدة'] + + return df \ No newline at end of file diff --git a/modules/pricing/services/templates_catalog/__init__.py b/modules/pricing/services/templates_catalog/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8c7a39ff16d8fa482e8d44f777bee35f84728795 --- /dev/null +++ b/modules/pricing/services/templates_catalog/__init__.py @@ -0,0 +1,3 @@ +from .templates_catalog import TemplatesCatalog + +__all__ = ['TemplatesCatalog'] \ No newline at end of file diff --git a/modules/pricing/services/templates_catalog/templates_catalog.py b/modules/pricing/services/templates_catalog/templates_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..23d6006f8f7781a90a22203f309372e16bcc84f6 --- /dev/null +++ b/modules/pricing/services/templates_catalog/templates_catalog.py @@ -0,0 +1,949 @@ +""" +كتالوج قوالب البناء والمقاولات +واجهة مستخدم متكاملة لعرض واستخدام نماذج بنود البناء الجاهزة +""" + +import os +import sys +import json +import pandas as pd +import streamlit as st +from typing import Dict, List, Any, Optional + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +# استيراد الدالة مباشرة من الملف +from utils.components.credits import display_credits +# استخدام display_credits كبديل لـ render_credits +render_credits = display_credits +from utils.helpers import format_number, format_currency, styled_button, filter_dataframe + +# النموذج المستخدم عند إضافة بند نموذجي جديد من صفحة التسعير +class NewTemplateForm: + """نموذج إضافة بند نموذجي جديد من صفحة التسعير""" + pass + +class TemplatesCatalog: + """كتالوج قوالب البناء والمقاولات""" + + def __init__(self, construction_templates): + """تهيئة كتالوج قوالب البناء والمقاولات""" + self.construction_templates = construction_templates + + # تهيئة حالة الجلسة للطلبات والعروض + if 'material_requests' not in st.session_state: + st.session_state.material_requests = [] + + if 'equipment_requests' not in st.session_state: + st.session_state.equipment_requests = [] + + if 'material_offers' not in st.session_state: + st.session_state.material_offers = [] + + if 'equipment_offers' not in st.session_state: + st.session_state.equipment_offers = [] + + # تهيئة قوائم الأسعار المرجعية + if 'reference_price_list' not in st.session_state: + st.session_state.reference_price_list = [] + + # تعبئة قوائم الأسعار المرجعية من كتالوج البنود النموذجية + self._populate_reference_price_list() + + def render(self): + """عرض واجهة كتالوج القوالب""" + # عرض الشعار والعنوان الرئيسي + render_header("كتالوج بنود المقاولات النموذجية") + + # تبويبات الكتالوج + tabs = st.tabs(["تصفح البنود النموذجية", "طلب تسعير مواد جديدة", "طلب تسعير معدات جديدة", "قوائم الأسعار المرجعية"]) + + with tabs[0]: + self._render_templates_browser() + + with tabs[1]: + self._render_material_request_form() + + with tabs[2]: + self._render_equipment_request_form() + + with tabs[3]: + self._render_reference_price_list() + + def _render_templates_browser(self): + """عرض واجهة تصفح كتالوج القوالب""" + st.markdown(""" +
+

🗃️ نماذج بنود المقاولات الجاهزة للاستخدام

+

يمكنك الاختيار من بين مجموعة متنوعة من نماذج البنود المعرفة مسبقًا والجاهزة للاستخدام في مشاريعك وعروض أسعارك.

+

تشمل النماذج تفاصيل كاملة عن:

+
    +
  • المواد المستخدمة وكمياتها
  • +
  • العمالة المطلوبة ومدة العمل
  • +
  • المعدات اللازمة وتكلفتها
  • +
  • المصروفات الإدارية وهامش الربح
  • +
+
+ """, unsafe_allow_html=True) + + # قسم البحث والتصفية + st.markdown("### تصفية البنود النموذجية") + + # الحصول على الفئات من الكتالوج + templates = self.construction_templates.get_all_templates() + categories = templates.get("categories", {}) + + # إنشاء قائمة الفئات + category_list = [{"id": cat_id, "name": cat_data.get("name", cat_id)} for cat_id, cat_data in categories.items()] + category_names = ["الكل"] + [cat["name"] for cat in category_list] + + # تصفية حسب الفئة + col1, col2 = st.columns(2) + with col1: + selected_category_name = st.selectbox("فئة البنود", category_names, index=0) + + with col2: + search_query = st.text_input("بحث في النماذج", placeholder="اكتب كلمة للبحث...") + + # تحديد الفئة المختارة + selected_category_id = None + if selected_category_name != "الكل": + for cat in category_list: + if cat["name"] == selected_category_name: + selected_category_id = cat["id"] + break + + # الحصول على النماذج المصفاة + filtered_templates = [] + all_templates = templates.get("templates", {}) + + for template_id, template in all_templates.items(): + # التصفية حسب الفئة إذا تم اختيار فئة محددة + if selected_category_id and template.get("category") != selected_category_id: + continue + + # التصفية حسب البحث إذا تم إدخال نص للبحث + if search_query: + template_name = template.get("name", "") + template_desc = template.get("description", "") + searchable_text = f"{template_name} {template_desc}" + if search_query.lower() not in searchable_text.lower(): + continue + + # إضافة النموذج إلى القائمة المصفاة + template_copy = template.copy() + template_copy["id"] = template_id + filtered_templates.append(template_copy) + + # عرض النماذج المصفاة + self._render_templates_list(filtered_templates) + + # عرض نموذج إضافة قالب جديد + with st.expander("إضافة قالب نموذجي جديد"): + self._render_new_template_form() + + # عرض الحقوق + render_credits() + + def _render_templates_list(self, templates: List[Dict[str, Any]]): + """عرض قائمة النماذج المصفاة""" + + if not templates: + st.warning("لا توجد نماذج بنود متاحة تطابق معايير البحث.") + return + + # تحويل النماذج إلى DataFrame + templates_data = [] + for template in templates: + # حساب التكلفة التقديرية للنموذج + estimated_cost = self._calculate_template_cost(template) + + templates_data.append({ + "الرقم التعريفي": template["id"], + "اسم النموذج": template.get("name", ""), + "الوصف": template.get("description", "")[:50] + "..." if len(template.get("description", "")) > 50 else template.get("description", ""), + "التكلفة التقديرية": estimated_cost, + "الوحدة": template.get("unit", ""), + "الفئة": template.get("category", "") + }) + + templates_df = pd.DataFrame(templates_data) + + # تنسيق الجدول + def highlight_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = templates_df.style.apply(highlight_row, axis=1) + styled_df = styled_df.format({ + "التكلفة التقديرية": "{:,.2f} ريال" + }) + + # عرض الجدول + st.markdown("### قائمة النماذج المتاحة") + st.dataframe(styled_df, use_container_width=True, hide_index=True) + + # عرض تفاصيل النموذج المحدد + selected_template_id = st.selectbox( + "اختر نموذجًا لعرض التفاصيل", + options=[t["الرقم التعريفي"] for t in templates_data], + format_func=lambda x: next((t["اسم النموذج"] for t in templates_data if t["الرقم التعريفي"] == x), x) + ) + + if selected_template_id: + # الحصول على النموذج المحدد + selected_template = next((t for t in templates if t["id"] == selected_template_id), None) + if selected_template: + self._render_template_details(selected_template) + + def _render_template_details(self, template: Dict[str, Any]): + """عرض تفاصيل النموذج المحدد""" + st.markdown(f"### تفاصيل نموذج: {template.get('name', '')}") + + # معلومات النموذج الأساسية + col1, col2, col3 = st.columns(3) + with col1: + st.markdown(f"**الوصف:** {template.get('description', '')}") + with col2: + st.markdown(f"**وحدة القياس:** {template.get('unit', '')}") + with col3: + # حساب التكلفة التقديرية للنموذج + estimated_cost = self._calculate_template_cost(template) + st.markdown(f"**التكلفة التقديرية:** {estimated_cost:,.2f} ريال") + + # تبويبات لعرض مكونات النموذج + tabs = st.tabs(["المواد", "العمالة", "المعدات", "التكاليف"]) + + # تبويب المواد + with tabs[0]: + self._render_materials_tab(template) + + # تبويب العمالة + with tabs[1]: + self._render_labor_tab(template) + + # تبويب المعدات + with tabs[2]: + self._render_equipment_tab(template) + + # تبويب التكاليف + with tabs[3]: + self._render_costs_tab(template) + + # أزرار العمليات + col1, col2, col3 = st.columns(3) + + with col1: + if styled_button("إضافة إلى حاسبة التكاليف", key=f"add_to_calc_{template['id']}", type="primary", icon="➕"): + st.session_state.selected_template_for_calculator = template["id"] + st.success("تم إضافة النموذج إلى حاسبة التكاليف!") + + with col2: + if styled_button("استخدام في بند جديد", key=f"use_in_new_item_{template['id']}", type="success", icon="🔄"): + st.session_state.selected_template_for_new_item = template["id"] + st.success("تم اختيار النموذج لاستخدامه في بند جديد!") + + with col3: + if styled_button("تحرير النموذج", key=f"edit_template_{template['id']}", type="secondary", icon="✏️"): + st.session_state.template_to_edit = template["id"] + + def _render_materials_tab(self, template: Dict[str, Any]): + """عرض تبويب المواد""" + components = template.get("components", {}) + materials = components.get("materials", []) + + if not materials: + st.info("لا توجد مواد محددة لهذا النموذج.") + return + + # تحويل المواد إلى DataFrame + materials_data = [] + total_materials_cost = 0 + + for material in materials: + material_cost = material.get("الكمية", 0) * material.get("سعر_الوحدة", 0) + total_materials_cost += material_cost + + materials_data.append({ + "اسم المادة": material.get("الاسم", ""), + "الكمية": material.get("الكمية", 0), + "الوحدة": material.get("الوحدة", ""), + "سعر الوحدة": material.get("سعر_الوحدة", 0), + "التكلفة": material_cost + }) + + materials_df = pd.DataFrame(materials_data) + + # تنسيق الجدول + def highlight_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = materials_df.style.apply(highlight_row, axis=1) + styled_df = styled_df.format({ + "الكمية": "{:.2f}", + "سعر الوحدة": "{:,.2f} ريال", + "التكلفة": "{:,.2f} ريال" + }) + + # عرض الجدول + st.dataframe(styled_df, use_container_width=True, hide_index=True) + st.markdown(f"**إجمالي تكلفة المواد:** {total_materials_cost:,.2f} ريال") + + def _render_labor_tab(self, template: Dict[str, Any]): + """عرض تبويب العمالة""" + components = template.get("components", {}) + labor = components.get("labor", []) + + if not labor: + st.info("لا توجد عمالة محددة لهذا النموذج.") + return + + # تحويل العمالة إلى DataFrame + labor_data = [] + total_labor_cost = 0 + + for worker in labor: + labor_cost = worker.get("العدد", 0) * worker.get("المدة", 0) * worker.get("سعر_اليوم", 0) + total_labor_cost += labor_cost + + labor_data.append({ + "نوع العامل": worker.get("النوع", ""), + "العدد": worker.get("العدد", 0), + "المدة (يوم)": worker.get("المدة", 0), + "سعر اليوم": worker.get("سعر_اليوم", 0), + "التكلفة": labor_cost + }) + + labor_df = pd.DataFrame(labor_data) + + # تنسيق الجدول + def highlight_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = labor_df.style.apply(highlight_row, axis=1) + styled_df = styled_df.format({ + "المدة (يوم)": "{:.2f}", + "سعر اليوم": "{:,.2f} ريال", + "التكلفة": "{:,.2f} ريال" + }) + + # عرض الجدول + st.dataframe(styled_df, use_container_width=True, hide_index=True) + st.markdown(f"**إجمالي تكلفة العمالة:** {total_labor_cost:,.2f} ريال") + + def _render_equipment_tab(self, template: Dict[str, Any]): + """عرض تبويب المعدات""" + components = template.get("components", {}) + equipment = components.get("equipment", []) + + if not equipment: + st.info("لا توجد معدات محددة لهذا النموذج.") + return + + # تحويل المعدات إلى DataFrame + equipment_data = [] + total_equipment_cost = 0 + + for eq in equipment: + equipment_cost = eq.get("العدد", 0) * eq.get("المدة", 0) * eq.get("سعر_اليوم", 0) + total_equipment_cost += equipment_cost + + equipment_data.append({ + "نوع المعدة": eq.get("النوع", ""), + "العدد": eq.get("العدد", 0), + "المدة (يوم)": eq.get("المدة", 0), + "سعر اليوم": eq.get("سعر_اليوم", 0), + "التكلفة": equipment_cost + }) + + equipment_df = pd.DataFrame(equipment_data) + + # تنسيق الجدول + def highlight_row(row): + """تنسيق الجدول مع تمييز الصفوف بالتناوب""" + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = equipment_df.style.apply(highlight_row, axis=1) + styled_df = styled_df.format({ + "المدة (يوم)": "{:.2f}", + "سعر اليوم": "{:,.2f} ريال", + "التكلفة": "{:,.2f} ريال" + }) + + # عرض الجدول + st.dataframe(styled_df, use_container_width=True, hide_index=True) + st.markdown(f"**إجمالي تكلفة المعدات:** {total_equipment_cost:,.2f} ريال") + + def _render_costs_tab(self, template: Dict[str, Any]): + """عرض تبويب التكاليف""" + # حساب إجمالي التكاليف المباشرة + direct_cost = self._calculate_direct_cost(template) + + # المصاريف الإدارية + admin_expenses_pct = template.get("admin_expenses", 0.05) + admin_expenses = direct_cost * admin_expenses_pct + + # هامش الربح + profit_margin_pct = template.get("profit_margin", 0.1) + profit_margin = direct_cost * profit_margin_pct + + # إجمالي التكلفة + total_cost = direct_cost + admin_expenses + profit_margin + + # عرض ملخص التكاليف + st.markdown("#### ملخص التكاليف") + + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"**التكاليف المباشرة:** {direct_cost:,.2f} ريال") + st.markdown(f"**المصاريف الإدارية ({admin_expenses_pct*100:.0f}%):** {admin_expenses:,.2f} ريال") + st.markdown(f"**هامش الربح ({profit_margin_pct*100:.0f}%):** {profit_margin:,.2f} ريال") + + with col2: + # رسم بياني دائري لتوزيع التكاليف + import plotly.express as px + + # حساب تكاليف المكونات + components = template.get("components", {}) + + materials_cost = sum( + mat.get("الكمية", 0) * mat.get("سعر_الوحدة", 0) + for mat in components.get("materials", []) + ) + + labor_cost = sum( + worker.get("العدد", 0) * worker.get("المدة", 0) * worker.get("سعر_اليوم", 0) + for worker in components.get("labor", []) + ) + + equipment_cost = sum( + eq.get("العدد", 0) * eq.get("المدة", 0) * eq.get("سعر_اليوم", 0) + for eq in components.get("equipment", []) + ) + + # إنشاء البيانات للرسم البياني + cost_distribution = [ + {"النوع": "المواد", "القيمة": materials_cost}, + {"النوع": "العمالة", "القيمة": labor_cost}, + {"النوع": "المعدات", "القيمة": equipment_cost}, + {"النوع": "المصاريف الإدارية", "القيمة": admin_expenses}, + {"النوع": "هامش الربح", "القيمة": profit_margin} + ] + + cost_df = pd.DataFrame(cost_distribution) + + fig = px.pie( + cost_df, + values="القيمة", + names="النوع", + title="توزيع التكاليف", + color_discrete_sequence=px.colors.sequential.Teal, + hole=0.4 + ) + + fig.update_traces(textposition='inside', textinfo='percent+label') + + fig.update_layout( + annotations=[dict(text=f"{total_cost:,.0f} ريال", x=0.5, y=0.5, font_size=14, showarrow=False)], + font=dict(family="Almarai, Arial", size=12), + margin=dict(t=30, b=30, l=10, r=10) + ) + + st.plotly_chart(fig, use_container_width=True) + + st.markdown(f"**السعر النهائي:** {total_cost:,.2f} ريال لكل {template.get('unit', 'وحدة')}") + + def _render_new_template_form(self): + """عرض نموذج إضافة قالب جديد""" + st.markdown("### إضافة قالب نموذجي جديد") + + # معلومات القالب الأساسية + col1, col2 = st.columns(2) + + with col1: + template_name = st.text_input("اسم القالب", key="new_template_name") + template_description = st.text_area("وصف القالب", key="new_template_description") + + with col2: + # الحصول على الفئات من الكتالوج + templates = self.construction_templates.get_all_templates() + categories = templates.get("categories", {}) + + # إنشاء قائمة الفئات + category_list = [{"id": cat_id, "name": cat_data.get("name", cat_id)} for cat_id, cat_data in categories.items()] + category_options = [cat["id"] for cat in category_list] + category_labels = [cat["name"] for cat in category_list] + + category_index = 0 + if category_options: + template_category = st.selectbox( + "فئة القالب", + options=category_options, + format_func=lambda x: next((cat["name"] for cat in category_list if cat["id"] == x), x), + key="new_template_category" + ) + else: + template_category = st.text_input("فئة القالب (لا توجد فئات محددة)", key="new_template_category_text") + + template_unit = st.selectbox( + "وحدة القياس", + options=["م²", "م³", "م.ط", "عدد", "طن", "كجم", "لتر", "يوم", "ساعة", "مقطوعية"], + index=0, + key="new_template_unit" + ) + + # إضافة المواد + st.markdown("#### المواد المستخدمة") + + # إنشاء مصفوفة لتخزين المواد + if "new_template_materials" not in st.session_state: + st.session_state.new_template_materials = [] + + # عرض المواد المضافة حاليًا + if st.session_state.new_template_materials: + materials_df = pd.DataFrame(st.session_state.new_template_materials) + st.dataframe(materials_df, hide_index=True) + + # إضافة مادة جديدة + st.markdown("##### 🧱 إضافة مادة جديدة") + st.markdown('
', unsafe_allow_html=True) + + col1, col2 = st.columns(2) + with col1: + material_name = st.text_input("اسم المادة", key="new_template_material_name") + material_quantity = st.number_input("الكمية", min_value=0.0, step=0.1, key="new_template_material_quantity") + + with col2: + material_unit = st.selectbox( + "وحدة القياس", + options=["م²", "م³", "م.ط", "عدد", "طن", "كجم", "لتر", "قطعة", "كيس", "لوح"], + key="new_template_material_unit" + ) + material_price = st.number_input("سعر الوحدة", min_value=0.0, step=1.0, key="new_template_material_price") + + st.markdown('
', unsafe_allow_html=True) + + if st.button("إضافة المادة", key="add_new_template_material"): + if material_name and material_quantity > 0 and material_price > 0: + st.session_state.new_template_materials.append({ + "الاسم": material_name, + "الكمية": material_quantity, + "الوحدة": material_unit, + "سعر_الوحدة": material_price + }) + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة للمادة.") + + # إضافة العمالة + st.markdown("#### العمالة المطلوبة") + + # إنشاء مصفوفة لتخزين العمالة + if "new_template_labor" not in st.session_state: + st.session_state.new_template_labor = [] + + # عرض العمالة المضافة حاليًا + if st.session_state.new_template_labor: + labor_df = pd.DataFrame(st.session_state.new_template_labor) + st.dataframe(labor_df, hide_index=True) + + # إضافة عامل جديد + st.markdown("##### 👷 إضافة عامل جديد") + st.markdown('
', unsafe_allow_html=True) + + col1, col2 = st.columns(2) + with col1: + labor_type = st.text_input("نوع العامل", key="new_template_labor_type") + labor_count = st.number_input("العدد", min_value=1, step=1, key="new_template_labor_count") + + with col2: + labor_duration = st.number_input("المدة (يوم)", min_value=0.1, step=0.1, key="new_template_labor_duration") + labor_price = st.number_input("سعر اليوم", min_value=0.0, step=10.0, key="new_template_labor_price") + + st.markdown('
', unsafe_allow_html=True) + + if st.button("إضافة العامل", key="add_new_template_labor"): + if labor_type and labor_count > 0 and labor_duration > 0 and labor_price > 0: + st.session_state.new_template_labor.append({ + "النوع": labor_type, + "العدد": labor_count, + "المدة": labor_duration, + "سعر_اليوم": labor_price + }) + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة للعامل.") + + # إضافة المعدات + st.markdown("#### المعدات اللازمة") + + # إنشاء مصفوفة لتخزين المعدات + if "new_template_equipment" not in st.session_state: + st.session_state.new_template_equipment = [] + + # عرض المعدات المضافة حاليًا + if st.session_state.new_template_equipment: + equipment_df = pd.DataFrame(st.session_state.new_template_equipment) + st.dataframe(equipment_df, hide_index=True) + + # إضافة معدة جديدة + st.markdown("##### 🚜 إضافة معدة جديدة") + st.markdown('
', unsafe_allow_html=True) + + col1, col2 = st.columns(2) + with col1: + equipment_type = st.text_input("نوع المعدة", key="new_template_equipment_type") + equipment_count = st.number_input("العدد", min_value=1, step=1, key="new_template_equipment_count") + + with col2: + equipment_duration = st.number_input("المدة (يوم)", min_value=0.1, step=0.1, key="new_template_equipment_duration") + equipment_price = st.number_input("سعر اليوم", min_value=0.0, step=50.0, key="new_template_equipment_price") + + st.markdown('
', unsafe_allow_html=True) + + if st.button("إضافة المعدة", key="add_new_template_equipment"): + if equipment_type and equipment_count > 0 and equipment_duration > 0 and equipment_price > 0: + st.session_state.new_template_equipment.append({ + "النوع": equipment_type, + "العدد": equipment_count, + "المدة": equipment_duration, + "سعر_اليوم": equipment_price + }) + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة للمعدة.") + + # المصاريف الإدارية وهامش الربح + st.markdown("#### المصاريف الإدارية وهامش الربح") + + col1, col2 = st.columns(2) + with col1: + admin_expenses = st.slider("نسبة المصاريف الإدارية (%)", min_value=0, max_value=20, value=5, step=1, key="new_template_admin_expenses") / 100 + + with col2: + profit_margin = st.slider("نسبة هامش الربح (%)", min_value=0, max_value=30, value=10, step=1, key="new_template_profit_margin") / 100 + + # الكلمات المفتاحية + st.markdown("#### الكلمات المفتاحية") + tags_input = st.text_input("الكلمات المفتاحية (مفصولة بفواصل)", key="new_template_tags") + tags = [tag.strip() for tag in tags_input.split(",")] if tags_input else [] + + # زر إنشاء القالب + if styled_button("إنشاء القالب النموذجي", key="create_new_template", type="primary", full_width=True, icon="✅"): + # التحقق من صحة البيانات + if not template_name: + st.error("يرجى إدخال اسم القالب.") + elif not template_description: + st.error("يرجى إدخال وصف القالب.") + elif not st.session_state.new_template_materials: + st.error("يرجى إضافة مادة واحدة على الأقل.") + else: + # إنشاء القالب الجديد + new_template = { + "name": template_name, + "description": template_description, + "category": template_category, + "unit": template_unit, + "components": { + "materials": st.session_state.new_template_materials, + "labor": st.session_state.new_template_labor, + "equipment": st.session_state.new_template_equipment + }, + "admin_expenses": admin_expenses, + "profit_margin": profit_margin, + "tags": tags + } + + # إضافة القالب إلى الكتالوج + try: + template_id = self.construction_templates.add_template(new_template) + st.success(f"تم إنشاء القالب النموذجي بنجاح! المعرف: {template_id}") + + # إعادة تعيين البيانات + st.session_state.new_template_materials = [] + st.session_state.new_template_labor = [] + st.session_state.new_template_equipment = [] + + # إعادة تحميل الصفحة + st.rerun() + except Exception as e: + st.error(f"حدث خطأ أثناء إنشاء القالب: {str(e)}") + + def _calculate_template_cost(self, template: Dict[str, Any]) -> float: + """حساب التكلفة التقديرية للنموذج""" + # حساب التكاليف المباشرة + direct_cost = self._calculate_direct_cost(template) + + # المصاريف الإدارية + admin_expenses = direct_cost * template.get("admin_expenses", 0.05) + + # هامش الربح + profit_margin = direct_cost * template.get("profit_margin", 0.1) + + # إجمالي التكلفة + total_cost = direct_cost + admin_expenses + profit_margin + + return total_cost + + def _populate_reference_price_list(self): + """تعبئة قوائم الأسعار المرجعية من كتالوج البنود النموذجية""" + templates = self.construction_templates.get_all_templates() + all_templates = templates.get("templates", {}) + + for template_id, template in all_templates.items(): + # إضافة البند النموذجي إلى قائمة الأسعار المرجعية + self._add_template_to_reference_list(template_id, template) + + def _add_template_to_reference_list(self, template_id: str, template: Dict[str, Any]): + """إضافة بند نموذجي إلى قائمة الأسعار المرجعية""" + # حساب التكلفة التقديرية للنموذج + estimated_cost = self._calculate_template_cost(template) + + # إنشاء عنصر القائمة المرجعية + reference_item = { + "id": template_id, + "name": template.get("name", ""), + "description": template.get("description", ""), + "unit": template.get("unit", ""), + "estimated_cost": estimated_cost, + "category": template.get("category", ""), + "type": "بند نموذجي", + "source": "كتالوج البنود", + "date_added": pd.Timestamp.now().strftime("%Y-%m-%d"), + "is_active": True + } + + # إضافة العنصر إلى قائمة الأسعار المرجعية إذا لم يكن موجوداً بالفعل + existing_item = next((item for item in st.session_state.reference_price_list if item["id"] == template_id), None) + if existing_item: + # تحديث العنصر الموجود + existing_index = st.session_state.reference_price_list.index(existing_item) + st.session_state.reference_price_list[existing_index] = reference_item + else: + # إضافة عنصر جديد + st.session_state.reference_price_list.append(reference_item) + + def _render_reference_price_list(self): + """عرض قوائم الأسعار المرجعية""" + st.markdown(""" +
+

📊 قوائم الأسعار المرجعية

+

قوائم الأسعار المرجعية تحتوي على أسعار المواد والخدمات المستخدمة في المشاريع.

+

يمكنك استخدام هذه القوائم في عمليات التسعير والتخطيط للمشاريع الجديدة.

+
+ """, unsafe_allow_html=True) + + # تبويبات قوائم الأسعار + tabs = st.tabs(["البنود النموذجية", "المواد", "العمالة", "المعدات", "إدارة القوائم"]) + + # تبويب البنود النموذجية + with tabs[0]: + self._render_reference_templates_list() + + # تبويب المواد + with tabs[1]: + self._render_reference_materials_list() + + # تبويب العمالة + with tabs[2]: + self._render_reference_labor_list() + + # تبويب المعدات + with tabs[3]: + self._render_reference_equipment_list() + + # تبويب إدارة القوائم + with tabs[4]: + self._render_reference_list_management() + + def _render_reference_templates_list(self): + """عرض قائمة البنود النموذجية في الأسعار المرجعية""" + st.markdown("### قائمة البنود النموذجية المرجعية") + + # فلترة العناصر للحصول على البنود النموذجية فقط + template_items = [item for item in st.session_state.reference_price_list if item["type"] == "بند نموذجي"] + + if not template_items: + st.info("لا توجد بنود نموذجية في قائمة الأسعار المرجعية.") + return + + # إنشاء DataFrame للعرض + df_data = [] + for item in template_items: + df_data.append({ + "الرقم التعريفي": item["id"], + "اسم البند": item["name"], + "الوصف": item["description"][:50] + "..." if len(item["description"]) > 50 else item["description"], + "الوحدة": item["unit"], + "السعر التقديري": item["estimated_cost"], + "الفئة": item["category"], + "المصدر": item["source"] + }) + + df = pd.DataFrame(df_data) + + # تنسيق الجدول + def highlight_row(row): + color = '#F0F8FF' if row.name % 2 == 0 else 'white' + return ['background-color: %s' % color] * len(row) + + styled_df = df.style.apply(highlight_row, axis=1) + styled_df = styled_df.format({ + "السعر التقديري": "{:,.2f} ريال" + }) + + # عرض الجدول + st.dataframe(styled_df, use_container_width=True, hide_index=True) + + # إضافة زر لإضافة بند من القائمة المرجعية إلى حاسبة التكاليف + selected_item_id = st.selectbox( + "اختر بنداً لإضافته إلى حاسبة التكاليف", + options=[item["id"] for item in template_items], + format_func=lambda x: next((item["name"] for item in template_items if item["id"] == x), x) + ) + + if selected_item_id: + if styled_button("إضافة إلى حاسبة التكاليف", key=f"add_ref_to_calc_{selected_item_id}", type="primary", icon="➕"): + st.session_state.selected_template_for_calculator = selected_item_id + st.success("تم إضافة البند المرجعي إلى حاسبة التكاليف!") + + def _render_reference_materials_list(self): + """عرض قائمة المواد في الأسعار المرجعية""" + st.markdown("### قائمة المواد المرجعية") + st.info("سيتم تطوير هذا القسم قريباً. يمكنك إضافة مواد من طلبات التسعير التي تم الرد عليها.") + + def _render_reference_labor_list(self): + """عرض قائمة العمالة في الأسعار المرجعية""" + st.markdown("### قائمة العمالة المرجعية") + st.info("سيتم تطوير هذا القسم قريباً.") + + def _render_reference_equipment_list(self): + """عرض قائمة المعدات في الأسعار المرجعية""" + st.markdown("### قائمة المعدات المرجعية") + st.info("سيتم تطوير هذا القسم قريباً. يمكنك إضافة معدات من طلبات التسعير التي تم الرد عليها.") + + def _render_reference_list_management(self): + """عرض إدارة قوائم الأسعار المرجعية""" + st.markdown("### إدارة قوائم الأسعار المرجعية") + + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"**إجمالي عدد العناصر في القوائم المرجعية:** {len(st.session_state.reference_price_list)}") + st.markdown(f"**عدد البنود النموذجية:** {len([item for item in st.session_state.reference_price_list if item['type'] == 'بند نموذجي'])}") + + with col2: + if styled_button("تحديث جميع القوائم المرجعية", key="update_all_reference_lists", type="primary", icon="🔄"): + self._populate_reference_price_list() + st.success("تم تحديث قوائم الأسعار المرجعية بنجاح!") + + st.markdown("### إضافة عنصر جديد يدوياً") + st.markdown("##### 🛠️ إضافة عنصر جديد إلى قوائم الأسعار المرجعية") + st.markdown('
', unsafe_allow_html=True) + + item_type = st.selectbox( + "نوع العنصر", + options=["مادة", "عمالة", "معدات"], + key="new_reference_item_type" + ) + + item_name = st.text_input("اسم العنصر", key="new_reference_item_name") + item_desc = st.text_area("وصف العنصر", key="new_reference_item_desc") + item_unit = st.text_input("وحدة القياس", key="new_reference_item_unit") + item_price = st.number_input("السعر", min_value=0.0, step=0.1, key="new_reference_item_price") + + st.markdown('
', unsafe_allow_html=True) + + if styled_button("إضافة إلى القائمة المرجعية", key="add_manual_reference_item", type="success", icon="➕"): + if not item_name: + st.error("الرجاء إدخال اسم العنصر") + elif not item_unit: + st.error("الرجاء إدخال وحدة القياس") + elif item_price <= 0: + st.error("الرجاء إدخال سعر صحيح") + else: + # إنشاء معرف فريد للعنصر + item_id = f"MAN-{item_type[:1]}-{len(st.session_state.reference_price_list) + 1:04d}" + + # إنشاء العنصر + new_item = { + "id": item_id, + "name": item_name, + "description": item_desc, + "unit": item_unit, + "estimated_cost": item_price, + "category": "أخرى", + "type": item_type, + "source": "إدخال يدوي", + "date_added": pd.Timestamp.now().strftime("%Y-%m-%d"), + "is_active": True + } + + # إضافة العنصر إلى القائمة + st.session_state.reference_price_list.append(new_item) + st.success(f"تم إضافة العنصر {item_name} إلى القائمة المرجعية بنجاح!") + + def _calculate_direct_cost(self, template: Dict[str, Any]) -> float: + """حساب التكاليف المباشرة للنموذج""" + components = template.get("components", {}) + + # حساب تكلفة المواد + materials_cost = sum( + mat.get("الكمية", 0) * mat.get("سعر_الوحدة", 0) + for mat in components.get("materials", []) + ) + + # حساب تكلفة العمالة + labor_cost = sum( + worker.get("العدد", 0) * worker.get("المدة", 0) * worker.get("سعر_اليوم", 0) + for worker in components.get("labor", []) + ) + + # حساب تكلفة المعدات + equipment_cost = sum( + eq.get("العدد", 0) * eq.get("المدة", 0) * eq.get("سعر_اليوم", 0) + for eq in components.get("equipment", []) + ) + direct_cost = materials_cost + labor_cost + equipment_cost + + return direct_cost + + +# دالة لتشغيل الكتالوج مباشرة في حالة تنفيذ الملف بشكل مستقل +def main(): + """تشغيل كتالوج القوالب بشكل مستقل""" + from modules.pricing.services.construction_templates import ConstructionTemplates + + # تهيئة الواجهة + st.set_page_config( + page_title="كتالوج بنود المقاولات النموذجية", + page_icon="🗃️", + layout="wide", + initial_sidebar_state="collapsed", + menu_items={ + 'Get Help': 'mailto:support@wahbi-ai.com', + 'Report a bug': 'mailto:support@wahbi-ai.com', + 'About': 'كتالوج بنود المقاولات النموذجية - جزء من نظام WAHBi AI لتحليل المناقصات' + } + ) + + # تهيئة كائن الكتالوج + construction_templates = ConstructionTemplates() + templates_catalog = TemplatesCatalog(construction_templates) + + # عرض الكتالوج + templates_catalog.render() + +# تشغيل الكتالوج مباشرة عند تنفيذ الملف +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/pricing/services/unbalanced_pricing.py b/modules/pricing/services/unbalanced_pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..77df2b857fe88bb48c307b3b14c5217c425efad2 --- /dev/null +++ b/modules/pricing/services/unbalanced_pricing.py @@ -0,0 +1,213 @@ +""" +خدمة التسعير غير المتزن +""" + +import pandas as pd +import numpy as np +from datetime import datetime +import os +import config + + +class UnbalancedPricing: + """خدمة التسعير غير المتزن للبنود""" + + def __init__(self): + """تهيئة خدمة التسعير غير المتزن""" + self.strategies = { + 'front_loading': self.apply_front_loading, + 'back_loading': self.apply_back_loading, + 'confirmed_items': self.apply_confirmed_items_loading, + 'variable_items': self.apply_variable_items_discount + } + + def apply_strategy(self, items_df, strategy, params=None): + """تطبيق استراتيجية تسعير غير متزن على البنود""" + # نسخة من البيانات المدخلة للعمل عليها + df = items_df.copy() + + # إضافة عمود إستراتيجية التسعير إذا لم يكن موجوداً + if 'إستراتيجية التسعير' not in df.columns: + df['إستراتيجية التسعير'] = 'متوازن' + + # تطبيق الإستراتيجية المطلوبة + if strategy in self.strategies: + df = self.strategies[strategy](df, params) + else: + # إذا كانت الإستراتيجية غير معروفة، أعد البيانات بدون تغيير + pass + + # حساب الإجمالي بعد التعديل + df['الإجمالي'] = df['الكمية'] * df['سعر الوحدة'] + + return df + + def apply_front_loading(self, items_df, params=None): + """تطبيق استراتيجية التحميل الأمامي (Front Loading)""" + df = items_df.copy() + + # استخراج المعلمات الافتراضية إذا لم يتم تحديدها + if params is None: + params = { + 'early_increase': 1.3, # زيادة 30% للبنود المبكرة + 'late_decrease': 0.7, # تخفيض 30% للبنود المتأخرة + 'early_percentage': 0.33, # نسبة البنود المبكرة 33% + 'late_percentage': 0.33 # نسبة البنود المتأخرة 33% + } + + # تحديد البنود المبكرة والمتأخرة والمتوسطة + items_count = len(df) + early_count = int(items_count * params['early_percentage']) + late_count = int(items_count * params['late_percentage']) + + early_items = df.iloc[:early_count].index + middle_items = df.iloc[early_count:items_count-late_count].index + late_items = df.iloc[items_count-late_count:].index + + # تطبيق الزيادة والنقصان + for idx in early_items: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['early_increase'] + df.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in middle_items: + df.at[idx, 'إستراتيجية التسعير'] = 'متوازن' + + for idx in late_items: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['late_decrease'] + df.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + return df + + def apply_back_loading(self, items_df, params=None): + """تطبيق استراتيجية التحميل الخلفي (Back Loading)""" + df = items_df.copy() + + # استخراج المعلمات الافتراضية إذا لم يتم تحديدها + if params is None: + params = { + 'early_decrease': 0.7, # تخفيض 30% للبنود المبكرة + 'late_increase': 1.3, # زيادة 30% للبنود المتأخرة + 'early_percentage': 0.33, # نسبة البنود المبكرة 33% + 'late_percentage': 0.33 # نسبة البنود المتأخرة 33% + } + + # تحديد البنود المبكرة والمتأخرة والمتوسطة + items_count = len(df) + early_count = int(items_count * params['early_percentage']) + late_count = int(items_count * params['late_percentage']) + + early_items = df.iloc[:early_count].index + middle_items = df.iloc[early_count:items_count-late_count].index + late_items = df.iloc[items_count-late_count:].index + + # تطبيق الزيادة والنقصان + for idx in early_items: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['early_decrease'] + df.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + for idx in middle_items: + df.at[idx, 'إستراتيجية التسعير'] = 'متوازن' + + for idx in late_items: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['late_increase'] + df.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + return df + + def apply_confirmed_items_loading(self, items_df, params=None): + """تطبيق استراتيجية تحميل البنود المؤكدة""" + df = items_df.copy() + + # استخراج المعلمات الافتراضية إذا لم يتم تحديدها + if params is None: + params = { + 'confirmed_increase': 1.25, # زيادة 25% للبنود المؤكدة + 'others_decrease': 0.85, # تخفيض 15% للبنود الأخرى + 'confirmed_items_indices': [] # قائمة مؤشرات البنود المؤكدة + } + + # إذا لم يتم تحديد البنود المؤكدة، استخدم قواعد اختيار افتراضية + if not params['confirmed_items_indices']: + # البنود التي تحتوي على كلمات مثل "أساسات" أو "هيكل" عادة ما تكون مؤكدة + confirmed_items = [] + for idx, row in df.iterrows(): + description = row['وصف البند'].lower() + if any(term in description for term in ['أساس', 'خرسان', 'هيكل', 'إنشائي']): + confirmed_items.append(idx) + else: + confirmed_items = params['confirmed_items_indices'] + + # تحديد البنود غير المؤكدة + all_indices = set(range(len(df))) + confirmed_indices = set(confirmed_items) + variable_indices = list(all_indices - confirmed_indices) + + # تطبيق الزيادة والنقصان + for idx in confirmed_items: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['confirmed_increase'] + df.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in variable_indices: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['others_decrease'] + df.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + return df + + def apply_variable_items_discount(self, items_df, params=None): + """تطبيق استراتيجية تخفيض البنود المحتمل زيادتها""" + df = items_df.copy() + + # استخراج المعلمات الافتراضية إذا لم يتم تحديدها + if params is None: + params = { + 'variable_decrease': 0.7, # تخفيض 30% للبنود المحتمل زيادتها + 'others_increase': 1.15, # زيادة 15% للبنود الأخرى + 'variable_items_indices': [] # قائمة مؤشرات البنود المحتمل زيادتها + } + + # إذا لم يتم تحديد البنود المحتمل زيادتها، استخدم قواعد اختيار افتراضية + if not params['variable_items_indices']: + # البنود التي تحتوي على كلمات مثل "حفر" أو "ردم" عادة ما تكون محتمل زيادتها + variable_items = [] + for idx, row in df.iterrows(): + description = row['وصف البند'].lower() + if any(term in description for term in ['حفر', 'ردم', 'تمديد', 'صرف', 'مياه']): + variable_items.append(idx) + else: + variable_items = params['variable_items_indices'] + + # تحديد البنود الأخرى + all_indices = set(range(len(df))) + variable_indices = set(variable_items) + other_indices = list(all_indices - variable_indices) + + # تطبيق الزيادة والنقصان + for idx in variable_items: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['variable_decrease'] + df.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + for idx in other_indices: + df.at[idx, 'سعر الوحدة'] = df.at[idx, 'سعر الوحدة'] * params['others_increase'] + df.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + return df + + def calibrate_prices(self, original_df, unbalanced_df): + """معايرة الأسعار للحفاظ على إجمالي التسعير الأصلي""" + # حساب الإجماليات + original_total = original_df['الإجمالي'].sum() + unbalanced_total = unbalanced_df['الإجمالي'].sum() + + # نسخة من البيانات المدخلة للعمل عليها + df = unbalanced_df.copy() + + # حساب معامل التعديل + adjustment_factor = original_total / unbalanced_total if unbalanced_total > 0 else 1.0 + + # تعديل الأسعار + df['سعر الوحدة'] = df['سعر الوحدة'] * adjustment_factor + + # حساب الإجمالي بعد التعديل + df['الإجمالي'] = df['الكمية'] * df['سعر الوحدة'] + + return df \ No newline at end of file diff --git a/modules/pricing/specs_analyzer.py b/modules/pricing/specs_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..9db1b7f586f7317f57fcf81089a4f79d5bd0d09b --- /dev/null +++ b/modules/pricing/specs_analyzer.py @@ -0,0 +1,527 @@ +""" +تطبيق وحدة التسعير المتكاملة +""" + +import streamlit as st +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import plotly.express as px +import plotly.graph_objects as go +from datetime import datetime +import random +import os +import time +import io + +from modules.pricing.services.standard_pricing import StandardPricing +from modules.pricing.services.unbalanced_pricing import UnbalancedPricing +from modules.pricing.services.local_content import LocalContentCalculator +from modules.pricing.services.price_prediction import PricePrediction +from utils.excel_handler import export_to_excel +from utils.helpers import format_number, format_currency + + +class PricingApp: + """وحدة التسعير المتكاملة""" + + def __init__(self): + self.pricing_methods = [ + "التسعير القياسي", + "التسعير غير المتزن", + "التسعير التنافسي", + "التسعير الموجه بالربحية" + ] + + # تهيئة خدمات التسعير + self.standard_pricing = StandardPricing() + self.unbalanced_pricing = UnbalancedPricing() + self.local_content = LocalContentCalculator() + self.price_prediction = PricePrediction() + + def render(self): + """عرض واجهة وحدة التسعير""" + + st.markdown("

وحدة التسعير المتكاملة

", unsafe_allow_html=True) + + tabs = st.tabs([ + "إنشاء تسعير جديد", + "نموذج التسعير الشامل", + "التسعير غير المتزن", + "المحتوى المحلي" + ]) + + with tabs[0]: + self._render_new_pricing_tab() + + with tabs[1]: + self._render_comprehensive_pricing_tab() + + with tabs[2]: + self._render_unbalanced_pricing_tab() + + with tabs[3]: + self._render_local_content_tab() + + def _render_new_pricing_tab(self): + """عرض تبويب إنشاء تسعير جديد""" + + st.markdown("### إنشاء تسعير جديد") + + col1, col2 = st.columns(2) + + with col1: + tender_name = st.text_input("اسم المناقصة") + client = st.text_input("الجهة المالكة") + pricing_method = st.selectbox("طريقة التسعير", self.pricing_methods) + + with col2: + tender_number = st.text_input("رقم المناقصة") + location = st.text_input("الموقع") + submission_date = st.date_input("تاريخ التقديم") + + # خيارات بيانات البنود + st.markdown("### بيانات البنود") + + data_source = st.radio( + "مصدر بيانات البنود", + ["إدخال يدوي", "استيراد من Excel", "استيراد من وحدة تحليل المستندات"] + ) + + if data_source == "إدخال يدوي": + # إنشاء بيانات افتراضية + if 'manual_items' not in st.session_state: + st.session_state.manual_items = pd.DataFrame({ + 'رقم البند': [f"A{i}" for i in range(1, 6)], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3"], + 'الكمية': [250, 25, 500, 300, 120], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0] + }) + + # عرض جدول البنود مع إمكانية التعديل + edited_items = st.data_editor( + st.session_state.manual_items, + use_container_width=True, + hide_index=True, + num_rows="dynamic" + ) + st.session_state.manual_items = edited_items + + elif data_source == "استيراد من Excel": + uploaded_file = st.file_uploader("رفع ملف Excel", type=["xlsx", "xls"]) + + if uploaded_file is not None: + st.success("تم رفع الملف بنجاح") + # محاكاة قراءة الملف + st.markdown("### معاينة البيانات المستوردة") + + # إنشاء بيانات افتراضية + import_items = pd.DataFrame({ + 'رقم البند': [f"A{i}" for i in range(1, 8)], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة", + "توريد وتركيب حديد التسليح للأعمدة", + "أعمال البلوك للجدران" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3", "طن", "م2"], + 'الكمية': [250, 25, 500, 300, 120, 10, 400], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }) + + st.dataframe(import_items) + + if st.button("استيراد البيانات"): + st.session_state.manual_items = import_items.copy() + st.session_state.manual_items_modified = True + st.success("تم استيراد البيانات بنجاح!") + + else: # استيراد من وحدة تحليل المستندات + available_documents = [ + "كراسة شروط مشروع توسعة مستشفى الملك فهد", + "جدول كميات صيانة محطات المياه", + "مخططات إنشاء مدرسة ثانوية" + ] + + selected_doc = st.selectbox("اختر المستند", available_documents) + + if st.button("استيراد البيانات من تحليل المستند"): + # محاكاة استيراد البيانات + with st.spinner("جاري استيراد البيانات..."): + time.sleep(2) + + # إنشاء بيانات افتراضية + doc_items = pd.DataFrame({ + 'رقم البند': [f"A{i}" for i in range(1, 8)], + 'وصف البند': [ + "توريد وتركيب أعمال الخرسانة المسلحة للأساسات", + "توريد وتركيب حديد التسليح للأساسات", + "أعمال العزل المائي للأساسات", + "أعمال الردم والدك للأساسات", + "توريد وتركيب أعمال الخرسانة المسلحة للأعمدة", + "توريد وتركيب حديد التسليح للأعمدة", + "أعمال البلوك للجدران" + ], + 'الوحدة': ["م3", "طن", "م2", "م3", "م3", "طن", "م2"], + 'الكمية': [250, 25, 500, 300, 120, 10, 400], + 'سعر الوحدة': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + 'الإجمالي': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + }) + + st.session_state.manual_items = doc_items.copy() + st.success("تم استيراد البيانات من تحليل المستند بنجاح!") + st.dataframe(doc_items) + + # زر بدء التسعير + if st.button("بدء التسعير"): + # تحقق من صحة البيانات + if 'manual_items' in st.session_state and not st.session_state.manual_items.empty: + # حفظ بيانات التسعير الحالي + st.session_state.current_pricing = { + 'name': tender_name, + 'number': tender_number, + 'client': client, + 'location': location, + 'method': pricing_method, + 'submission_date': submission_date, + 'items': st.session_state.manual_items.copy(), + 'status': 'جديد', + 'created_at': datetime.now() + } + + # الانتقال إلى تبويب نموذج التسعير الشامل + st.success("تم إنشاء التسعير بنجاح! يمكنك الانتقال إلى نموذج التسعير الشامل.") + else: + st.error("يرجى إدخال بيانات البنود أولاً.") + + def _render_comprehensive_pricing_tab(self): + """عرض تبويب نموذج التسعير الشامل""" + + st.markdown("### نموذج التسعير الشامل") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # عرض معلومات التسعير الحالي + pricing = st.session_state.current_pricing + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("اسم المناقصة", pricing['name']) + st.metric("الجهة المالكة", pricing['client']) + + with col2: + st.metric("رقم المناقصة", pricing['number']) + st.metric("تاريخ التقديم", pricing['submission_date'].strftime("%Y-%m-%d")) + + with col3: + st.metric("طريقة التسعير", pricing['method']) + st.metric("الموقع", pricing['location']) + + # عرض البنود والتسعير + st.markdown("### بنود التسعير") + + items = pricing['items'].copy() + + # إضافة أسعار الوحدة للمحاكاة + if 'سعر الوحدة' in items.columns and (items['سعر الوحدة'] == 0).all(): + items['سعر الوحدة'] = [ + round(random.uniform(1000, 3000), 2), # الخرسانة + round(random.uniform(5000, 7000), 2), # الحديد + round(random.uniform(100, 200), 2), # العزل + round(random.uniform(50, 100), 2), # الردم + round(random.uniform(1200, 3500), 2), # الخرسانة للأعمدة + ] + + if len(items) > 5: + for i in range(5, len(items)): + items.at[i, 'سعر الوحدة'] = round(random.uniform(500, 5000), 2) + + # حساب الإجمالي + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + # عرض الجدول مع إمكانية التعديل + edited_items = st.data_editor( + items, + use_container_width=True, + hide_index=True, + disabled=('رقم البند', 'وصف البند', 'الوحدة', 'الكمية', 'الإجمالي') + ) + + # حساب الإجمالي بعد التعديل + edited_items['الإجمالي'] = edited_items['الكمية'] * edited_items['سعر الوحدة'] + st.session_state.current_pricing['items'] = edited_items + + # حساب وعرض إجماليات التسعير + total_price = edited_items['الإجمالي'].sum() + + st.markdown("### إجماليات التسعير") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التكاليف المباشرة", f"{total_price:,.2f} ريال") + + with col2: + overhead_percentage = st.slider("نسبة المصاريف العامة والأرباح (%)", 5, 30, 15) + overhead_value = total_price * overhead_percentage / 100 + st.metric("المصاريف العامة والأرباح", f"{overhead_value:,.2f} ريال") + + with col3: + grand_total = total_price + overhead_value + st.metric("الإجمالي النهائي", f"{grand_total:,.2f} ريال") + + # رسم بياني لتوزيع التكاليف + st.markdown("### تحليل التكاليف") + + # حساب النسب المئوية لكل بند + pie_data = edited_items.copy() + pie_data['نسبة من إجمالي التكاليف'] = pie_data['الإجمالي'] / total_price * 100 + + fig = px.pie( + pie_data, + values='نسبة من إجمالي التكاليف', + names='وصف البند', + title='توزيع التكاليف حسب البنود', + hole=0.4 + ) + + st.plotly_chart(fig, use_container_width=True) + + # أزرار العمليات + col1, col2, col3 = st.columns(3) + + with col1: + if st.button("حفظ التسعير"): + st.success("تم حفظ التسعير بنجاح!") + + with col2: + if st.button("تصدير إلى Excel"): + st.success("تم تصدير التسعير إلى Excel بنجاح!") + + with col3: + if st.button("تحليل المخاطر المالية"): + st.success("تم إرسال الطلب إلى وحدة تحليل المخاطر!") + + def _render_unbalanced_pricing_tab(self): + """عرض تبويب التسعير غير المتزن""" + + st.markdown("### التسعير غير المتزن") + + # التحقق من وجود تسعير حالي + if 'current_pricing' not in st.session_state or st.session_state.current_pricing is None: + st.warning("ليس هناك تسعير حالي. يرجى إنشاء تسعير جديد أولاً.") + return + + # شرح التسعير غير المتزن + with st.expander("ما هو التسعير غير المتزن؟", expanded=False): + st.markdown(""" + **التسعير غير المتزن** هو استراتيجية تسعير تقوم على توزيع التكاليف بين بنود المناقصة بشكل غير متساوٍ، مع الحفاظ على إجمالي قيمة العطاء. + + ### استراتيجيات التسعير غير المتزن: + + 1. **التحميل الأمامي (Front Loading)**: زيادة أسعار البنود المبكرة في المشروع للحصول على تدفق نقدي أفضل في بداية المشروع. + 2. **التحميل الخلفي (Back Loading)**: زيادة أسعار البنود المتأخرة في المشروع. + 3. **تحميل البنود المؤكدة**: زيادة أسعار البنود التي من المؤكد تنفيذها بالكميات المحددة. + 4. **تخفيض أسعار البنود المحتملة**: تخفيض أسعار البنود التي قد تزيد كمياتها أثناء التنفيذ. + + ### مزايا التسعير غير المتزن: + + - تحسين التدفق النقدي للمشروع. + - تعظيم الربحية في حالة التغييرات والأوامر التغييرية. + - زيادة فرص الفوز بالمناقصة. + + ### مخاطر التسعير غير المتزن: + + - قد يتم رفض العطاء إذا كان عدم التوازن واضحاً. + - قد تتأثر السمعة سلباً إذا تم استخدامه بشكل مفرط. + - قد يؤدي إلى خسائر إذا لم يتم تنفيذ البنود ذات الأسعار العالية. + """) + + # عرض بنود التسعير الحالي + items = st.session_state.current_pricing['items'].copy() + + # إضافة عمود إستراتيجية التسعير + if 'إستراتيجية التسعير' not in items.columns: + items['إستراتيجية التسعير'] = 'متوازن' + + st.markdown("### إستراتيجية التسعير غير المتزن") + + # اختيار الإستراتيجية + strategy = st.selectbox( + "اختر إستراتيجية التسعير", + [ + "تحميل أمامي (Front Loading)", + "تحميل البنود المؤكدة", + "تخفيض البنود المحتمل زيادتها", + "إستراتيجية مخصصة" + ] + ) + + # تطبيق الإستراتيجية المختارة + if strategy == "تحميل أمامي (Front Loading)": + # محاكاة تحميل أمامي + items_count = len(items) + early_items = items.iloc[:items_count//3].index + middle_items = items.iloc[items_count//3:2*items_count//3].index + late_items = items.iloc[2*items_count//3:].index + + # تطبيق الزيادة والنقصان + for idx in early_items: + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.3 # زيادة 30% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in middle_items: + items.at[idx, 'إستراتيجية التسعير'] = 'متوازن' + + for idx in late_items: + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.7 # نقص 30% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + elif strategy == "تحميل البنود المؤكدة": + # محاكاة - اعتبار بعض البنود مؤكدة + confirmed_items = [0, 2, 4] # الأصفار-مستندة + variable_items = [idx for idx in range(len(items)) if idx not in confirmed_items] + + # تطبيق الزيادة والنقصان + for idx in confirmed_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.25 # زيادة 25% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + for idx in variable_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.85 # نقص 15% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + elif strategy == "تخفيض البنود المحتمل زيادتها": + # محاكاة - اعتبار بعض البنود محتمل زيادتها + variable_items = [1, 3] # الأصفار-مستندة + other_items = [idx for idx in range(len(items)) if idx not in variable_items] + + # تطبيق الزيادة والنقصان + for idx in variable_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 0.7 # نقص 30% + items.at[idx, 'إستراتيجية التسعير'] = 'نقص' + + for idx in other_items: + if idx < len(items): + items.at[idx, 'سعر الوحدة'] = items.at[idx, 'سعر الوحدة'] * 1.15 # زيادة 15% + items.at[idx, 'إستراتيجية التسعير'] = 'زيادة' + + else: # إستراتيجية مخصصة + st.markdown("### تعديل أسعار البنود يدوياً") + st.markdown("قم بتعديل أسعار البنود وإستراتيجية التسعير يدوياً.") + + # حساب الإجمالي بعد التعديل + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + # تعيين ألوان للإستراتيجيات + def highlight_strategy(val): + if val == 'زيادة': + return 'background-color: #a8e6cf' + elif val == 'نقص': + return 'background-color: #ff9aa2' + return '' + + # عرض الجدول مع تنسيق + st.markdown("### بنود التسعير غير المتزن") + styled_items = items.style.applymap(highlight_strategy, subset=['إستراتيجية التسعير']) + st.dataframe(styled_items, use_container_width=True) + + # المقارنة بين التسعير المتوازن وغير المتوازن + st.markdown("### مقارنة التسعير المتوازن وغير المتوازن") + + original_items = st.session_state.current_pricing['items'].copy() + original_total = original_items['الإجمالي'].sum() + unbalanced_total = items['الإجمالي'].sum() + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي التسعير المتوازن", f"{original_total:,.2f} ريال") + + with col2: + st.metric("إجمالي التسعير غير المتوازن", f"{unbalanced_total:,.2f} ريال") + + with col3: + diff = unbalanced_total - original_total + st.metric("الفرق", f"{diff:,.2f} ريال", delta=f"{diff/original_total*100:.1f}%") + + # المعايرة للحفاظ على إجمالي التسعير + if abs(diff) > 1: # إذا كان هناك فرق كبير + if st.button("معايرة الأسعار للحفاظ على إجمالي التسعير"): + # تعديل الأسعار للحفاظ على إجمالي التكلفة + adjustment_factor = original_total / unbalanced_total + items['سعر الوحدة'] = items['سعر الوحدة'] * adjustment_factor + items['الإجمالي'] = items['الكمية'] * items['سعر الوحدة'] + + st.success(f"تم تعديل الأسعار للحفاظ على إجمالي التسعير الأصلي ({original_total:,.2f} ريال)") + st.dataframe(items, use_container_width=True) + + # رسم بياني للمقارنة + st.markdown("### تحليل بصري للتسعير غير المتوازن") + + # إعداد البيانات للرسم البياني + chart_data = pd.DataFrame({ + 'وصف البند': original_items['وصف البند'], + 'التسعير المتوازن': original_items['الإجمالي'], + 'التسعير غير المتوازن': items['الإجمالي'] + }) + + # رسم بياني شريطي للمقارنة + fig = go.Figure() + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير المتوازن'], + name='التسعير المتوازن', + marker_color='rgb(55, 83, 109)' + )) + + fig.add_trace(go.Bar( + x=chart_data['وصف البند'], + y=chart_data['التسعير غير المتوازن'], + name='التسعير غير المتوازن', + marker_color='rgb(26, 118, 255)' + )) + + fig.update_layout( + title='مقارنة بين التسعير المتوازن وغير المتوازن', + xaxis_tickfont_size=14, + yaxis=dict( + title='الإجمالي (ريال)', + titlefont_size=16, + tickfont_size=14, + ), + legend=dict( + x=0, + y=1.0, + bgcolor='rgba(255, 255, 255, 0)', + bordercolor='rgba(255, 255, 255, 0)' + ), + barmode='group', + bargap=0.15, + bargroupgap=0.1 + ) + + st.plotly_chart(fig, use_container_width=True) + + # زر حفظ التسعير غير المتوازن + if st.button(" \ No newline at end of file diff --git a/modules/project_tracker/__init__.py b/modules/project_tracker/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d9b980b1d848af2732c586ff297deb2fdf8995e --- /dev/null +++ b/modules/project_tracker/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +وحدة متتبع حالة المشروع المتحرك مع تصور التقدم +""" \ No newline at end of file diff --git a/modules/project_tracker/status_tracker.py b/modules/project_tracker/status_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..8a304fdc87c1ca5a30ff7a9dc736adaa764da7ed --- /dev/null +++ b/modules/project_tracker/status_tracker.py @@ -0,0 +1,1740 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة متتبع حالة المشروع المتحرك مع تصور التقدم +""" + +import os +import sys +import json +import time +import datetime +import streamlit as st +import pandas as pd +import numpy as np +import plotly.express as px +import plotly.graph_objects as go +from datetime import datetime, timedelta +import random +import math + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد المكونات المساعدة +from utils.helpers import format_time, get_user_info, create_directory_if_not_exists + + +class ProjectStatusTracker: + """فئة متتبع حالة المشروع المتحرك""" + + def __init__(self, project_id=None, user_id=None): + """تهيئة متتبع حالة المشروع""" + self.project_id = project_id or 1 # استخدام المشروع الافتراضي إذا لم يتم توفير معرف + self.user_id = user_id or 1 # استخدام المستخدم الافتراضي إذا لم يتم توفير معرف + self.tracker_path = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'project_tracker') + create_directory_if_not_exists(self.tracker_path) + self.project_data_file = os.path.join(self.tracker_path, f'project_{self.project_id}_status.json') + + # تعريف المراحل الافتراضية للمشروع + self.default_project_phases = [ + { + "id": "planning", + "name": "التخطيط", + "description": "مرحلة التخطيط وإعداد الجدول الزمني", + "order": 1, + "progress": 100, + "status": "completed", + "start_date": (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() - timedelta(days=20)).strftime('%Y-%m-%d'), + "actual_end_date": (datetime.now() - timedelta(days=18)).strftime('%Y-%m-%d'), + "deliverables": ["خطة المشروع", "الجدول الزمني", "خطة الموارد"], + "responsible": "فريق التخطيط", + "notes": "تم الانتهاء من مرحلة التخطيط بنجاح قبل الموعد المحدد", + "critical": True + }, + { + "id": "pricing", + "name": "التسعير", + "description": "تسعير المشروع وتحليل التكاليف", + "order": 2, + "progress": 100, + "status": "completed", + "start_date": (datetime.now() - timedelta(days=20)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() - timedelta(days=10)).strftime('%Y-%m-%d'), + "actual_end_date": (datetime.now() - timedelta(days=8)).strftime('%Y-%m-%d'), + "deliverables": ["جدول الكميات المسعر", "تحليل التكاليف", "خطة التدفق النقدي"], + "responsible": "قسم التسعير", + "notes": "تم تحقيق وفر في تكاليف المشروع بنسبة 5%", + "critical": True + }, + { + "id": "bidding", + "name": "تقديم العطاء", + "description": "إعداد وتقديم وثائق العطاء", + "order": 3, + "progress": 100, + "status": "completed", + "start_date": (datetime.now() - timedelta(days=10)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + "actual_end_date": (datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + "deliverables": ["وثائق العطاء", "خطاب التقديم", "الضمان البنكي الابتدائي"], + "responsible": "مدير المشروع", + "notes": "تم تقديم العطاء في الموعد المحدد", + "critical": True + }, + { + "id": "evaluation", + "name": "تقييم العطاء", + "description": "مرحلة تقييم العطاء من قبل العميل", + "order": 4, + "progress": 75, + "status": "in_progress", + "start_date": (datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() + timedelta(days=5)).strftime('%Y-%m-%d'), + "actual_end_date": None, + "deliverables": ["الرد على استفسارات العميل", "العرض التقديمي", "تقديم المستندات الإضافية"], + "responsible": "العميل / مدير المشروع", + "notes": "مرحلة التقييم جارية، تم الرد على جميع استفسارات العميل", + "critical": True + }, + { + "id": "awarding", + "name": "ترسية العطاء", + "description": "مرحلة ترسية العطاء وتوقيع العقد", + "order": 5, + "progress": 0, + "status": "not_started", + "start_date": (datetime.now() + timedelta(days=5)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() + timedelta(days=15)).strftime('%Y-%m-%d'), + "actual_end_date": None, + "deliverables": ["خطاب الترسية", "العقد الموقع", "الضمان البنكي النهائي"], + "responsible": "الإدارة القانونية / مدير المشروع", + "notes": "ننتظر نتيجة الترسية", + "critical": True + }, + { + "id": "mobilization", + "name": "التجهيز", + "description": "تجهيز الموقع وتوفير الموارد", + "order": 6, + "progress": 0, + "status": "not_started", + "start_date": (datetime.now() + timedelta(days=15)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d'), + "actual_end_date": None, + "deliverables": ["تقرير التجهيز", "قائمة الموارد", "خطة التنفيذ التفصيلية"], + "responsible": "قسم العمليات", + "notes": "التجهيز سيبدأ بعد توقيع العقد", + "critical": False + }, + { + "id": "execution", + "name": "التنفيذ", + "description": "تنفيذ أعمال المشروع", + "order": 7, + "progress": 0, + "status": "not_started", + "start_date": (datetime.now() + timedelta(days=30)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() + timedelta(days=180)).strftime('%Y-%m-%d'), + "actual_end_date": None, + "deliverables": ["تقارير التقدم الدورية", "محاضر الاجتماعات", "الفواتير"], + "responsible": "فريق التنفيذ", + "notes": "التنفيذ سيستمر لمدة 6 أشهر", + "critical": True + }, + { + "id": "handover", + "name": "التسليم", + "description": "تسليم المشروع للعميل", + "order": 8, + "progress": 0, + "status": "not_started", + "start_date": (datetime.now() + timedelta(days=180)).strftime('%Y-%m-%d'), + "end_date": (datetime.now() + timedelta(days=195)).strftime('%Y-%m-%d'), + "actual_end_date": None, + "deliverables": ["محضر الاستلام", "وثائق الضمان", "دليل التشغيل والصيانة"], + "responsible": "مدير المشروع / العميل", + "notes": "التسليم يشمل فترة الاختبار والتدريب", + "critical": True + }, + ] + + # تحميل بيانات المشروع + self.load_project_data() + + # تحميل البيانات لمؤشرات الأداء الرئيسية + self.kpi_data_file = os.path.join(self.tracker_path, f'project_{self.project_id}_kpis.json') + self.load_kpi_data() + + def load_project_data(self): + """تحميل بيانات حالة المشروع""" + try: + if os.path.exists(self.project_data_file): + with open(self.project_data_file, 'r', encoding='utf-8') as f: + self.project_data = json.load(f) + else: + # بيانات افتراضية عند عدم وجود ملف + self.project_data = { + 'project_id': self.project_id, + 'project_name': "مشروع إنشاء مبنى إداري", + 'project_code': "PC-2025-001", + 'client': "وزارة الإسكان", + 'location': "الرياض، المملكة العربية السعودية", + 'start_date': (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'), + 'end_date': (datetime.now() + timedelta(days=200)).strftime('%Y-%m-%d'), + 'budget': 10000000, + 'duration': 230, + 'elapsed_days': 30, + 'overall_progress': 25, + 'status': "في التقدم", + 'phases': self.default_project_phases, + 'last_updated': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + self.save_project_data() + except Exception as e: + st.error(f"خطأ في تحميل بيانات المشروع: {e}") + self.project_data = { + 'project_id': self.project_id, + 'project_name': "مشروع إنشاء مبنى إداري", + 'project_code': "PC-2025-001", + 'client': "وزارة الإسكان", + 'location': "الرياض، المملكة العربية السعودية", + 'start_date': (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'), + 'end_date': (datetime.now() + timedelta(days=200)).strftime('%Y-%m-%d'), + 'budget': 10000000, + 'duration': 230, + 'elapsed_days': 30, + 'overall_progress': 25, + 'status': "في التقدم", + 'phases': self.default_project_phases, + 'last_updated': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + def save_project_data(self): + """حفظ بيانات حالة المشروع""" + try: + with open(self.project_data_file, 'w', encoding='utf-8') as f: + json.dump(self.project_data, f, ensure_ascii=False, indent=2) + except Exception as e: + st.error(f"خطأ في حفظ بيانات المشروع: {e}") + + def load_kpi_data(self): + """تحميل بيانات مؤشرات الأداء الرئيسية""" + try: + if os.path.exists(self.kpi_data_file): + with open(self.kpi_data_file, 'r', encoding='utf-8') as f: + self.kpi_data = json.load(f) + else: + # بيانات افتراضية عند عدم وجود ملف + self.kpi_data = { + 'spi': 1.05, # مؤشر أداء الجدول الزمني (SPI) + 'cpi': 0.98, # مؤشر أداء التكلفة (CPI) + 'quality_score': 92, # درجة جودة المشروع + 'safety_incidents': 0, # عدد حوادث السلامة + 'resource_utilization': 85, # نسبة استغلال الموارد + 'risk_score': 15, # درجة المخاطر (كلما قلت كان أفضل) + 'customer_satisfaction': 90, # درجة رضا العميل + 'environmental_compliance': 95, # نسبة الامتثال البيئي + 'trends': { + 'spi': [0.95, 0.98, 1.02, 1.05], + 'cpi': [1.02, 1.00, 0.99, 0.98], + 'quality_score': [85, 88, 90, 92], + 'risk_score': [25, 22, 18, 15], + 'dates': [ + (datetime.now() - timedelta(days=21)).strftime('%Y-%m-%d'), + (datetime.now() - timedelta(days=14)).strftime('%Y-%m-%d'), + (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d'), + datetime.now().strftime('%Y-%m-%d') + ] + }, + 'issues': [ + { + 'id': 1, + 'description': "تأخر في توريد المواد", + 'severity': "متوسط", + 'status': "قيد المعالجة", + 'created_date': (datetime.now() - timedelta(days=10)).strftime('%Y-%m-%d'), + 'responsible': "قسم المشتريات", + 'resolution': "التنسيق مع المورد البديل" + }, + { + 'id': 2, + 'description': "نقص في فريق العمل", + 'severity': "منخفض", + 'status': "تم الحل", + 'created_date': (datetime.now() - timedelta(days=15)).strftime('%Y-%m-%d'), + 'responsible': "قسم الموارد البشرية", + 'resolution': "تم توظيف فريق إضافي" + } + ], + 'last_updated': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + self.save_kpi_data() + except Exception as e: + st.error(f"خطأ في تحميل بيانات مؤشرات الأداء: {e}") + self.kpi_data = { + 'spi': 1.05, + 'cpi': 0.98, + 'quality_score': 92, + 'safety_incidents': 0, + 'resource_utilization': 85, + 'risk_score': 15, + 'customer_satisfaction': 90, + 'environmental_compliance': 95, + 'trends': { + 'spi': [0.95, 0.98, 1.02, 1.05], + 'cpi': [1.02, 1.00, 0.99, 0.98], + 'quality_score': [85, 88, 90, 92], + 'risk_score': [25, 22, 18, 15], + 'dates': [ + (datetime.now() - timedelta(days=21)).strftime('%Y-%m-%d'), + (datetime.now() - timedelta(days=14)).strftime('%Y-%m-%d'), + (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d'), + datetime.now().strftime('%Y-%m-%d') + ] + }, + 'issues': [ + { + 'id': 1, + 'description': "تأخر في توريد المواد", + 'severity': "متوسط", + 'status': "قيد المعالجة", + 'created_date': (datetime.now() - timedelta(days=10)).strftime('%Y-%m-%d'), + 'responsible': "قسم المشتريات", + 'resolution': "التنسيق مع المورد البديل" + }, + { + 'id': 2, + 'description': "نقص في فريق العمل", + 'severity': "منخفض", + 'status': "تم الحل", + 'created_date': (datetime.now() - timedelta(days=15)).strftime('%Y-%m-%d'), + 'responsible': "قسم الموارد البشرية", + 'resolution': "تم توظيف فريق إضافي" + } + ], + 'last_updated': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + + def save_kpi_data(self): + """حفظ بيانات مؤشرات الأداء الرئيسية""" + try: + with open(self.kpi_data_file, 'w', encoding='utf-8') as f: + json.dump(self.kpi_data, f, ensure_ascii=False, indent=2) + except Exception as e: + st.error(f"خطأ في حفظ بيانات مؤشرات الأداء: {e}") + + def update_project_status(self, phase_id, progress, status, actual_end_date=None, notes=None): + """تحديث حالة مرحلة في المشروع""" + # البحث عن المرحلة + phase = next((p for p in self.project_data['phases'] if p['id'] == phase_id), None) + if not phase: + return False + + # تحديث البيانات + phase['progress'] = progress + phase['status'] = status + if actual_end_date: + phase['actual_end_date'] = actual_end_date + if notes: + phase['notes'] = notes + + # تحديث التقدم الكلي للمشروع + self._update_overall_progress() + + # حفظ البيانات + self.project_data['last_updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self.save_project_data() + + return True + + def _update_overall_progress(self): + """تحديث نسبة التقدم الكلية للمشروع""" + total_weight = len(self.project_data['phases']) + total_progress = sum(phase['progress'] for phase in self.project_data['phases']) + + self.project_data['overall_progress'] = round(total_progress / total_weight) + + # تحديث عدد الأيام المنقضية + start_date = datetime.strptime(self.project_data['start_date'], '%Y-%m-%d') + self.project_data['elapsed_days'] = (datetime.now() - start_date).days + + def add_project_issue(self, description, severity, responsible, resolution=None): + """إضافة مشكلة جديدة للمشروع""" + # إنشاء معرف جديد + new_id = max([issue['id'] for issue in self.kpi_data['issues']], default=0) + 1 + + # إضافة المشكلة + new_issue = { + 'id': new_id, + 'description': description, + 'severity': severity, + 'status': "قيد المعالجة", + 'created_date': datetime.now().strftime('%Y-%m-%d'), + 'responsible': responsible, + 'resolution': resolution or "" + } + + self.kpi_data['issues'].append(new_issue) + self.kpi_data['last_updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self.save_kpi_data() + + return new_issue + + def update_issue_status(self, issue_id, status, resolution=None): + """تحديث حالة مشكلة في المشروع""" + # البحث عن المشكلة + issue = next((i for i in self.kpi_data['issues'] if i['id'] == issue_id), None) + if not issue: + return False + + # تحديث البيانات + issue['status'] = status + if resolution: + issue['resolution'] = resolution + + # حفظ البيانات + self.kpi_data['last_updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self.save_kpi_data() + + return True + + def update_kpi_values(self, kpi_updates): + """تحديث قيم مؤشرات الأداء الرئيسية""" + # تحديث القيم + for key, value in kpi_updates.items(): + if key in self.kpi_data and key != 'trends' and key != 'issues': + self.kpi_data[key] = value + + # تحديث الاتجاهات + for key, value in kpi_updates.items(): + if key in self.kpi_data and key != 'trends' and key != 'issues' and key in self.kpi_data['trends']: + # إضافة القيمة الجديدة للاتجاه وحذف أقدم قيمة إذا تجاوز العدد 5 + self.kpi_data['trends'][key].append(value) + if len(self.kpi_data['trends'][key]) > 5: + self.kpi_data['trends'][key].pop(0) + + # تحديث تاريخ الاتجاه + today = datetime.now().strftime('%Y-%m-%d') + if today not in self.kpi_data['trends']['dates']: + self.kpi_data['trends']['dates'].append(today) + if len(self.kpi_data['trends']['dates']) > 5: + self.kpi_data['trends']['dates'].pop(0) + + # حفظ البيانات + self.kpi_data['last_updated'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self.save_kpi_data() + + return True + + def render_project_status_dashboard(self): + """عرض لوحة تحكم حالة المشروع""" + st.markdown("

متتبع حالة المشروع المتحرك

", unsafe_allow_html=True) + + st.markdown(""" +
+ متتبع حالة المشروع المتحرك يوفر عرضاً تفاعلياً ومرئياً لحالة المشروع ومراحله المختلفة، + مع إمكانية متابعة مؤشرات الأداء الرئيسية وتتبع التقدم بشكل مباشر. +
+ """, unsafe_allow_html=True) + + # عرض معلومات المشروع + self._render_project_info() + + # عرض بطاقات مؤشرات الأداء الرئيسية + self._render_kpi_cards() + + # عرض تقدم المشروع + self._render_project_progress() + + # عرض نظرة عامة على المراحل + self._render_phases_timeline() + + # عرض اتجاهات مؤشرات الأداء + self._render_kpi_trends() + + # عرض قائمة المشكلات + self._render_issues_table() + + # عرض لوحة تحكم تحديث حالة المشروع + if st.checkbox("تحديث حالة المشروع"): + self._render_update_panel() + + def _render_project_info(self): + """عرض معلومات المشروع""" + st.markdown("

معلومات المشروع

", unsafe_allow_html=True) + + col1, col2, col3 = st.columns([2, 1, 1]) + + with col1: + st.markdown(f""" +
+
{self.project_data['project_name']}
+
رمز المشروع: {self.project_data['project_code']}
+
العميل: {self.project_data['client']}
+
الموقع: {self.project_data['location']}
+
+ """, unsafe_allow_html=True) + + with col2: + # حساب الأيام المتبقية + end_date = datetime.strptime(self.project_data['end_date'], '%Y-%m-%d') + remaining_days = (end_date - datetime.now()).days + + st.markdown(f""" +
+
تاريخ البدء
+
{self.project_data['start_date']}
+
تاريخ الانتهاء
+
{self.project_data['end_date']}
+
الأيام المتبقية
+
{remaining_days} يوم
+
+ """, unsafe_allow_html=True) + + with col3: + # عرض معلومات التقدم + progress = self.project_data['overall_progress'] + progress_color = "green" if progress >= 80 else "orange" if progress >= 50 else "red" + + st.markdown(f""" +
+
التقدم الكلي
+
{progress}%
+
الميزانية
+
{self.project_data['budget']:,} ريال
+
آخر تحديث
+
{self.project_data['last_updated']}
+
+ """, unsafe_allow_html=True) + + def _render_kpi_cards(self): + """عرض بطاقات مؤشرات الأداء الرئيسية""" + st.markdown("

مؤشرات الأداء الرئيسية

", unsafe_allow_html=True) + + col1, col2, col3, col4 = st.columns(4) + + with col1: + # مؤشر أداء الجدول الزمني (SPI) + spi = self.kpi_data['spi'] + spi_color = "green" if spi >= 1.0 else "red" + spi_icon = "⬆️" if spi >= 1.0 else "⬇️" + spi_trend = round((spi - self.kpi_data['trends']['spi'][0]) * 100, 1) + + st.markdown(f""" +
+
مؤشر أداء الجدول الزمني
+
{spi}
+
+ {spi_icon} {spi_trend}% +
+
+ """, unsafe_allow_html=True) + + # مؤشر درجة الجودة + quality = self.kpi_data['quality_score'] + quality_color = "green" if quality >= 90 else "orange" if quality >= 80 else "red" + quality_icon = "⬆️" if quality >= 90 else "➡️" if quality >= 80 else "⬇️" + + st.markdown(f""" +
+
درجة الجودة
+
{quality}%
+
+ {quality_icon} ممتاز +
+
+ """, unsafe_allow_html=True) + + with col2: + # مؤشر أداء التكلفة (CPI) + cpi = self.kpi_data['cpi'] + cpi_color = "green" if cpi >= 1.0 else "red" + cpi_icon = "⬆️" if cpi >= 1.0 else "⬇️" + cpi_trend = round((cpi - self.kpi_data['trends']['cpi'][0]) * 100, 1) + + st.markdown(f""" +
+
مؤشر أداء التكلفة
+
{cpi}
+
+ {cpi_icon} {cpi_trend}% +
+
+ """, unsafe_allow_html=True) + + # مؤشر درجة المخاطر + risk = self.kpi_data['risk_score'] + risk_color = "green" if risk < 20 else "orange" if risk < 40 else "red" + risk_icon = "⬇️" if risk < 20 else "➡️" if risk < 40 else "⬆️" + + st.markdown(f""" +
+
درجة المخاطر
+
{risk}%
+
+ {risk_icon} منخفضة +
+
+ """, unsafe_allow_html=True) + + with col3: + # مؤشر استغلال الموارد + resources = self.kpi_data['resource_utilization'] + resources_color = "green" if resources >= 85 else "orange" if resources >= 70 else "red" + + st.markdown(f""" +
+
استغلال الموارد
+
{resources}%
+
+ استغلال فعال للموارد +
+
+ """, unsafe_allow_html=True) + + # عدد حوادث السلامة + safety = self.kpi_data['safety_incidents'] + safety_color = "green" if safety == 0 else "orange" if safety < 3 else "red" + + st.markdown(f""" +
+
حوادث السلامة
+
{safety}
+
+ سجل سلامة ممتاز +
+
+ """, unsafe_allow_html=True) + + with col4: + # مؤشر رضا العميل + satisfaction = self.kpi_data['customer_satisfaction'] + satisfaction_color = "green" if satisfaction >= 90 else "orange" if satisfaction >= 75 else "red" + + st.markdown(f""" +
+
رضا العميل
+
{satisfaction}%
+
+ مستوى رضا ممتاز +
+
+ """, unsafe_allow_html=True) + + # مؤشر الامتثال البيئي + compliance = self.kpi_data['environmental_compliance'] + compliance_color = "green" if compliance >= 90 else "orange" if compliance >= 75 else "red" + + st.markdown(f""" +
+
الامتثال البيئي
+
{compliance}%
+
+ امتثال كامل للمعايير +
+
+ """, unsafe_allow_html=True) + + def _render_project_progress(self): + """عرض تقدم المشروع""" + st.markdown("

تقدم المشروع

", unsafe_allow_html=True) + + # مؤشر التقدم الكلي + st.markdown("
التقدم الكلي للمشروع
", unsafe_allow_html=True) + + # حساب النسبة المئوية للوقت المنقضي + start_date = datetime.strptime(self.project_data['start_date'], '%Y-%m-%d') + end_date = datetime.strptime(self.project_data['end_date'], '%Y-%m-%d') + total_days = (end_date - start_date).days + elapsed_days = self.project_data['elapsed_days'] + time_percentage = min(100, max(0, round((elapsed_days / total_days) * 100))) + + # تقدم المشروع + progress_percentage = self.project_data['overall_progress'] + + # تحديد لون التقدم + progress_status = "ahead" if progress_percentage > time_percentage else "behind" if progress_percentage < time_percentage else "on-track" + progress_color = "#00b894" if progress_status == "ahead" else "#d63031" if progress_status == "behind" else "#fdcb6e" + + # إنشاء المخطط + fig = go.Figure() + + # إضافة خط التقدم المخطط + fig.add_trace(go.Scatter( + x=[start_date, end_date], + y=[0, 100], + mode='lines', + name='التقدم المخطط', + line=dict(color='rgba(0, 0, 0, 0.3)', width=2, dash='dash'), + hoverinfo='text', + hovertext=['بداية المشروع', 'نهاية المشروع'] + )) + + # إضافة نقطة الوقت الحالي + current_date = start_date + timedelta(days=elapsed_days) + fig.add_trace(go.Scatter( + x=[current_date], + y=[time_percentage], + mode='markers', + name='الوقت المنقضي', + marker=dict(color='#2d3436', size=12, symbol='diamond'), + hoverinfo='text', + hovertext=[f'الوقت المنقضي: {time_percentage}%'] + )) + + # إضافة نقطة التقدم الفعلي + fig.add_trace(go.Scatter( + x=[current_date], + y=[progress_percentage], + mode='markers', + name='التقدم الفعلي', + marker=dict(color=progress_color, size=16, symbol='circle'), + hoverinfo='text', + hovertext=[f'التقدم الفعلي: {progress_percentage}%'] + )) + + # تنسيق المخطط + fig.update_layout( + title=f"التقدم: {progress_percentage}% - الوقت المنقضي: {time_percentage}%", + xaxis_title="التاريخ", + yaxis_title="نسبة التقدم (%)", + legend=dict( + x=0, + y=1.1, + orientation='h' + ), + height=300, + margin=dict(l=20, r=20, t=50, b=20), + xaxis=dict(showgrid=False), + yaxis=dict(range=[0, 100]), + hovermode='closest', + plot_bgcolor='rgba(255, 255, 255, 0.8)' + ) + + st.plotly_chart(fig, use_container_width=True) + + # حالة التقدم + status_message = "المشروع متقدم عن الجدول الزمني" if progress_status == "ahead" else "المشروع متأخر عن الجدول الزمني" if progress_status == "behind" else "المشروع في الموعد المحدد" + status_color = "green" if progress_status == "ahead" else "red" if progress_status == "behind" else "orange" + + st.markdown(f""" +
+ {status_message} (الفرق: {abs(progress_percentage - time_percentage)}%) +
+ """, unsafe_allow_html=True) + + # تحليل المراحل + st.markdown("
تقدم مراحل المشروع
", unsafe_allow_html=True) + + # ترتيب المراحل + sorted_phases = sorted(self.project_data['phases'], key=lambda x: x['order']) + + # إنشاء بيانات للرسم البياني + phase_labels = [phase['name'] for phase in sorted_phases] + phase_progress = [phase['progress'] for phase in sorted_phases] + phase_colors = [ + "#00b894" if phase['status'] == "completed" else + "#fdcb6e" if phase['status'] == "in_progress" else + "#d63031" if phase['status'] == "delayed" else + "#a29bfe" if phase['status'] == "on_hold" else + "#dfe6e9" # لحالة not_started + for phase in sorted_phases + ] + + # إنشاء الرسم البياني + fig = go.Figure() + + fig.add_trace(go.Bar( + y=phase_labels, + x=phase_progress, + orientation='h', + marker=dict( + color=phase_colors + ), + hoverinfo='text', + hovertext=[f"{phase['name']}: {phase['progress']}% - {self._get_status_text(phase['status'])}" for phase in sorted_phases] + )) + + fig.update_layout( + title="تقدم مراحل المشروع", + xaxis_title="نسبة التقدم (%)", + yaxis=dict( + categoryorder='array', + categoryarray=phase_labels + ), + xaxis=dict(range=[0, 100]), + height=400, + margin=dict(l=20, r=20, t=50, b=20), + plot_bgcolor='rgba(255, 255, 255, 0.8)' + ) + + st.plotly_chart(fig, use_container_width=True) + + # وضع مفتاح الألوان + col1, col2, col3, col4, col5 = st.columns(5) + with col1: + st.markdown('
مكتمل
', unsafe_allow_html=True) + with col2: + st.markdown('
قيد التنفيذ
', unsafe_allow_html=True) + with col3: + st.markdown('
متأخر
', unsafe_allow_html=True) + with col4: + st.markdown('
متوقف
', unsafe_allow_html=True) + with col5: + st.markdown('
لم يبدأ
', unsafe_allow_html=True) + + def _render_phases_timeline(self): + """عرض الجدول الزمني للمراحل""" + st.markdown("

الجدول الزمني للمراحل

", unsafe_allow_html=True) + + # ترتيب المراحل + sorted_phases = sorted(self.project_data['phases'], key=lambda x: x['order']) + + # تحويل التواريخ إلى كائنات datetime + for phase in sorted_phases: + phase['start_date_obj'] = datetime.strptime(phase['start_date'], '%Y-%m-%d') + phase['end_date_obj'] = datetime.strptime(phase['end_date'], '%Y-%m-%d') + if phase['actual_end_date']: + phase['actual_end_date_obj'] = datetime.strptime(phase['actual_end_date'], '%Y-%m-%d') + else: + phase['actual_end_date_obj'] = None + + # إيجاد أدنى وأقصى تاريخ + min_date = min(phase['start_date_obj'] for phase in sorted_phases) + max_date = max(phase['end_date_obj'] for phase in sorted_phases) + + # إضافة هامش + date_range = (max_date - min_date).days + min_date = min_date - timedelta(days=date_range * 0.05) + max_date = max_date + timedelta(days=date_range * 0.05) + + # إنشاء الرسم البياني + fig = go.Figure() + + # إضافة خط الوقت الحالي + current_date = datetime.now() + fig.add_shape( + type="line", + x0=current_date, + y0=-0.5, + x1=current_date, + y1=len(sorted_phases) - 0.5, + line=dict( + color="red", + width=2, + dash="dash", + ), + name="اليوم" + ) + + # إضافة المراحل + for i, phase in enumerate(sorted_phases): + # إضافة المرحلة المخططة + fig.add_trace(go.Bar( + x=[phase['end_date_obj'] - phase['start_date_obj']], + y=[phase['name']], + orientation='h', + base=[phase['start_date_obj']], + marker=dict( + color='rgba(171, 214, 255, 0.8)', + line=dict(color='rgba(50, 136, 229, 1.0)', width=1) + ), + name=phase['name'] + " (مخطط)", + hoverinfo='text', + hovertext=[f"{phase['name']} (مخطط)
البداية: {phase['start_date']}
النهاية: {phase['end_date']}
المدة: {(phase['end_date_obj'] - phase['start_date_obj']).days} يوم"] + )) + + # تحديد لون المرحلة الفعلية + if phase['status'] == "completed": + color = 'rgba(0, 184, 148, 0.8)' + line_color = 'rgba(0, 150, 136, 1.0)' + elif phase['status'] == "in_progress": + color = 'rgba(253, 203, 110, 0.8)' + line_color = 'rgba(225, 177, 44, 1.0)' + elif phase['status'] == "delayed": + color = 'rgba(214, 48, 49, 0.8)' + line_color = 'rgba(192, 57, 43, 1.0)' + elif phase['status'] == "on_hold": + color = 'rgba(162, 155, 254, 0.8)' + line_color = 'rgba(108, 92, 231, 1.0)' + else: # not_started + continue # لا نعرض المراحل التي لم تبدأ بعد + + # إضافة المرحلة الفعلية + if phase['status'] in ["completed", "in_progress", "delayed", "on_hold"]: + if phase['status'] == "completed" and phase['actual_end_date_obj']: + # للمراحل المكتملة التي لها تاريخ انتهاء فعلي + duration = phase['actual_end_date_obj'] - phase['start_date_obj'] + fig.add_trace(go.Bar( + x=[duration], + y=[phase['name']], + orientation='h', + base=[phase['start_date_obj']], + marker=dict( + color=color, + line=dict(color=line_color, width=1) + ), + name=phase['name'] + " (فعلي)", + hoverinfo='text', + hovertext=[f"{phase['name']} (فعلي)
البداية: {phase['start_date']}
النهاية الفعلية: {phase['actual_end_date']}
المدة: {duration.days} يوم
التقدم: {phase['progress']}%"] + )) + else: + # للمراحل الجارية أو المتأخرة أو المعلقة + if current_date > phase['start_date_obj']: + duration = current_date - phase['start_date_obj'] + fig.add_trace(go.Bar( + x=[duration], + y=[phase['name']], + orientation='h', + base=[phase['start_date_obj']], + marker=dict( + color=color, + line=dict(color=line_color, width=1) + ), + name=phase['name'] + " (فعلي)", + hoverinfo='text', + hovertext=[f"{phase['name']} (فعلي)
البداية: {phase['start_date']}
حتى تاريخه: {current_date.strftime('%Y-%m-%d')}
المدة: {duration.days} يوم
التقدم: {phase['progress']}%"] + )) + + # تنسيق المخطط + fig.update_layout( + title="الجدول الزمني للمراحل", + xaxis=dict(type='date', range=[min_date, max_date], title='التاريخ'), + yaxis=dict(title=None, autorange="reversed"), + height=400, + margin=dict(l=20, r=20, t=50, b=20), + barmode='overlay', + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), + plot_bgcolor='rgba(255, 255, 255, 0.8)' + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض تفاصيل المراحل + with st.expander("عرض تفاصيل المراحل"): + for phase in sorted_phases: + status_class = self._get_status_class(phase['status']) + status_text = self._get_status_text(phase['status']) + critical_badge = 'مسار حرج' if phase.get('critical', False) else '' + + st.markdown(f""" +
+
+
{phase['name']} {critical_badge}
+
{status_text}
+
+
+
+
+
+
{phase['progress']}%
+
+
+
+
البداية: {phase['start_date']}
+
النهاية (مخطط): {phase['end_date']}
+ {f'
النهاية (فعلي): {phase["actual_end_date"]}
' if phase['actual_end_date'] else ''} +
+
+
المسؤول: {phase['responsible']}
+
التسليمات: {', '.join(phase['deliverables'])}
+
+
+
{phase['notes']}
+
+ """, unsafe_allow_html=True) + + def _render_kpi_trends(self): + """عرض اتجاهات مؤشرات الأداء الرئيسية""" + st.markdown("

اتجاهات مؤشرات الأداء

", unsafe_allow_html=True) + + tabs = st.tabs(["أداء الجدول الزمني والتكلفة", "الجودة والمخاطر"]) + + with tabs[0]: + # تحويل البيانات + dates = [datetime.strptime(date, '%Y-%m-%d') for date in self.kpi_data['trends']['dates']] + spi_values = self.kpi_data['trends']['spi'] + cpi_values = self.kpi_data['trends']['cpi'] + + # إنشاء رسم بياني لمؤشر أداء الجدول الزمني والتكلفة + fig = go.Figure() + + # مؤشر أداء الجدول الزمني + fig.add_trace(go.Scatter( + x=dates, + y=spi_values, + mode='lines+markers', + name='مؤشر أداء الجدول الزمني (SPI)', + line=dict(color='#00b894', width=2), + marker=dict(size=8) + )) + + # مؤشر أداء التكلفة + fig.add_trace(go.Scatter( + x=dates, + y=cpi_values, + mode='lines+markers', + name='مؤشر أداء التكلفة (CPI)', + line=dict(color='#0984e3', width=2), + marker=dict(size=8) + )) + + # إضافة خط المرجع (1.0) + fig.add_shape( + type="line", + x0=min(dates), + y0=1.0, + x1=max(dates), + y1=1.0, + line=dict( + color="#636e72", + width=1, + dash="dash", + ) + ) + + # تنسيق الرسم البياني + fig.update_layout( + title="اتجاهات مؤشرات أداء الجدول الزمني والتكلفة", + xaxis_title="التاريخ", + yaxis_title="القيمة", + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), + height=350, + margin=dict(l=20, r=20, t=50, b=20), + yaxis=dict(range=[0.8, 1.2], zeroline=False), + hovermode='x unified', + plot_bgcolor='rgba(255, 255, 255, 0.8)' + ) + + fig.update_yaxes(tickformat=".2f") + + st.plotly_chart(fig, use_container_width=True) + + # شرح المؤشرات + col1, col2 = st.columns(2) + with col1: + st.markdown(""" +
+

مؤشر أداء الجدول الزمني (SPI)

+
    +
  • SPI > 1.0: المشروع متقدم عن الجدول الزمني
  • +
  • SPI = 1.0: المشروع في الموعد المحدد
  • +
  • SPI < 1.0: المشروع متأخر عن الجدول الزمني
  • +
+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+

مؤشر أداء التكلفة (CPI)

+
    +
  • CPI > 1.0: المشروع أقل من الميزانية
  • +
  • CPI = 1.0: المشروع ضمن الميزانية
  • +
  • CPI < 1.0: المشروع تجاوز الميزانية
  • +
+
+ """, unsafe_allow_html=True) + + with tabs[1]: + # تحويل البيانات + dates = [datetime.strptime(date, '%Y-%m-%d') for date in self.kpi_data['trends']['dates']] + quality_values = self.kpi_data['trends']['quality_score'] + risk_values = self.kpi_data['trends']['risk_score'] + + # إنشاء رسم بياني للجودة والمخاطر + fig = go.Figure() + + # مؤشر الجودة + fig.add_trace(go.Scatter( + x=dates, + y=quality_values, + mode='lines+markers', + name='درجة الجودة (%)', + line=dict(color='#00b894', width=2), + marker=dict(size=8) + )) + + # إنشاء محور Y ثانوي للمخاطر + fig.add_trace(go.Scatter( + x=dates, + y=risk_values, + mode='lines+markers', + name='درجة المخاطر (%)', + line=dict(color='#d63031', width=2), + marker=dict(size=8), + yaxis="y2" + )) + + # تنسيق الرسم البياني + fig.update_layout( + title="اتجاهات الجودة والمخاطر", + xaxis_title="التاريخ", + yaxis=dict( + title="درجة الجودة (%)", + range=[0, 100], + side="left", + zeroline=False + ), + yaxis2=dict( + title="درجة المخاطر (%)", + range=[0, 100], + side="right", + overlaying="y", + zeroline=False + ), + legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1), + height=350, + margin=dict(l=20, r=20, t=50, b=20), + hovermode='x unified', + plot_bgcolor='rgba(255, 255, 255, 0.8)' + ) + + st.plotly_chart(fig, use_container_width=True) + + # شرح المؤشرات + col1, col2 = st.columns(2) + with col1: + st.markdown(""" +
+

درجة الجودة

+
    +
  • 90-100%: ممتاز
  • +
  • 80-89%: جيد جداً
  • +
  • 70-79%: جيد
  • +
  • < 70%: تحتاج إلى تحسين
  • +
+
+ """, unsafe_allow_html=True) + + with col2: + st.markdown(""" +
+

درجة المخاطر

+
    +
  • 0-20%: منخفضة
  • +
  • 21-40%: متوسطة
  • +
  • 41-70%: عالية
  • +
  • > 70%: حرجة
  • +
+
+ """, unsafe_allow_html=True) + + def _render_issues_table(self): + """عرض جدول المشكلات""" + st.markdown("

مشكلات المشروع

", unsafe_allow_html=True) + + if not self.kpi_data['issues']: + st.info("لا توجد مشكلات مسجلة للمشروع.") + return + + # تحويل المشكلات إلى DataFrame + issues_data = pd.DataFrame(self.kpi_data['issues']) + + # تنسيق الجدول + st.markdown(""" + + """, unsafe_allow_html=True) + + # قائمة المشكلات + for issue in self.kpi_data['issues']: + # تحديد فئة الخطورة + severity_class = "" + if issue['severity'] == "عالي": + severity_class = "high" + elif issue['severity'] == "متوسط": + severity_class = "medium" + else: + severity_class = "low" + + # تحديد فئة الحالة + status_class = "" + if issue['status'] == "تم الحل": + status_class = "resolved" + elif issue['status'] == "قيد المعالجة": + status_class = "in-progress" + elif issue['status'] == "معلق": + status_class = "on-hold" + else: + status_class = "open" + + st.markdown(f""" +
+
+
#{issue['id']} - {issue['description']}
+
+ {issue['severity']} + {issue['status']} +
+
+
+
+
تاريخ الإنشاء: {issue['created_date']}
+
المسؤول: {issue['responsible']}
+
+
+
خطة المعالجة: {issue['resolution']}
+
+
+
+ """, unsafe_allow_html=True) + + if st.checkbox("إضافة مشكلة جديدة"): + with st.form("add_issue_form"): + description = st.text_input("وصف المشكلة") + + col1, col2 = st.columns(2) + with col1: + severity = st.selectbox("الخطورة", ["منخفض", "متوسط", "عالي"]) + with col2: + responsible = st.text_input("المسؤول") + + resolution = st.text_area("خطة المعالجة") + + submitted = st.form_submit_button("إضافة المشكلة") + if submitted: + if description and responsible: + self.add_project_issue(description, severity, responsible, resolution) + st.success("تمت إضافة المشكلة بنجاح") + st.rerun() + else: + st.error("يرجى ملء جميع الحقول المطلوبة") + + def _render_update_panel(self): + """عرض لوحة تحديث حالة المشروع""" + st.markdown("

تحديث حالة المشروع

", unsafe_allow_html=True) + + tabs = st.tabs(["تحديث مرحلة", "تحديث مؤشرات الأداء", "تحديث حالة مشكلة"]) + + with tabs[0]: + # تحديث مرحلة + sorted_phases = sorted(self.project_data['phases'], key=lambda x: x['order']) + phase_names = [phase['name'] for phase in sorted_phases] + phase_ids = [phase['id'] for phase in sorted_phases] + + selected_phase_index = st.selectbox("اختر المرحلة", range(len(phase_names)), format_func=lambda i: phase_names[i]) + selected_phase = sorted_phases[selected_phase_index] + + with st.form("update_phase_form"): + st.markdown(f"**تحديث مرحلة: {selected_phase['name']}**") + + col1, col2 = st.columns(2) + with col1: + progress = st.slider("نسبة التقدم", 0, 100, selected_phase['progress']) + with col2: + status = st.selectbox("الحالة", [ + "not_started", "in_progress", "completed", "delayed", "on_hold" + ], + index=["not_started", "in_progress", "completed", "delayed", "on_hold"].index(selected_phase['status']), + format_func=lambda s: self._get_status_text(s)) + + actual_end_date = None + if status == "completed": + actual_end_date = st.date_input("تاريخ الانتهاء الفعلي", + value=datetime.now() if not selected_phase['actual_end_date'] else datetime.strptime(selected_phase['actual_end_date'], '%Y-%m-%d')) + actual_end_date = actual_end_date.strftime('%Y-%m-%d') + + notes = st.text_area("ملاحظات", selected_phase['notes']) + + submitted = st.form_submit_button("تحديث المرحلة") + if submitted: + success = self.update_project_status(selected_phase['id'], progress, status, actual_end_date, notes) + if success: + st.success("تم تحديث حالة المرحلة بنجاح") + st.rerun() + else: + st.error("حدث خطأ أثناء تحديث حالة المرحلة") + + with tabs[1]: + # تحديث مؤشرات الأداء + with st.form("update_kpi_form"): + st.markdown("**تحديث مؤشرات الأداء**") + + col1, col2 = st.columns(2) + with col1: + spi = st.number_input("مؤشر أداء الجدول الزمني (SPI)", 0.1, 2.0, self.kpi_data['spi'], 0.01) + cpi = st.number_input("مؤشر أداء التكلفة (CPI)", 0.1, 2.0, self.kpi_data['cpi'], 0.01) + quality_score = st.slider("درجة الجودة", 0, 100, self.kpi_data['quality_score']) + risk_score = st.slider("درجة المخاطر", 0, 100, self.kpi_data['risk_score']) + + with col2: + resource_utilization = st.slider("استغلال الموارد", 0, 100, self.kpi_data['resource_utilization']) + safety_incidents = st.number_input("حوادث السلامة", 0, 100, self.kpi_data['safety_incidents']) + customer_satisfaction = st.slider("رضا العميل", 0, 100, self.kpi_data['customer_satisfaction']) + environmental_compliance = st.slider("الامتثال البيئي", 0, 100, self.kpi_data['environmental_compliance']) + + submitted = st.form_submit_button("تحديث مؤشرات الأداء") + if submitted: + kpi_updates = { + 'spi': spi, + 'cpi': cpi, + 'quality_score': quality_score, + 'risk_score': risk_score, + 'resource_utilization': resource_utilization, + 'safety_incidents': safety_incidents, + 'customer_satisfaction': customer_satisfaction, + 'environmental_compliance': environmental_compliance + } + + success = self.update_kpi_values(kpi_updates) + if success: + st.success("تم تحديث مؤشرات الأداء بنجاح") + st.rerun() + else: + st.error("حدث خطأ أثناء تحديث مؤشرات الأداء") + + with tabs[2]: + # تحديث حالة مشكلة + if not self.kpi_data['issues']: + st.info("لا توجد مشكلات لتحديثها.") + else: + issue_descriptions = [f"#{issue['id']} - {issue['description']}" for issue in self.kpi_data['issues']] + issue_ids = [issue['id'] for issue in self.kpi_data['issues']] + + selected_issue_index = st.selectbox("اختر المشكلة", range(len(issue_descriptions)), format_func=lambda i: issue_descriptions[i]) + selected_issue = self.kpi_data['issues'][selected_issue_index] + + with st.form("update_issue_form"): + st.markdown(f"**تحديث حالة المشكلة: {selected_issue['description']}**") + + status = st.selectbox("الحالة", [ + "مفتوح", "قيد المعالجة", "تم الحل", "معلق" + ], + index=["مفتوح", "قيد المعالجة", "تم الحل", "معلق"].index(selected_issue['status']) if selected_issue['status'] in ["مفتوح", "قيد المعالجة", "تم الحل", "معلق"] else 0) + + resolution = st.text_area("خطة المعالجة / الحل", selected_issue['resolution']) + + submitted = st.form_submit_button("تحديث المشكلة") + if submitted: + success = self.update_issue_status(selected_issue['id'], status, resolution) + if success: + st.success("تم تحديث حالة المشكلة بنجاح") + st.rerun() + else: + st.error("حدث خطأ أثناء تحديث حالة المشكلة") + + def _get_status_text(self, status): + """الحصول على النص العربي لحالة المرحلة""" + status_map = { + "not_started": "لم تبدأ", + "in_progress": "قيد التنفيذ", + "completed": "مكتملة", + "delayed": "متأخرة", + "on_hold": "متوقفة" + } + return status_map.get(status, status) + + def _get_status_class(self, status): + """الحصول على فئة CSS لحالة المرحلة""" + status_map = { + "not_started": "not-started", + "in_progress": "in-progress", + "completed": "completed", + "delayed": "delayed", + "on_hold": "on-hold" + } + return status_map.get(status, "") + + def render(self): + """عرض واجهة متتبع حالة المشروع""" + # إضافة CSS مخصص + st.markdown(""" + + """, unsafe_allow_html=True) + + # عرض لوحة تحكم حالة المشروع + self.render_project_status_dashboard() \ No newline at end of file diff --git a/modules/project_tracker/tracker_app.py b/modules/project_tracker/tracker_app.py new file mode 100644 index 0000000000000000000000000000000000000000..3fa249317afc63249666dbecd7802b8e6f902e94 --- /dev/null +++ b/modules/project_tracker/tracker_app.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة تطبيق متتبع حالة المشروع المتحرك مع تصور التقدم +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +from datetime import datetime, timedelta + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات متتبع حالة المشروع +from modules.project_tracker.status_tracker import ProjectStatusTracker + + +class TrackerApp: + """وحدة تطبيق متتبع حالة المشروع المتحرك""" + + def __init__(self, project_id=None, user_id=None): + """تهيئة وحدة تطبيق متتبع حالة المشروع المتحرك""" + self.project_tracker = ProjectStatusTracker(project_id, user_id) + + def render(self): + """عرض واجهة وحدة تطبيق متتبع حالة المشروع المتحرك""" + self.project_tracker.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="متتبع حالة المشروع المتحرك | WAHBi AI", + page_icon="📊", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = TrackerApp() + app.render() \ No newline at end of file diff --git a/modules/projects/__init__.py b/modules/projects/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4a6c17a73333f06321271be498c9a2e2a175542f --- /dev/null +++ b/modules/projects/__init__.py @@ -0,0 +1 @@ +# ملف تهيئة حزمة إدارة المشاريع \ No newline at end of file diff --git a/modules/projects/projects_app.py b/modules/projects/projects_app.py new file mode 100644 index 0000000000000000000000000000000000000000..a2996048f89249e786f202765bc753d054e48c3e --- /dev/null +++ b/modules/projects/projects_app.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة إدارة المشاريع لنظام تحليل العقود والمناقصات +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات إدارة المشاريع +from modules.projects.projects_management import ProjectsManagement + + +class ProjectsApp: + """وحدة إدارة المشاريع الرئيسية""" + + def __init__(self): + """تهيئة وحدة إدارة المشاريع""" + self.projects_management = ProjectsManagement() + + def render(self): + """عرض واجهة وحدة إدارة المشاريع""" + st.markdown("

وحدة إدارة المشاريع

", unsafe_allow_html=True) + + st.markdown(""" +
+ تمكنك وحدة إدارة المشاريع من إنشاء وتتبع وإدارة المشاريع بكفاءة، مع ميزات متقدمة لمراقبة المواعيد النهائية والموارد. + يمكنك إضافة معلومات تفصيلية للمشاريع، بما في ذلك معلومات الموقع، مرئيات المدير، والمخاطر والمميزات. +
+ """, unsafe_allow_html=True) + + # عرض نموذج إدارة المشاريع + self.projects_management.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="إدارة المشاريع | WAHBi AI", + page_icon="🏗️", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = ProjectsApp() + app.render() \ No newline at end of file diff --git a/modules/projects/projects_management.py b/modules/projects/projects_management.py new file mode 100644 index 0000000000000000000000000000000000000000..812d74bb3eac0b29d54ef2ff3b3210c749983096 --- /dev/null +++ b/modules/projects/projects_management.py @@ -0,0 +1,942 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +نموذج إدارة المشاريع لنظام WAHBi-AI +يتضمن ميزات إضافة وإدارة المشاريع الجديدة مع معلومات موسعة +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import datetime +import tempfile +from pathlib import Path +import plotly.express as px +import plotly.graph_objects as go +import json +import time +from datetime import datetime, timedelta + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +from utils.components.credits import render_credits +from utils.helpers import format_number, format_currency, styled_button + +class ProjectsManagement: + """نموذج إدارة المشاريع""" + + def __init__(self): + """تهيئة نموذج إدارة المشاريع""" + # تهيئة حالة الجلسة + if 'projects' not in st.session_state: + st.session_state.projects = [] + + if 'project_files' not in st.session_state: + st.session_state.project_files = {} + + if 'project_inquiries' not in st.session_state: + st.session_state.project_inquiries = {} + + # ضمان وجود مجلد لحفظ ملفات المشاريع + self.projects_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../data/projects")) + os.makedirs(self.projects_dir, exist_ok=True) + + def render(self): + """عرض واجهة إدارة المشاريع""" + # عرض الشعار والعنوان الرئيسي + render_header("إدارة المشاريع") + + # تبويبات إدارة المشاريع + tabs = st.tabs(["المشاريع الحالية", "إضافة مشروع جديد", "أرشيف المشاريع", "التقارير"]) + + with tabs[0]: + self._render_current_projects() + + with tabs[1]: + self._render_new_project_form() + + with tabs[2]: + self._render_archived_projects() + + with tabs[3]: + self._render_projects_reports() + + # عرض الحقوق + render_credits() + + def _render_current_projects(self): + """عرض قائمة المشاريع الحالية""" + st.markdown(""" +
+

🏗️ المشاريع الحالية

+

قائمة المشاريع النشطة التي يتم العمل عليها حالياً.

+
+ """, unsafe_allow_html=True) + + # تصفية المشاريع النشطة + active_projects = [p for p in st.session_state.projects if p.get("status") != "archived"] + + if not active_projects: + st.info("لا توجد مشاريع نشطة حالياً. يمكنك إضافة مشروع جديد من تبويب 'إضافة مشروع جديد'.") + return + + # عرض المشاريع النشطة + for idx, project in enumerate(active_projects): + with st.expander(f"{project.get('name')} - {project.get('client')}"): + self._render_project_details(project, idx) + + def _render_project_details(self, project, idx): + """عرض تفاصيل مشروع محدد""" + # معلومات أساسية + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown(f"**اسم المشروع:** {project.get('name')}") + st.markdown(f"**رقم المشروع:** {project.get('number')}") + + with col2: + st.markdown(f"**العميل:** {project.get('client')}") + st.markdown(f"**الموقع:** {project.get('location')}") + + with col3: + st.markdown(f"**تاريخ البدء:** {project.get('start_date')}") + st.markdown(f"**تاريخ التقديم:** {project.get('submission_date')}") + + # تبويبات تفاصيل المشروع + project_tabs = st.tabs([ + "معلومات المشروع", + "مرئيات مدير المنطقة", + "صور وفيديوهات الموقع", + "مميزات ومخاطر المشروع", + "استفسارات المالك", + "معلومات الموقع" + ]) + + # تبويب معلومات المشروع + with project_tabs[0]: + # المعلومات الأساسية + st.markdown("### معلومات المشروع الأساسية") + st.markdown(f"**نوع المشروع:** {project.get('type')}") + st.markdown(f"**القيمة التقديرية:** {format_currency(project.get('estimated_value', 0))} ريال") + st.markdown(f"**المدة المتوقعة:** {project.get('duration')} يوم") + st.markdown(f"**حالة المشروع:** {project.get('status')}") + + # جدول زمني للمشروع + st.markdown("### الجدول الزمني للمشروع") + + # حساب الأيام المتبقية للتقديم + if project.get('submission_date'): + try: + submission_date = datetime.strptime(project.get('submission_date'), "%Y-%m-%d") + today = datetime.now() + days_remaining = (submission_date - today).days + + # عرض شريط التقدم للوقت المتبقي + if days_remaining > 0: + st.markdown(f"**الوقت المتبقي للتقديم:** {days_remaining} يوم") + progress_pct = min(1.0, max(0.0, days_remaining / 30.0)) # افتراض 30 يوم كمدة قياسية + st.progress(progress_pct) + else: + st.error(f"**انتهت مدة التقديم منذ:** {abs(days_remaining)} يوم") + except: + st.warning("تعذر حساب الأيام المتبقية. يرجى التأكد من صحة التاريخ.") + + # أزرار العمليات + col1, col2 = st.columns(2) + with col1: + if styled_button("تحديث حالة المشروع", key=f"update_project_{idx}", type="primary", icon="🔄"): + st.session_state.project_to_update = idx + + with col2: + if styled_button("تصدير معلومات المشروع", key=f"export_project_{idx}", type="success", icon="📤"): + st.session_state.project_to_export = idx + + # تبويب مرئيات مدير المنطقة + with project_tabs[1]: + st.markdown("### مرئيات مدير المنطقة") + + # عرض المرئيات الموجودة + manager_insights = project.get('manager_insights', []) + + if manager_insights: + for i, insight in enumerate(manager_insights): + with st.expander(f"مرئية #{i+1} - {insight.get('date')}"): + st.markdown(f"**العنوان:** {insight.get('title')}") + st.markdown(f"**التاريخ:** {insight.get('date')}") + st.markdown(f"**المحتوى:**\n{insight.get('content')}") + + # عرض المرفقات إن وجدت + attachments = insight.get('attachments', []) + if attachments: + st.markdown("**المرفقات:**") + for att in attachments: + st.markdown(f"- {att}") + else: + st.info("لا توجد مرئيات مضافة لمدير المنطقة.") + + # إضافة مرئية جديدة + st.markdown("### إضافة مرئية جديدة") + + insight_title = st.text_input("عنوان المرئية", key=f"new_insight_title_{idx}") + insight_content = st.text_area("محتوى المرئية", key=f"new_insight_content_{idx}") + insight_file = st.file_uploader("إرفاق ملف (اختياري)", key=f"new_insight_file_{idx}") + + if styled_button("إضافة مرئية", key=f"add_insight_{idx}", type="primary", icon="➕"): + if not insight_title or not insight_content: + st.error("يرجى تعبئة عنوان ومحتوى المرئية.") + else: + # إنشاء مرئية جديدة + new_insight = { + "title": insight_title, + "date": datetime.now().strftime("%Y-%m-%d"), + "content": insight_content, + "attachments": [] + } + + # حفظ الملف المرفق إن وجد + if insight_file: + file_path = self._save_project_file(project.get('number'), "insights", insight_file) + if file_path: + new_insight["attachments"].append(file_path) + + # إضافة المرئية للمشروع + if 'manager_insights' not in project: + project['manager_insights'] = [] + + project['manager_insights'].append(new_insight) + st.success("تمت إضافة المرئية بنجاح!") + st.rerun() + + # تبويب صور وفيديوهات الموقع + with project_tabs[2]: + st.markdown("### صور وفيديوهات الموقع") + + # عرض الصور والفيديوهات الموجودة + site_media = project.get('site_media', []) + + if site_media: + media_tabs = st.tabs(["الصور", "الفيديوهات", "مرفقات أخرى"]) + + # عرض الصور + with media_tabs[0]: + images = [m for m in site_media if m.get('type') == 'image'] + if images: + for img in images: + st.markdown(f"**{img.get('title')}** - {img.get('date')}") + if 'file_path' in img: + try: + # يمكن تنفيذ عرض الصورة هنا إذا كانت متاحة + st.markdown(f"*مسار الملف:* {img.get('file_path')}") + except: + st.warning("تعذر عرض الصورة.") + st.markdown(f"*الوصف:* {img.get('description', '')}") + st.markdown("---") + else: + st.info("لا توجد صور مضافة للموقع.") + + # عرض الفيديوهات + with media_tabs[1]: + videos = [m for m in site_media if m.get('type') == 'video'] + if videos: + for vid in videos: + st.markdown(f"**{vid.get('title')}** - {vid.get('date')}") + if 'file_path' in vid: + st.markdown(f"*مسار الملف:* {vid.get('file_path')}") + st.markdown(f"*الوصف:* {vid.get('description', '')}") + st.markdown("---") + else: + st.info("لا توجد فيديوهات مضافة للموقع.") + + # عرض مرفقات أخرى + with media_tabs[2]: + other_files = [m for m in site_media if m.get('type') not in ['image', 'video']] + if other_files: + for f in other_files: + st.markdown(f"**{f.get('title')}** - {f.get('date')}") + if 'file_path' in f: + st.markdown(f"*مسار الملف:* {f.get('file_path')}") + st.markdown(f"*الوصف:* {f.get('description', '')}") + st.markdown("---") + else: + st.info("لا توجد مرفقات أخرى للموقع.") + else: + st.info("لا توجد صور أو فيديوهات مضافة للموقع.") + + # إضافة وسائط جديدة + st.markdown("### إضافة صور أو فيديوهات جديدة") + + media_type = st.selectbox( + "نوع الملف", + options=["صورة", "فيديو", "ملف آخر"], + key=f"new_media_type_{idx}" + ) + + media_title = st.text_input("عنوان الملف", key=f"new_media_title_{idx}") + media_desc = st.text_area("وصف الملف", key=f"new_media_desc_{idx}") + media_file = st.file_uploader( + "اختر الملف", + type=["jpg", "jpeg", "png", "mp4", "avi", "mov", "pdf", "docx"] if media_type == "ملف آخر" else + ["mp4", "avi", "mov"] if media_type == "فيديو" else + ["jpg", "jpeg", "png"], + key=f"new_media_file_{idx}" + ) + + if styled_button("إضافة للموقع", key=f"add_media_{idx}", type="primary", icon="➕"): + if not media_title or not media_file: + st.error("يرجى تعبئة عنوان الملف واختيار الملف.") + else: + # تحديد نوع الملف لحفظه + file_type = "images" if media_type == "صورة" else "videos" if media_type == "فيديو" else "others" + + # حفظ الملف + file_path = self._save_project_file(project.get('number'), file_type, media_file) + + if file_path: + # إنشاء كائن الوسائط + new_media = { + "title": media_title, + "date": datetime.now().strftime("%Y-%m-%d"), + "description": media_desc, + "type": "image" if media_type == "صورة" else "video" if media_type == "فيديو" else "other", + "file_path": file_path + } + + # إضافة الوسائط للمشروع + if 'site_media' not in project: + project['site_media'] = [] + + project['site_media'].append(new_media) + st.success(f"تمت إضافة {media_type} بنجاح!") + st.rerun() + + # تبويب مميزات ومخاطر المشروع + with project_tabs[3]: + st.markdown("### مميزات ومخاطر المشروع") + + # عرض المميزات والمخاطر في تبويبات + advantage_risk_tabs = st.tabs(["مميزات المشروع", "مخاطر المشروع"]) + + # تبويب مميزات المشروع + with advantage_risk_tabs[0]: + advantages = project.get('advantages', []) + + if advantages: + for i, adv in enumerate(advantages): + st.markdown(f"**{i+1}. {adv.get('title')}**") + st.markdown(f"*التأثير:* {adv.get('impact')}") + st.markdown(f"{adv.get('description')}") + st.markdown("---") + else: + st.info("لم يتم إضافة مميزات للمشروع.") + + # إضافة ميزة جديدة + st.markdown("### إضافة ميزة جديدة") + adv_title = st.text_input("عنوان الميزة", key=f"new_adv_title_{idx}") + adv_impact = st.selectbox( + "مستوى التأثير", + options=["منخفض", "متوسط", "عالي"], + key=f"new_adv_impact_{idx}" + ) + adv_desc = st.text_area("وصف الميزة", key=f"new_adv_desc_{idx}") + + if styled_button("إضافة ميزة", key=f"add_adv_{idx}", type="success", icon="✨"): + if not adv_title or not adv_desc: + st.error("يرجى تعبئة عنوان ووصف الميزة.") + else: + # إنشاء ميزة جديدة + new_adv = { + "title": adv_title, + "impact": adv_impact, + "description": adv_desc, + "date_added": datetime.now().strftime("%Y-%m-%d") + } + + # إضافة الميزة للمشروع + if 'advantages' not in project: + project['advantages'] = [] + + project['advantages'].append(new_adv) + st.success("تمت إضافة الميزة بنجاح!") + st.rerun() + + # تبويب مخاطر المشروع + with advantage_risk_tabs[1]: + risks = project.get('risks', []) + + if risks: + for i, risk in enumerate(risks): + risk_color = "🔴" if risk.get('severity') == "عالي" else "🟠" if risk.get('severity') == "متوسط" else "🟡" + st.markdown(f"{risk_color} **{i+1}. {risk.get('title')}**") + st.markdown(f"*الحدة:* {risk.get('severity')} | *الاحتمالية:* {risk.get('probability')}%") + st.markdown(f"*الوصف:* {risk.get('description')}") + st.markdown(f"*الإجراءات المقترحة:* {risk.get('mitigation_plan')}") + st.markdown("---") + else: + st.info("لم يتم إضافة مخاطر للمشروع.") + + # إضافة مخاطر جديدة + st.markdown("### إضافة مخاطر جديدة") + risk_title = st.text_input("عنوان المخاطرة", key=f"new_risk_title_{idx}") + risk_severity = st.selectbox( + "حدة المخاطرة", + options=["منخفض", "متوسط", "عالي"], + key=f"new_risk_severity_{idx}" + ) + risk_probability = st.slider( + "احتمالية الحدوث (%)", + min_value=0, + max_value=100, + value=50, + key=f"new_risk_prob_{idx}" + ) + risk_desc = st.text_area("وصف المخاطرة", key=f"new_risk_desc_{idx}") + risk_mitigation = st.text_area("خطة التخفيف المقترحة", key=f"new_risk_mitigation_{idx}") + + if styled_button("إضافة مخاطرة", key=f"add_risk_{idx}", type="warning", icon="⚠️"): + if not risk_title or not risk_desc: + st.error("يرجى تعبئة عنوان ووصف المخاطرة.") + else: + # إنشاء مخاطرة جديدة + new_risk = { + "title": risk_title, + "severity": risk_severity, + "probability": risk_probability, + "description": risk_desc, + "mitigation_plan": risk_mitigation, + "date_added": datetime.now().strftime("%Y-%m-%d") + } + + # إضافة المخاطرة للمشروع + if 'risks' not in project: + project['risks'] = [] + + project['risks'].append(new_risk) + st.success("تمت إضافة المخاطرة بنجاح!") + st.rerun() + + # تبويب استفسارات المالك + with project_tabs[4]: + st.markdown("### استفسارات المالك") + + # الحصول على استفسارات المشروع + inquiries = project.get('inquiries', []) + + if inquiries: + for i, inq in enumerate(inquiries): + with st.expander(f"استفسار #{i+1} - {inq.get('date')}"): + st.markdown(f"**السؤال:** {inq.get('question')}") + + if inq.get('answer'): + st.markdown(f"**الإجابة:** {inq.get('answer')}") + st.markdown(f"**تاريخ الإجابة:** {inq.get('answer_date', 'غير محدد')}") + else: + st.warning("لم تتم الإجابة على هذا الاستفسار بعد.") + + # نموذج للإجابة + answer_text = st.text_area("إجابة الاستفسار", key=f"answer_{idx}_{i}") + + if styled_button("إرسال الإجابة", key=f"send_answer_{idx}_{i}", type="primary", icon="✉️"): + if not answer_text: + st.error("يرجى كتابة الإجابة.") + else: + # تحديث الاستفسار بالإجابة + inq['answer'] = answer_text + inq['answer_date'] = datetime.now().strftime("%Y-%m-%d") + st.success("تم إرسال الإجابة بنجاح!") + st.rerun() + else: + st.info("لا توجد استفسارات من المالك لهذا المشروع.") + + # إضافة استفسار جديد + st.markdown("### إضافة استفسار جديد") + + inquiry_question = st.text_area("سؤال الاستفسار", key=f"new_inquiry_{idx}") + + if styled_button("إضافة استفسار", key=f"add_inquiry_{idx}", type="primary", icon="❓"): + if not inquiry_question: + st.error("يرجى كتابة السؤال.") + else: + # إنشاء استفسار جديد + new_inquiry = { + "question": inquiry_question, + "date": datetime.now().strftime("%Y-%m-%d"), + "answer": None, + "answer_date": None + } + + # إضافة الاستفسار للمشروع + if 'inquiries' not in project: + project['inquiries'] = [] + + project['inquiries'].append(new_inquiry) + st.success("تمت إضافة الاستفسار بنجاح!") + st.rerun() + + # تبويب معلومات الموقع + with project_tabs[5]: + st.markdown("### معلومات الموقع") + + # عرض معلومات الموقع الحالية + site_info = project.get('site_info', {}) + + if site_info: + st.markdown("#### معلومات أساسية") + st.markdown(f"**الطبيعة الجغرافية:** {site_info.get('geography', 'غير محدد')}") + st.markdown(f"**إمكانية الوصول:** {site_info.get('accessibility', 'غير محدد')}") + st.markdown(f"**المسافة عن أقرب مدينة:** {site_info.get('distance_to_city', 'غير محدد')}") + + st.markdown("#### بيانات التضاريس") + st.markdown(f"**نوع التربة:** {site_info.get('soil_type', 'غير محدد')}") + st.markdown(f"**متوسط درجة الحرارة:** {site_info.get('avg_temperature', 'غير محدد')}") + st.markdown(f"**موسم الأمطار:** {site_info.get('rainy_season', 'غير محدد')}") + + st.markdown("#### الخدمات المتوفرة") + st.markdown(f"**مياه:** {'متوفر' if site_info.get('has_water', False) else 'غير متوفر'}") + st.markdown(f"**كهرباء:** {'متوفر' if site_info.get('has_electricity', False) else 'غير متوفر'}") + st.markdown(f"**اتصالات:** {'متوفر' if site_info.get('has_communications', False) else 'غير متوفر'}") + + st.markdown("#### ملاحظات إضافية") + st.markdown(f"{site_info.get('notes', '')}") + + # خريطة الموقع (يمكن إضافتها لاحقاً إذا توفرت الإحداثيات) + if 'latitude' in site_info and 'longitude' in site_info: + st.markdown("#### موقع المشروع على الخريطة") + # يمكن استخدام تقنيات مثل folium أو ربط Google Maps API هنا + else: + st.info("لم يتم إضافة معلومات الموقع بعد.") + + # تحديث معلومات الموقع + st.markdown("### تحديث معلومات الموقع") + + # قسم الطبيعة الجغرافية + st.markdown("#### الطبيعة الجغرافية والوصول") + geo_col1, geo_col2 = st.columns(2) + + with geo_col1: + geography = st.selectbox( + "الطبيعة الجغرافية", + options=["صحراوية", "جبلية", "ساحلية", "زراعية", "حضرية", "أخرى"], + index=0 if not site_info else ["صحراوية", "جبلية", "ساحلية", "زراعية", "حضرية", "أخرى"].index(site_info.get('geography', "صحراوية")), + key=f"site_geography_{idx}" + ) + + accessibility = st.selectbox( + "إمكانية الوصول", + options=["سهلة", "متوسطة", "صعبة"], + index=0 if not site_info else ["سهلة", "متوسطة", "صعبة"].index(site_info.get('accessibility', "سهلة")), + key=f"site_accessibility_{idx}" + ) + + with geo_col2: + distance_to_city = st.text_input( + "المسافة عن أقرب مدينة (كم)", + value=site_info.get('distance_to_city', ""), + key=f"site_distance_{idx}" + ) + + nearest_city = st.text_input( + "أقرب مدينة رئيسية", + value=site_info.get('nearest_city', ""), + key=f"site_nearest_city_{idx}" + ) + + # قسم التضاريس والمناخ + st.markdown("#### التضاريس والمناخ") + terrain_col1, terrain_col2 = st.columns(2) + + with terrain_col1: + soil_type = st.selectbox( + "نوع التربة", + options=["رملية", "صخرية", "طينية", "مختلطة", "أخرى"], + index=0 if not site_info else ["رملية", "صخرية", "طينية", "مختلطة", "أخرى"].index(site_info.get('soil_type', "رملية")), + key=f"site_soil_{idx}" + ) + + avg_temperature = st.text_input( + "متوسط درجة الحرارة", + value=site_info.get('avg_temperature', ""), + key=f"site_temp_{idx}" + ) + + with terrain_col2: + rainy_season = st.text_input( + "موسم الأمطار", + value=site_info.get('rainy_season', ""), + key=f"site_rainy_{idx}" + ) + + wind_info = st.text_input( + "معلومات الرياح", + value=site_info.get('wind_info', ""), + key=f"site_wind_{idx}" + ) + + # قسم الخدمات المتوفرة + st.markdown("#### الخدمات المتوفرة") + services_col1, services_col2, services_col3 = st.columns(3) + + with services_col1: + has_water = st.checkbox( + "مياه", + value=site_info.get('has_water', False), + key=f"site_water_{idx}" + ) + + with services_col2: + has_electricity = st.checkbox( + "كهرباء", + value=site_info.get('has_electricity', False), + key=f"site_electricity_{idx}" + ) + + with services_col3: + has_communications = st.checkbox( + "اتصالات", + value=site_info.get('has_communications', False), + key=f"site_communications_{idx}" + ) + + # ملاحظات إضافية + site_notes = st.text_area( + "ملاحظات إضافية عن الموقع", + value=site_info.get('notes', ""), + key=f"site_notes_{idx}" + ) + + # حفظ معلومات الموقع + if styled_button("حفظ معلومات الموقع", key=f"save_site_info_{idx}", type="primary", icon="💾"): + # تجميع معلومات الموقع + updated_site_info = { + "geography": geography, + "accessibility": accessibility, + "distance_to_city": distance_to_city, + "nearest_city": nearest_city, + "soil_type": soil_type, + "avg_temperature": avg_temperature, + "rainy_season": rainy_season, + "wind_info": wind_info, + "has_water": has_water, + "has_electricity": has_electricity, + "has_communications": has_communications, + "notes": site_notes + } + + # تحديث معلومات الموقع في المشروع + project['site_info'] = updated_site_info + st.success("تم حفظ معلومات الموقع بنجاح!") + + def _render_new_project_form(self): + """عرض نموذج إضافة مشروع جديد""" + st.markdown(""" +
+

➕ إضافة مشروع جديد

+

قم بإدخال معلومات المشروع الجديد بالتفصيل.

+
+ """, unsafe_allow_html=True) + + # قسم المعلومات الأساسية + st.markdown("### معلومات المشروع الأساسية") + + # عمل تقسيم لحقول المعلومات الأساسية + col1, col2 = st.columns(2) + + with col1: + project_name = st.text_input("اسم المشروع", key="new_project_name") + project_number = st.text_input("رقم المشروع", key="new_project_number") + project_client = st.text_input("العميل", key="new_project_client") + + with col2: + project_location = st.text_input("موقع المشروع", key="new_project_location") + project_type = st.selectbox( + "نوع المشروع", + options=["بنية تحتية", "مباني", "طرق", "جسور", "شبكات مياه", "شبكات كهرباء", "أخرى"], + key="new_project_type" + ) + project_estimated_value = st.number_input( + "القيمة التقديرية (ريال)", + min_value=0.0, + step=100000.0, + format="%.2f", + key="new_project_value" + ) + + # قسم التواريخ والجدول الزمني + st.markdown("### الجدول الزمني") + + date_col1, date_col2 = st.columns(2) + + with date_col1: + project_start_date = st.date_input( + "تاريخ البدء", + value=datetime.now(), + key="new_project_start_date" + ) + + project_submission_date = st.date_input( + "تاريخ التقديم", + value=datetime.now() + timedelta(days=30), + key="new_project_submission_date" + ) + + with date_col2: + project_duration = st.number_input( + "مدة المشروع (يوم)", + min_value=1, + value=180, + step=1, + key="new_project_duration" + ) + + project_status = st.selectbox( + "حالة المشروع", + options=["جديد", "قيد الدراسة", "تم التقديم", "تم الترسية", "قيد التنفيذ", "مكتمل", "ملغي"], + key="new_project_status" + ) + + # زر إضافة المشروع + if styled_button("إضافة المشروع", key="add_new_project", type="success", icon="✅"): + # التحقق من وجود المعلومات الأساسية + if not project_name or not project_number or not project_client or not project_location: + st.error("يرجى تعبئة جميع الحقول الأساسية (اسم المشروع، رقم المشروع، العميل، الموقع).") + else: + # إنشاء كائن المشروع الجديد + new_project = { + "name": project_name, + "number": project_number, + "client": project_client, + "location": project_location, + "type": project_type, + "estimated_value": project_estimated_value, + "start_date": project_start_date.strftime("%Y-%m-%d"), + "submission_date": project_submission_date.strftime("%Y-%m-%d"), + "duration": project_duration, + "status": project_status, + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "site_info": {}, + "manager_insights": [], + "site_media": [], + "advantages": [], + "risks": [], + "inquiries": [] + } + + # إضافة المشروع للقائمة + st.session_state.projects.append(new_project) + + # إنشاء مجلد للمشروع + project_dir = os.path.join(self.projects_dir, project_number) + os.makedirs(project_dir, exist_ok=True) + + # إنشاء المجلدات الفرعية + os.makedirs(os.path.join(project_dir, "insights"), exist_ok=True) + os.makedirs(os.path.join(project_dir, "images"), exist_ok=True) + os.makedirs(os.path.join(project_dir, "videos"), exist_ok=True) + os.makedirs(os.path.join(project_dir, "others"), exist_ok=True) + + st.success(f"تمت إضافة المشروع '{project_name}' بنجاح!") + st.rerun() + + def _render_archived_projects(self): + """عرض قائمة المشاريع المؤرشفة""" + st.markdown(""" +
+

📂 أرشيف المشاريع

+

قائمة المشاريع المكتملة أو المؤرشفة.

+
+ """, unsafe_allow_html=True) + + # تصفية المشاريع المؤرشفة + archived_projects = [p for p in st.session_state.projects if p.get("status") == "archived" or p.get("status") == "مكتمل"] + + if not archived_projects: + st.info("لا توجد مشاريع مؤرشفة حالياً.") + return + + # عرض المشاريع المؤرشفة + for idx, project in enumerate(archived_projects): + with st.expander(f"{project.get('name')} - {project.get('client')}"): + self._render_project_details(project, idx + 1000) # استخدام مؤشر مختلف لتجنب التعارض + + def _render_projects_reports(self): + """عرض تقارير المشاريع والإحصائيات""" + st.markdown(""" +
+

📊 تقارير المشاريع

+

عرض إحصائيات وتقارير عن المشاريع الحالية والسابقة.

+
+ """, unsafe_allow_html=True) + + # إذا لم تكن هناك مشاريع + if not st.session_state.projects: + st.info("لا توجد مشاريع لعرض التقارير.") + return + + # إحصائيات عامة + st.markdown("### إحصائيات عامة") + + # حساب الإحصائيات + total_projects = len(st.session_state.projects) + active_projects = len([p for p in st.session_state.projects if p.get("status") not in ["archived", "مكتمل", "ملغي"]]) + completed_projects = len([p for p in st.session_state.projects if p.get("status") in ["مكتمل"]]) + canceled_projects = len([p for p in st.session_state.projects if p.get("status") in ["ملغي"]]) + + # عرض الإحصائيات في صفوف + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.metric("إجمالي المشاريع", total_projects) + + with col2: + st.metric("المشاريع النشطة", active_projects) + + with col3: + st.metric("المشاريع المكتملة", completed_projects) + + with col4: + st.metric("المشاريع الملغاة", canceled_projects) + + # تحليل المشاريع حسب النوع + st.markdown("### توزيع المشاريع حسب النوع") + + # حساب عدد المشاريع حسب النوع + project_types = {} + for project in st.session_state.projects: + project_type = project.get("type", "غير محدد") + if project_type in project_types: + project_types[project_type] += 1 + else: + project_types[project_type] = 1 + + # إنشاء بيانات للرسم البياني + types_df = pd.DataFrame({ + "نوع المشروع": list(project_types.keys()), + "عدد المشاريع": list(project_types.values()) + }) + + # رسم بياني دائري لتوزيع المشاريع حسب النوع + fig = px.pie( + types_df, + values="عدد المشاريع", + names="نوع المشروع", + title="توزيع المشاريع حسب النوع" + ) + st.plotly_chart(fig, use_container_width=True) + + # تحليل المشاريع حسب الحالة + st.markdown("### توزيع المشاريع حسب الحالة") + + # حساب عدد المشاريع حسب الحالة + project_statuses = {} + for project in st.session_state.projects: + status = project.get("status", "غير محدد") + if status in project_statuses: + project_statuses[status] += 1 + else: + project_statuses[status] = 1 + + # إنشاء بيانات للرسم البياني + statuses_df = pd.DataFrame({ + "حالة المشروع": list(project_statuses.keys()), + "عدد المشاريع": list(project_statuses.values()) + }) + + # رسم بياني شريطي لتوزيع المشاريع حسب الحالة + fig2 = px.bar( + statuses_df, + x="حالة المشروع", + y="عدد المشاريع", + title="توزيع المشاريع حسب الحالة", + color="حالة المشروع" + ) + st.plotly_chart(fig2, use_container_width=True) + + # عرض مخاطر المشاريع + st.markdown("### أهم المخاطر في المشاريع الحالية") + + # تجميع المخاطر من جميع المشاريع النشطة + all_risks = [] + for project in st.session_state.projects: + if project.get("status") not in ["archived", "مكتمل", "ملغي"]: + for risk in project.get("risks", []): + all_risks.append({ + "مشروع": project.get("name"), + "مخاطرة": risk.get("title"), + "الحدة": risk.get("severity"), + "الاحتمالية": risk.get("probability", 0) + }) + + if all_risks: + # تحويل المخاطر إلى DataFrame + risks_df = pd.DataFrame(all_risks) + + # ترتيب المخاطر حسب الحدة والاحتمالية + risks_df = risks_df.sort_values(by=["الحدة", "الاحتمالية"], ascending=[False, False]) + + # تلوين الجدول حسب الحدة + def color_severity(val): + if val == "عالي": + return 'background-color: #FFCCCC' + elif val == "متوسط": + return 'background-color: #FFFFCC' + else: + return 'background-color: #CCFFCC' + + # عرض جدول المخاطر + st.dataframe(risks_df.style.applymap(color_severity, subset=["الحدة"]), use_container_width=True) + else: + st.info("لا توجد مخاطر مسجلة في المشاريع النشطة.") + + def _save_project_file(self, project_number, file_type, uploaded_file): + """حفظ ملف مرفق للمشروع وإرجاع المسار""" + try: + # التأكد من وجود المجلد + project_dir = os.path.join(self.projects_dir, project_number) + type_dir = os.path.join(project_dir, file_type) + + os.makedirs(type_dir, exist_ok=True) + + # إنشاء اسم الملف + file_name = f"{int(time.time())}_{uploaded_file.name}" + file_path = os.path.join(type_dir, file_name) + + # حفظ الملف + with open(file_path, "wb") as f: + f.write(uploaded_file.getbuffer()) + + return file_path + except Exception as e: + st.error(f"خطأ في حفظ الملف: {str(e)}") + return None + + +# تشغيل النموذج مباشرة عند استدعاء الملف +def main(): + """تشغيل نموذج إدارة المشاريع بشكل مستقل""" + # تهيئة الواجهة + st.set_page_config( + page_title="إدارة المشاريع | WAHBi AI", + page_icon="🏗️", + layout="wide", + initial_sidebar_state="expanded", + menu_items={ + 'Get Help': 'mailto:support@wahbi-ai.com', + 'Report a bug': 'mailto:support@wahbi-ai.com', + 'About': 'نظام إدارة المشاريع - جزء من نظام WAHBi AI لتحليل المناقصات' + } + ) + + # تهيئة نموذج إدارة المشاريع + projects_management = ProjectsManagement() + + # عرض واجهة إدارة المشاريع + projects_management.render() + +# تشغيل النموذج إذا تم استدعاء الملف مباشرة +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/reports/reports_app.py b/modules/reports/reports_app.py new file mode 100644 index 0000000000000000000000000000000000000000..55485214f6d4779aeec220e21326e8f27eccee53 --- /dev/null +++ b/modules/reports/reports_app.py @@ -0,0 +1,88 @@ +import streamlit as st +import pandas as pd +import plotly.express as px +from datetime import datetime, timedelta +import time + +class ReportsApp: + """وحدة التقارير والتحليلات""" + + def __init__(self): + pass + + def render(self): + st.markdown("

وحدة التقارير والتحليلات

", unsafe_allow_html=True) + tabs = st.tabs(["لوحة المعلومات", "تقارير المشاريع", "تقارير التسعير", "تقارير المخاطر", "التقارير المخصصة"]) + + with tabs[0]: + self._render_dashboard_tab() + + # باقي التبويبات موجودة ولكن لم يتم طلب تصحيحها في هذا السياق + + def _render_dashboard_tab(self): + st.markdown("### لوحة معلومات النظام") + + col1, col2, col3, col4 = st.columns(4) + + with col1: + total_projects = self._get_total_projects() + st.metric("إجمالي المشاريع", total_projects) + + with col2: + active_projects = self._get_active_projects() + st.metric("المشاريع النشطة", active_projects, delta=f"{active_projects/total_projects*100:.1f}%" if total_projects > 0 else "0%") + + with col3: + won_projects = self._get_won_projects() + st.metric("المشاريع المرساة", won_projects, delta=f"{won_projects/total_projects*100:.1f}%" if total_projects > 0 else "0%") + + with col4: + avg_local_content = self._get_avg_local_content() + st.metric("متوسط المحتوى المحلي", f"{avg_local_content:.1f}%", delta=f"{avg_local_content-70:.1f}%" if avg_local_content > 0 else "0%") + + st.markdown("#### توزيع المشاريع حسب الحالة") + project_status_data = self._get_project_status_data() + fig = px.pie(project_status_data, values='count', names='status', title='توزيع المشاريع حسب الحالة', hole=0.4) + st.plotly_chart(fig, use_container_width=True) + + st.markdown("#### اتجاه المشاريع الشهري") + monthly_data = self._get_monthly_project_data() + fig = px.line(monthly_data, x='month', y=['new', 'submitted', 'won'], title='اتجاه المشاريع الشهري') + st.plotly_chart(fig, use_container_width=True) + + st.markdown("#### توزيع قيم المشاريع") + project_value_data = self._get_project_value_data() + fig = px.bar(project_value_data, x='range', y='count', title='توزيع قيم المشاريع') + st.plotly_chart(fig, use_container_width=True) + + def _get_total_projects(self): + return 10 + + def _get_active_projects(self): + return 7 + + def _get_won_projects(self): + return 4 + + def _get_avg_local_content(self): + return 72.5 + + def _get_project_status_data(self): + return pd.DataFrame({ + 'status': ['جديد', 'قيد التنفيذ', 'تمت الترسية', 'ملغي'], + 'count': [5, 3, 1, 1] + }) + + def _get_monthly_project_data(self): + return pd.DataFrame({ + 'month': ['يناير', 'فبراير', 'مارس'], + 'new': [2, 3, 4], + 'submitted': [1, 2, 3], + 'won': [0, 1, 2] + }) + + def _get_project_value_data(self): + return pd.DataFrame({ + 'range': ['0-500K', '500K-1M', '1M-2M', '2M+'], + 'count': [2, 3, 4, 1] + }) diff --git a/modules/resources/__init__.py b/modules/resources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..07fc5996aa64a07e030c6fb9e1dac3ebd6f9e368 --- /dev/null +++ b/modules/resources/__init__.py @@ -0,0 +1,5 @@ +""" +وحدة إدارة الموارد +""" + +__version__ = '1.0.0' \ No newline at end of file diff --git a/modules/resources/resources_app.py b/modules/resources/resources_app.py index 1e5e94fc0b38f7b3c123ee37f28ccad388b5c651..17dceb00b915b79891d9bfb28b37fccf87a20fc0 100644 --- a/modules/resources/resources_app.py +++ b/modules/resources/resources_app.py @@ -1,5 +1,5 @@ """ -وحدة الموارد - التطبيق الرئيسي +تطبيق وحدة الموارد """ import streamlit as st @@ -12,1695 +12,1436 @@ from datetime import datetime, timedelta import time import io import os -import json -import base64 -from pathlib import Path +import tempfile +import random + class ResourcesApp: - """وحدة الموارد""" + """وحدة إدارة الموارد""" def __init__(self): """تهيئة وحدة الموارد""" - # تهيئة حالة الجلسة - if 'resources_data' not in st.session_state: - # إنشاء بيانات افتراضية للموارد البشرية - np.random.seed(42) - - # إنشاء بيانات الموظفين - n_employees = 50 - employee_ids = [f"EMP-{i+1:03d}" for i in range(n_employees)] - employee_names = [ - "أحمد محمد", "محمد علي", "علي إبراهيم", "إبراهيم خالد", "خالد عبدالله", - "عبدالله سعد", "سعد فهد", "فهد ناصر", "ناصر سلطان", "سلطان عمر", - "عمر يوسف", "يوسف عبدالرحمن", "عبدالرحمن حسن", "حسن أحمد", "أحمد عبدالعزيز", - "عبدالعزيز سعود", "سعود فيصل", "فيصل تركي", "تركي بندر", "بندر سلمان", - "سلمان محمد", "محمد عبدالله", "عبدالله فهد", "فهد سعد", "سعد خالد", - "خالد علي", "علي عمر", "عمر سعيد", "سعيد ماجد", "ماجد فارس", - "فارس نايف", "نايف سامي", "سامي راشد", "راشد وليد", "وليد هاني", - "هاني زياد", "زياد طارق", "طارق عادل", "عادل فراس", "فراس باسم", - "باسم جمال", "جمال كريم", "كريم نبيل", "نبيل هشام", "هشام عماد", - "عماد أيمن", "أيمن رامي", "رامي سمير", "سمير وائل", "وائل مازن" - ] - employee_departments = np.random.choice(["الهندسة", "المشتريات", "المالية", "الموارد البشرية", "تقنية المعلومات", "التسويق", "المبيعات"], n_employees) - employee_positions = np.random.choice(["مدير", "مهندس", "محاسب", "مشرف", "أخصائي", "مساعد", "فني"], n_employees) - employee_skills = [ - np.random.choice(["إدارة المشاريع", "التصميم الهندسي", "تحليل البيانات", "إدارة العقود", "التخطيط الاستراتيجي"], - size=np.random.randint(1, 4), - replace=False).tolist() - for _ in range(n_employees) - ] - employee_experiences = np.random.randint(1, 20, n_employees) - employee_costs = np.random.randint(5000, 25000, n_employees) - employee_availabilities = np.random.choice([True, False], n_employees, p=[0.8, 0.2]) - employee_ratings = np.random.uniform(3.0, 5.0, n_employees) - - # إنشاء DataFrame للموظفين - employees_data = { - "رقم الموظف": employee_ids, - "اسم الموظف": employee_names, - "القسم": employee_departments, - "المنصب": employee_positions, - "المهارات": employee_skills, - "سنوات الخبرة": employee_experiences, - "التكلفة الشهرية": employee_costs, - "متاح": employee_availabilities, - "التقييم": employee_ratings - } - - # إنشاء بيانات المعدات - n_equipment = 30 - equipment_ids = [f"EQ-{i+1:03d}" for i in range(n_equipment)] - equipment_names = [ - "حفارة كبيرة", "حفارة صغيرة", "جرافة", "شاحنة نقل", "رافعة كبيرة", - "رافعة متوسطة", "رافعة صغيرة", "خلاطة خرسانة", "مضخة خرسانة", "مولد كهرباء كبير", - "مولد كهرباء متوسط", "مولد كهرباء صغير", "ضاغط هواء", "آلة لحام", "معدات قياس", - "معدات اختبار", "سقالات", "قوالب خرسانية", "معدات سباكة", "معدات كهربائية", - "معدات تكييف", "معدات تدفئة", "معدات إضاءة", "معدات سلامة", "معدات إطفاء", - "سيارة نقل صغيرة", "سيارة نقل متوسطة", "سيارة نقل كبيرة", "معدات حفر يدوية", "معدات بناء يدوية" - ] - equipment_types = np.random.choice(["حفر", "نقل", "رفع", "خرسانة", "كهرباء", "قياس", "بناء", "سلامة"], n_equipment) - equipment_costs = np.random.randint(500, 5000, n_equipment) - equipment_availabilities = np.random.choice([True, False], n_equipment, p=[0.7, 0.3]) - equipment_conditions = np.random.choice(["ممتاز", "جيد", "متوسط", "سيء"], n_equipment, p=[0.4, 0.3, 0.2, 0.1]) - equipment_locations = np.random.choice(["المستودع", "موقع المشروع 1", "موقع المشروع 2", "موقع المشروع 3", "في الصيانة"], n_equipment) - - # إنشاء DataFrame للمعدات - equipment_data = { - "رقم المعدة": equipment_ids, - "اسم المعدة": equipment_names, - "النوع": equipment_types, - "التكلفة اليومية": equipment_costs, - "متاحة": equipment_availabilities, - "الحالة": equipment_conditions, - "الموقع": equipment_locations - } - - # إنشاء بيانات المواد - n_materials = 40 - material_ids = [f"MAT-{i+1:03d}" for i in range(n_materials)] - material_names = [ - "خرسانة جاهزة", "حديد تسليح", "طابوق", "أسمنت", "رمل", "بحص", "خشب", "ألمنيوم", "زجاج", "دهان", - "سيراميك", "رخام", "جبس", "عازل مائي", "عازل حراري", "أنابيب PVC", "أسلاك كهربائية", "مفاتيح كهربائية", - "إنارة", "تكييف", "مصاعد", "أبواب خشبية", "أبواب حديدية", "نوافذ ألمنيوم", "نوافذ زجاجية", - "أرضيات خشبية", "أرضيات بلاط", "أرضيات رخام", "أرضيات سيراميك", "أرضيات بورسلين", - "دهان داخلي", "دهان خارجي", "مواد عزل", "مواد تشطيب", "مواد كهربائية", "مواد سباكة", - "مواد تكييف", "مواد إضاءة", "مواد سلامة", "مواد متنوعة" - ] - material_units = np.random.choice(["م3", "طن", "م2", "كجم", "لتر", "قطعة", "متر"], n_materials) - material_quantities = np.random.randint(10, 1000, n_materials) - material_costs = np.random.randint(50, 5000, n_materials) - material_suppliers = np.random.choice(["المورد 1", "المورد 2", "المورد 3", "المورد 4", "المورد 5"], n_materials) - material_lead_times = np.random.randint(1, 30, n_materials) - - # إنشاء DataFrame للمواد - materials_data = { - "رقم المادة": material_ids, - "اسم المادة": material_names, - "الوحدة": material_units, - "الكمية المتاحة": material_quantities, - "تكلفة الوحدة": material_costs, - "المورد": material_suppliers, - "مدة التوريد (يوم)": material_lead_times - } - - # إنشاء بيانات المشاريع - n_projects = 10 - project_ids = [f"PRJ-{i+1:03d}" for i in range(n_projects)] - project_names = [ - "مشروع إنشاء مبنى إداري", "مشروع إنشاء مبنى سكني", "مشروع إنشاء مدرسة", - "مشروع إنشاء مستشفى", "مشروع تطوير طرق", "مشروع إنشاء جسر", - "مشروع بنية تحتية", "مشروع إنشاء مركز تجاري", "مشروع إنشاء فندق", - "مشروع إنشاء مصنع" + # تهيئة البيانات في حالة الجلسة إذا لم تكن موجودة + if 'materials' not in st.session_state: + st.session_state.materials = [ + { + 'id': 1, + 'name': 'خرسانة جاهزة', + 'category': 'مواد إنشائية', + 'unit': 'م3', + 'price': 250, + 'supplier': 'شركة الخرسانة الوطنية', + 'local_content': 95, + 'last_updated': '2024-01-15' + }, + { + 'id': 2, + 'name': 'حديد تسليح', + 'category': 'مواد إنشائية', + 'unit': 'طن', + 'price': 4500, + 'supplier': 'مصنع الحديد السعودي', + 'local_content': 45, + 'last_updated': '2024-02-10' + }, + { + 'id': 3, + 'name': 'بلوك خرساني', + 'category': 'مواد إنشائية', + 'unit': 'م3', + 'price': 350, + 'supplier': 'مصنع البلوك الحديث', + 'local_content': 95, + 'last_updated': '2024-01-20' + }, + { + 'id': 4, + 'name': 'رمل', + 'category': 'مواد إنشائية', + 'unit': 'م3', + 'price': 60, + 'supplier': 'مؤسسة توريدات البناء', + 'local_content': 100, + 'last_updated': '2024-01-15' + }, + { + 'id': 5, + 'name': 'بلاط سيراميك', + 'category': 'مواد تشطيب', + 'unit': 'م2', + 'price': 120, + 'supplier': 'شركة السيراميك الوطنية', + 'local_content': 80, + 'last_updated': '2024-02-05' + } ] - project_locations = np.random.choice(["الرياض", "جدة", "الدمام", "مكة", "المدينة", "أبها", "تبوك"], n_projects) - project_start_dates = [ - (datetime.now() - timedelta(days=np.random.randint(0, 180))).strftime("%Y-%m-%d") - for _ in range(n_projects) + + if 'labor' not in st.session_state: + st.session_state.labor = [ + { + 'id': 1, + 'name': 'مهندس مدني', + 'category': 'هندسة', + 'unit': 'شهر', + 'price': 15000, + 'supplier': 'داخلي', + 'local_content': 90, + 'last_updated': '2024-01-10' + }, + { + 'id': 2, + 'name': 'مهندس معماري', + 'category': 'هندسة', + 'unit': 'شهر', + 'price': 14000, + 'supplier': 'داخلي', + 'local_content': 85, + 'last_updated': '2024-01-10' + }, + { + 'id': 3, + 'name': 'مساح', + 'category': 'هندسة', + 'unit': 'شهر', + 'price': 8000, + 'supplier': 'داخلي', + 'local_content': 100, + 'last_updated': '2024-01-10' + }, + { + 'id': 4, + 'name': 'فني كهرباء', + 'category': 'فني', + 'unit': 'شهر', + 'price': 7000, + 'supplier': 'داخلي', + 'local_content': 95, + 'last_updated': '2024-01-10' + }, + { + 'id': 5, + 'name': 'عامل بناء', + 'category': 'عمالة', + 'unit': 'يوم', + 'price': 200, + 'supplier': 'شركة توريد عمالة', + 'local_content': 60, + 'last_updated': '2024-01-20' + } ] - project_end_dates = [ - (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=np.random.randint(180, 720))).strftime("%Y-%m-%d") - for start_date in project_start_dates + + if 'equipment' not in st.session_state: + st.session_state.equipment = [ + { + 'id': 1, + 'name': 'حفارة كبيرة', + 'category': 'معدات ثقيلة', + 'unit': 'يوم', + 'price': 2500, + 'supplier': 'شركة المعدات الثقيلة', + 'local_content': 70, + 'last_updated': '2024-01-15' + }, + { + 'id': 2, + 'name': 'خلاطة خرسانة', + 'category': 'معدات إنشائية', + 'unit': 'يوم', + 'price': 1800, + 'supplier': 'مؤسسة معدات البناء', + 'local_content': 65, + 'last_updated': '2024-01-20' + }, + { + 'id': 3, + 'name': 'رافعة برجية', + 'category': 'معدات ثقيلة', + 'unit': 'شهر', + 'price': 45000, + 'supplier': 'شركة المعدات الثقيلة', + 'local_content': 50, + 'last_updated': '2024-02-05' + }, + { + 'id': 4, + 'name': 'مولد كهربائي', + 'category': 'معدات مساندة', + 'unit': 'شهر', + 'price': 12000, + 'supplier': 'شركة المعدات الكهربائية', + 'local_content': 75, + 'last_updated': '2024-01-25' + }, + { + 'id': 5, + 'name': 'سقالات معدنية', + 'category': 'معدات مساندة', + 'unit': 'م2/شهر', + 'price': 50, + 'supplier': 'مؤسسة معدات البناء', + 'local_content': 90, + 'last_updated': '2024-01-15' + } ] - project_budgets = np.random.randint(1000000, 50000000, n_projects) - project_statuses = np.random.choice(["قيد التنفيذ", "مكتمل", "متوقف", "مخطط"], n_projects) - - # إنشاء DataFrame للمشاريع - projects_data = { - "رقم المشروع": project_ids, - "اسم المشروع": project_names, - "الموقع": project_locations, - "تاريخ البدء": project_start_dates, - "تاريخ الانتهاء": project_end_dates, - "الميزانية": project_budgets, - "الحالة": project_statuses - } - - # إنشاء بيانات تخصيص الموارد للمشاريع - n_allocations = 100 - allocation_ids = [f"ALLOC-{i+1:03d}" for i in range(n_allocations)] - allocation_projects = np.random.choice(project_ids, n_allocations) - allocation_resource_types = np.random.choice(["موظف", "معدة", "مادة"], n_allocations) - allocation_resource_ids = [] - for res_type in allocation_resource_types: - if res_type == "موظف": - allocation_resource_ids.append(np.random.choice(employee_ids)) - elif res_type == "معدة": - allocation_resource_ids.append(np.random.choice(equipment_ids)) - else: - allocation_resource_ids.append(np.random.choice(material_ids)) - - allocation_start_dates = [ - (datetime.now() - timedelta(days=np.random.randint(0, 90))).strftime("%Y-%m-%d") - for _ in range(n_allocations) + + if 'subcontractors' not in st.session_state: + st.session_state.subcontractors = [ + { + 'id': 1, + 'name': 'مؤسسة الإنشاءات المتكاملة', + 'category': 'أعمال إنشائية', + 'specialization': 'تنفيذ الهيكل الخرساني', + 'rating': 4.8, + 'city': 'الرياض', + 'contact_person': 'محمد العتيبي', + 'phone': '0555555555', + 'email': 'info@constructionfirm.sa', + 'local_content': 85, + 'last_updated': '2024-01-15' + }, + { + 'id': 2, + 'name': 'شركة التكييف والتبريد', + 'category': 'أعمال كهروميكانيكية', + 'specialization': 'تركيب أنظمة التكييف والتبريد', + 'rating': 4.5, + 'city': 'جدة', + 'contact_person': 'أحمد الغامدي', + 'phone': '0566666666', + 'email': 'info@acfirm.sa', + 'local_content': 75, + 'last_updated': '2024-01-20' + }, + { + 'id': 3, + 'name': 'مؤسسة الكهرباء الحديثة', + 'category': 'أعمال كهروميكانيكية', + 'specialization': 'تنفيذ الأعمال الكهربائية', + 'rating': 4.6, + 'city': 'الرياض', + 'contact_person': 'فهد السويلم', + 'phone': '0577777777', + 'email': 'info@electricfirm.sa', + 'local_content': 90, + 'last_updated': '2024-02-05' + }, + { + 'id': 4, + 'name': 'شركة المقاولات المتخصصة', + 'category': 'أعمال تشطيبات', + 'specialization': 'تنفيذ أعمال التشطيبات الداخلية', + 'rating': 4.7, + 'city': 'الدمام', + 'contact_person': 'خالد الدوسري', + 'phone': '0588888888', + 'email': 'info@specializedcontractor.sa', + 'local_content': 80, + 'last_updated': '2024-01-25' + }, + { + 'id': 5, + 'name': 'مؤسسة الصيانة والتشغيل', + 'category': 'أعمال صيانة', + 'specialization': 'صيانة وتشغيل المباني', + 'rating': 4.4, + 'city': 'الرياض', + 'contact_person': 'عبدالله العنزي', + 'phone': '0599999999', + 'email': 'info@maintenancefirm.sa', + 'local_content': 95, + 'last_updated': '2024-02-10' + } ] - allocation_end_dates = [ - (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=np.random.randint(30, 180))).strftime("%Y-%m-%d") - for start_date in allocation_start_dates + + if 'price_history' not in st.session_state: + st.session_state.price_history = [ + # تاريخ أسعار الخرسانة الجاهزة + *[{'material_id': 1, 'date': (datetime.now() - timedelta(days=i*30)).strftime('%Y-%m-%d'), 'price': 250 - (i * 5) if i < 3 else 250 - 15 + (i - 2) * 10} for i in range(12)], + + # تاريخ أسعار حديد التسليح + *[{'material_id': 2, 'date': (datetime.now() - timedelta(days=i*30)).strftime('%Y-%m-%d'), 'price': 4500 - (i * 100) if i < 4 else 4500 - 400 + (i - 3) * 150} for i in range(12)], + + # تاريخ أسعار البلوك الخرساني + *[{'material_id': 3, 'date': (datetime.now() - timedelta(days=i*30)).strftime('%Y-%m-%d'), 'price': 350 - (i * 10) if i < 6 else 350 - 60 + (i - 5) * 15} for i in range(12)] ] - allocation_quantities = np.random.randint(1, 10, n_allocations) - allocation_costs = np.random.randint(5000, 50000, n_allocations) - - # إنشاء DataFrame لتخصيص الموارد - allocations_data = { - "رقم التخصيص": allocation_ids, - "رقم المشروع": allocation_projects, - "نوع المورد": allocation_resource_types, - "رقم المورد": allocation_resource_ids, - "تاريخ البدء": allocation_start_dates, - "تاريخ الانتهاء": allocation_end_dates, - "الكمية": allocation_quantities, - "التكلفة": allocation_costs - } - - # تخزين البيانات في حالة الجلسة - st.session_state.resources_data = { - "employees": pd.DataFrame(employees_data), - "equipment": pd.DataFrame(equipment_data), - "materials": pd.DataFrame(materials_data), - "projects": pd.DataFrame(projects_data), - "allocations": pd.DataFrame(allocations_data) - } def render(self): """عرض واجهة وحدة الموارد""" - st.markdown("

وحدة الموارد

", unsafe_allow_html=True) + st.markdown("

وحدة إدارة الموارد

", unsafe_allow_html=True) tabs = st.tabs([ - "لوحة المعلومات", - "الموارد البشرية", - "المعدات", + "لوحة المعلومات", "المواد", - "تخصيص الموارد", - "تخطيط الموارد" + "العمالة", + "المعدات", + "المقاولين من الباطن", + "تحليل الأسعار" ]) with tabs[0]: self._render_dashboard_tab() with tabs[1]: - self._render_human_resources_tab() + self._render_materials_tab() with tabs[2]: - self._render_equipment_tab() + self._render_labor_tab() with tabs[3]: - self._render_materials_tab() + self._render_equipment_tab() with tabs[4]: - self._render_resource_allocation_tab() + self._render_subcontractors_tab() with tabs[5]: - self._render_resource_planning_tab() + self._render_price_analysis_tab() def _render_dashboard_tab(self): """عرض تبويب لوحة المعلومات""" - st.markdown("### لوحة معلومات الموارد") - - # استخراج البيانات - employees_df = st.session_state.resources_data["employees"] - equipment_df = st.session_state.resources_data["equipment"] - materials_df = st.session_state.resources_data["materials"] - projects_df = st.session_state.resources_data["projects"] - allocations_df = st.session_state.resources_data["allocations"] + st.markdown("### لوحة معلومات إدارة الموارد") # عرض مؤشرات الأداء الرئيسية - st.markdown("#### مؤشرات الأداء الرئيسية") - col1, col2, col3, col4 = st.columns(4) with col1: - total_employees = len(employees_df) - available_employees = len(employees_df[employees_df["متاح"] == True]) - st.metric("الموظفون", f"{available_employees}/{total_employees}") + total_materials = len(st.session_state.materials) + st.metric("عدد المواد", total_materials) with col2: - total_equipment = len(equipment_df) - available_equipment = len(equipment_df[equipment_df["متاحة"] == True]) - st.metric("المعدات", f"{available_equipment}/{total_equipment}") + total_labor = len(st.session_state.labor) + st.metric("عدد موارد العمالة", total_labor) with col3: - total_materials = len(materials_df) - low_stock_materials = len(materials_df[materials_df["الكمية المتاحة"] < 50]) - st.metric("المواد", f"{total_materials}", f"-{low_stock_materials} منخفضة المخزون") + total_equipment = len(st.session_state.equipment) + st.metric("عدد المعدات", total_equipment) with col4: - total_projects = len(projects_df) - active_projects = len(projects_df[projects_df["الحالة"] == "قيد التنفيذ"]) - st.metric("المشاريع النشطة", f"{active_projects}/{total_projects}") - - # عرض توزيع الموارد البشرية حسب القسم - st.markdown("#### توزيع الموارد البشرية حسب القسم") - - dept_counts = employees_df["القسم"].value_counts().reset_index() - dept_counts.columns = ["القسم", "العدد"] - - fig = px.pie( - dept_counts, - values="العدد", - names="القسم", - title="توزيع الموظفين حسب القسم", - color="القسم" - ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع المعدات حسب النوع - st.markdown("#### توزيع المعدات حسب النوع") - - type_counts = equipment_df["النوع"].value_counts().reset_index() - type_counts.columns = ["النوع", "العدد"] - - fig = px.bar( - type_counts, - x="النوع", - y="العدد", - title="توزيع المعدات حسب النوع", - color="النوع", - text_auto=True - ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع المواد حسب المورد - st.markdown("#### توزيع المواد حسب المورد") + total_subcontractors = len(st.session_state.subcontractors) + st.metric("عدد المقاولين من الباطن", total_subcontractors) - supplier_counts = materials_df["المورد"].value_counts().reset_index() - supplier_counts.columns = ["المورد", "العدد"] + # رسم بياني لتوزيع المحتوى المحلي + st.markdown("### المحتوى المحلي للموارد") - fig = px.pie( - supplier_counts, - values="العدد", - names="المورد", - title="توزيع المواد حسب المورد", - color="المورد" - ) + # إعداد البيانات + local_content_data = [] - st.plotly_chart(fig, use_container_width=True) + # إضافة بيانات المواد + for material in st.session_state.materials: + local_content_data.append({ + 'النوع': 'المواد', + 'اسم المورد': material['name'], + 'نسبة المحتوى المحلي': material['local_content'] + }) - # عرض توزيع تكاليف الموارد - st.markdown("#### توزيع تكاليف الموارد") + # إضافة بيانات العمالة + for labor in st.session_state.labor: + local_content_data.append({ + 'النوع': 'العمالة', + 'اسم المورد': labor['name'], + 'نسبة المحتوى المحلي': labor['local_content'] + }) - # حساب إجمالي تكاليف الموظفين - total_employee_cost = employees_df["التكلفة الشهرية"].sum() + # إضافة بيانات المعدات + for equipment in st.session_state.equipment: + local_content_data.append({ + 'النوع': 'المعدات', + 'اسم المورد': equipment['name'], + 'نسبة المحتوى المحلي': equipment['local_content'] + }) - # حساب إجمالي تكاليف المعدات (افتراضياً لشهر واحد) - total_equipment_cost = equipment_df["التكلفة اليومية"].sum() * 30 + # إضافة بيانات المقاولين من الباطن + for subcontractor in st.session_state.subcontractors: + local_content_data.append({ + 'النوع': 'المقاولين من الباطن', + 'اسم المورد': subcontractor['name'], + 'نسبة المحتوى المحلي': subcontractor['local_content'] + }) - # حساب إجمالي تكاليف المواد - total_material_cost = (materials_df["الكمية المتاحة"] * materials_df["تكلفة الوحدة"]).sum() + # تحويل البيانات إلى DataFrame + local_content_df = pd.DataFrame(local_content_data) - # إنشاء DataFrame لتوزيع التكاليف - cost_distribution = pd.DataFrame({ - "نوع المورد": ["الموظفون", "المعدات", "المواد"], - "التكلفة": [total_employee_cost, total_equipment_cost, total_material_cost] - }) + # حساب متوسط المحتوى المحلي لكل نوع + avg_local_content = local_content_df.groupby('النوع')['نسبة المحتوى المحلي'].mean().reset_index() - fig = px.pie( - cost_distribution, - values="التكلفة", - names="نوع المورد", - title="توزيع تكاليف الموارد", - color="نوع المورد", - color_discrete_map={ - "الموظفون": "#3498db", - "المعدات": "#2ecc71", - "المواد": "#f39c12" - } + # رسم المخطط الشريطي + fig = px.bar( + avg_local_content, + x='النوع', + y='نسبة المحتوى المحلي', + title='متوسط نسبة المحتوى المحلي حسب نوع المورد', + color='النوع', + text_auto='.1f' ) - st.plotly_chart(fig, use_container_width=True) - - # عرض تخصيص الموارد للمشاريع - st.markdown("#### تخصيص الموارد للمشاريع") - - # حساب عدد الموارد المخصصة لكل مشروع - project_allocations = allocations_df["رقم المشروع"].value_counts().reset_index() - project_allocations.columns = ["رقم المشروع", "عدد الموارد المخصصة"] + fig.update_traces(texttemplate='%{text}%', textposition='outside') - # دمج بيانات المشاريع مع بيانات التخصيص - project_allocations = project_allocations.merge( - projects_df[["رقم المشروع", "اسم المشروع", "الحالة"]], - on="رقم المشروع", - how="left" + fig.add_shape( + type="line", + x0=-0.5, + x1=len(avg_local_content) - 0.5, + y0=70, # النسبة المستهدفة + y1=70, + line=dict(color="red", width=2, dash="dash"), + name="النسبة المستهدفة" ) - fig = px.bar( - project_allocations, - x="اسم المشروع", - y="عدد الموارد المخصصة", - title="تخصيص الموارد للمشاريع", - color="الحالة", - text_auto=True, - color_discrete_map={ - "قيد التنفيذ": "#3498db", - "مكتمل": "#2ecc71", - "متوقف": "#e74c3c", - "مخطط": "#f39c12" - } + fig.add_annotation( + x=1, + y=75, + text=f"النسبة المستهدفة (70%)", + showarrow=False, + font=dict(color="red") ) st.plotly_chart(fig, use_container_width=True) - # عرض توزيع أنواع الموارد المخصصة - st.markdown("#### توزيع أنواع الموارد المخصصة") - - resource_type_counts = allocations_df["نوع المورد"].value_counts().reset_index() - resource_type_counts.columns = ["نوع المورد", "العدد"] - - fig = px.pie( - resource_type_counts, - values="العدد", - names="نوع المورد", - title="توزيع أنواع الموارد المخصصة", - color="نوع المورد", - color_discrete_map={ - "موظف": "#3498db", - "معدة": "#2ecc71", - "مادة": "#f39c12" + # عرض تنبيهات الموارد + st.markdown("### تنبيهات الموارد") + + # محاكاة تنبيهات الموارد + alerts = [ + { + "type": "تغير في الأسعار", + "resource": "حديد تسليح", + "message": "ارتفاع في سعر الحديد بنسبة 5% في الأسبوع الماضي", + "date": "2024-03-15", + "severity": "متوسطة" + }, + { + "type": "نقص في المخزون", + "resource": "بلاط سيراميك", + "message": "انخفاض مخزون السيراميك إلى أقل من 20% من المستوى المطلوب", + "date": "2024-03-18", + "severity": "عالية" + }, + { + "type": "انتهاء صلاحية عقود", + "resource": "مؤسسة الإنشاءات المتكاملة", + "message": "سينتهي العقد مع المقاول خلال 30 يوماً", + "date": "2024-03-10", + "severity": "منخفضة" + }, + { + "type": "تغير في المحتوى المحلي", + "resource": "شركة التكييف والتبريد", + "message": "انخفاض نسبة المحتوى المحلي إلى أقل من النسبة المستهدفة", + "date": "2024-03-12", + "severity": "متوسطة" } - ) + ] - st.plotly_chart(fig, use_container_width=True) + # عرض التنبيهات + for alert in alerts: + if alert["severity"] == "عالية": + st.error(f"**{alert['type']}**: {alert['message']} - *{alert['resource']}* ({alert['date']})") + elif alert["severity"] == "متوسطة": + st.warning(f"**{alert['type']}**: {alert['message']} - *{alert['resource']}* ({alert['date']})") + else: + st.info(f"**{alert['type']}**: {alert['message']} - *{alert['resource']}* ({alert['date']})") + + # عرض نظرة عامة على الأسعار + st.markdown("### نظرة عامة على تطور الأسعار") + + # إعداد البيانات + price_history_data = [] + material_names = {material['id']: material['name'] for material in st.session_state.materials} + + for entry in st.session_state.price_history: + material_id = entry['material_id'] + if material_id in material_names: + price_history_data.append({ + 'المادة': material_names[material_id], + 'التاريخ': pd.to_datetime(entry['date']), + 'السعر': entry['price'] + }) + + # تحويل البيانات إلى DataFrame + price_history_df = pd.DataFrame(price_history_data) + + # التحقق من وجود بيانات قبل رسم المخطط + if len(price_history_data) == 0: + st.warning("لا توجد بيانات تاريخية للأسعار متاحة لعرضها") + else: + # رسم المخطط الخطي + fig = px.line( + price_history_df, + x='التاريخ', + y='السعر', + color='المادة', + title='تطور أسعار المواد الرئيسية خلال العام الماضي', + labels={'التاريخ': 'التاريخ', 'السعر': 'السعر (ريال)', 'المادة': 'المادة'} + ) + # عرض المخطط فقط إذا تم إنشاؤه + st.plotly_chart(fig, use_container_width=True) - def _render_human_resources_tab(self): - """عرض تبويب الموارد البشرية""" - - st.markdown("### إدارة الموارد البشرية") - - # استخراج البيانات - employees_df = st.session_state.resources_data["employees"] + def _render_materials_tab(self): + """عرض تبويب المواد""" - # عرض خيارات التصفية - st.markdown("#### خيارات التصفية") + st.markdown("### إدارة المواد") - col1, col2, col3 = st.columns(3) + # عرض أدوات البحث والتصفية + col1, col2 = st.columns(2) with col1: - selected_departments = st.multiselect( - "القسم", - options=employees_df["القسم"].unique(), - default=employees_df["القسم"].unique() - ) + search_query = st.text_input("بحث في المواد", placeholder="ابحث باسم المادة أو الفئة أو المورد...") with col2: - selected_positions = st.multiselect( - "المنصب", - options=employees_df["المنصب"].unique(), - default=employees_df["المنصب"].unique() + category_filter = st.multiselect( + "تصفية حسب الفئة", + options=list(set(material['category'] for material in st.session_state.materials)), + default=[], + key="material_category_filter_tab" ) - with col3: - availability_filter = st.selectbox( - "الإتاحة", - options=["الكل", "متاح فقط", "غير متاح فقط"] - ) - - # تطبيق التصفية - filtered_df = employees_df[ - employees_df["القسم"].isin(selected_departments) & - employees_df["المنصب"].isin(selected_positions) - ] - - if availability_filter == "متاح فقط": - filtered_df = filtered_df[filtered_df["متاح"] == True] - elif availability_filter == "غير متاح فقط": - filtered_df = filtered_df[filtered_df["متاح"] == False] - - # عرض البيانات المصفاة - st.markdown("#### قائمة الموظفين") - - st.dataframe( - filtered_df, - column_config={ - "رقم الموظف": st.column_config.TextColumn("رقم الموظف"), - "اسم الموظف": st.column_config.TextColumn("اسم الموظف"), - "القسم": st.column_config.TextColumn("القسم"), - "المنصب": st.column_config.TextColumn("المنصب"), - "المهارات": st.column_config.ListColumn("المهارات"), - "سنوات الخبرة": st.column_config.NumberColumn("سنوات الخبرة"), - "التكلفة الشهرية": st.column_config.NumberColumn("التكلفة الشهرية", format="%.2f ريال"), - "متاح": st.column_config.CheckboxColumn("متاح"), - "التقييم": st.column_config.ProgressColumn("التقييم", min_value=0, max_value=5) - }, - use_container_width=True, - hide_index=True - ) - - # عرض إحصائيات الموارد البشرية - st.markdown("#### إحصائيات الموارد البشرية") - - col1, col2, col3, col4 = st.columns(4) - - with col1: - total_employees = len(filtered_df) - st.metric("إجمالي الموظفين", f"{total_employees}") - - with col2: - available_employees = len(filtered_df[filtered_df["متاح"] == True]) - availability_rate = available_employees / total_employees * 100 if total_employees > 0 else 0 - st.metric("معدل الإتاحة", f"{availability_rate:.1f}%") - - with col3: - avg_experience = filtered_df["سنوات الخبرة"].mean() - st.metric("متوسط سنوات الخبرة", f"{avg_experience:.1f} سنة") + # تطبيق البحث والتصفية + filtered_materials = st.session_state.materials - with col4: - avg_cost = filtered_df["التكلفة الشهرية"].mean() - st.metric("متوسط التكلفة الشهرية", f"{avg_cost:.0f} ريال") + if search_query: + filtered_materials = [ + material for material in filtered_materials + if (search_query.lower() in material['name'].lower() or + search_query.lower() in material['category'].lower() or + search_query.lower() in material['supplier'].lower()) + ] - # عرض توزيع الموظفين حسب القسم - st.markdown("#### توزيع الموظفين حسب القسم") + if category_filter: + filtered_materials = [material for material in filtered_materials if material['category'] in category_filter] - dept_counts = filtered_df["القسم"].value_counts().reset_index() - dept_counts.columns = ["القسم", "العدد"] + # زر إضافة مادة جديدة + if st.button("إضافة مادة جديدة"): + st.session_state.show_material_form = True - fig = px.bar( - dept_counts, - x="القسم", - y="العدد", - title="توزيع الموظفين حسب القسم", - color="القسم", - text_auto=True - ) + # نموذج إضافة مادة جديدة + if st.session_state.get('show_material_form', False): + with st.form("add_material_form"): + st.markdown("#### إضافة مادة جديدة") + + col1, col2 = st.columns(2) + + with col1: + new_material_name = st.text_input("اسم المادة", key="new_material_name") + new_material_category = st.text_input("الفئة", key="new_material_category") + new_material_unit = st.text_input("وحدة القياس", key="new_material_unit") + + with col2: + new_material_price = st.number_input("السعر (ريال)", min_value=0.0, key="new_material_price") + new_material_supplier = st.text_input("المورد", key="new_material_supplier") + new_material_local_content = st.slider("نسبة المحتوى المحلي (%)", 0, 100, 50, key="new_material_local_content") + + submitted = st.form_submit_button("إضافة المادة") + cancel = st.form_submit_button("إلغاء") + + if submitted and new_material_name and new_material_category and new_material_unit: + # إضافة المادة الجديدة + new_material = { + 'id': max([material['id'] for material in st.session_state.materials], default=0) + 1, + 'name': new_material_name, + 'category': new_material_category, + 'unit': new_material_unit, + 'price': new_material_price, + 'supplier': new_material_supplier, + 'local_content': new_material_local_content, + 'last_updated': datetime.now().strftime('%Y-%m-%d') + } + + st.session_state.materials.append(new_material) + st.success(f"تمت إضافة المادة '{new_material_name}' بنجاح!") + st.session_state.show_material_form = False + st.rerun() + + if cancel: + st.session_state.show_material_form = False + st.rerun() - st.plotly_chart(fig, use_container_width=True) + # عرض قائمة المواد + if filtered_materials: + # تحويل البيانات إلى DataFrame + materials_df = pd.DataFrame(filtered_materials) + + # تنسيق البيانات للعرض + display_df = materials_df.copy() + display_df['price'] = display_df['price'].apply(lambda x: f"{x:,.2f} ريال") + display_df['local_content'] = display_df['local_content'].apply(lambda x: f"{x}%") + + # تغيير أسماء الأعمدة للعرض + display_df.columns = [ + 'معرف', 'اسم المادة', 'الفئة', 'وحدة القياس', 'السعر', 'المورد', 'نسبة المحتوى المحلي', 'آخر تحديث' + ] + + # عرض الجدول + st.dataframe(display_df, use_container_width=True, hide_index=True) + + # عرض ملخص إحصائي + st.markdown("#### ملخص إحصائي للمواد") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي عدد المواد", len(filtered_materials)) + + with col2: + avg_price = sum(material['price'] for material in filtered_materials) / len(filtered_materials) + st.metric("متوسط سعر المواد", f"{avg_price:,.2f} ريال") + + with col3: + avg_local_content = sum(material['local_content'] for material in filtered_materials) / len(filtered_materials) + st.metric("متوسط نسبة المحتوى المحلي", f"{avg_local_content:.1f}%") + + # عرض مخطط توزيع المواد حسب الفئة + category_counts = materials_df.groupby('category').size().reset_index(name='count') + + fig = px.pie( + category_counts, + names='category', + values='count', + title='توزيع المواد حسب الفئة' + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.warning("لا توجد مواد مطابقة لمعايير البحث.") + + def _render_labor_tab(self): + """عرض تبويب العمالة""" - # عرض توزيع الموظفين حسب المنصب - st.markdown("#### توزيع الموظفين حسب المنصب") + st.markdown("### إدارة العمالة") - position_counts = filtered_df["المنصب"].value_counts().reset_index() - position_counts.columns = ["المنصب", "العدد"] + # عرض أدوات البحث والتصفية + col1, col2 = st.columns(2) - fig = px.pie( - position_counts, - values="العدد", - names="المنصب", - title="توزيع الموظفين حسب المنصب", - color="المنصب" - ) + with col1: + search_query = st.text_input("بحث في العمالة", placeholder="ابحث باسم العامل أو الفئة أو المورد...") - st.plotly_chart(fig, use_container_width=True) + with col2: + category_filter = st.multiselect( + "تصفية حسب الفئة", + options=list(set(labor['category'] for labor in st.session_state.labor)), + default=[], + key="labor_category_filter_tab" + ) - # عرض توزيع الموظفين حسب سنوات الخبرة - st.markdown("#### توزيع الموظفين حسب سنوات الخبرة") + # تطبيق البحث والتصفية + filtered_labor = st.session_state.labor - # إنشاء فئات لسنوات الخبرة - experience_bins = [0, 3, 5, 10, 15, 20] - experience_labels = ["أقل من 3 سنوات", "3-5 سنوات", "6-10 سنوات", "11-15 سنة", "أكثر من 15 سنة"] + if search_query: + filtered_labor = [ + labor for labor in filtered_labor + if (search_query.lower() in labor['name'].lower() or + search_query.lower() in labor['category'].lower() or + search_query.lower() in labor['supplier'].lower()) + ] - filtered_df["فئة الخبرة"] = pd.cut(filtered_df["سنوات الخبرة"], bins=experience_bins, labels=experience_labels, right=False) + if category_filter: + filtered_labor = [labor for labor in filtered_labor if labor['category'] in category_filter] - experience_counts = filtered_df["فئة الخبرة"].value_counts().reset_index() - experience_counts.columns = ["فئة الخبرة", "العدد"] + # زر إضافة عامل جديد + if st.button("إضافة عامل جديد"): + st.session_state.show_labor_form = True - fig = px.bar( - experience_counts, - x="فئة الخبرة", - y="العدد", - title="توزيع الموظفين حسب سنوات الخبرة", - color="فئة الخبرة", - text_auto=True - ) + # نموذج إضافة عامل جديد + if st.session_state.get('show_labor_form', False): + with st.form("add_labor_form"): + st.markdown("#### إضافة عامل جديد") + + col1, col2 = st.columns(2) + + with col1: + new_labor_name = st.text_input("اسم العامل", key="new_labor_name") + new_labor_category = st.text_input("الفئة", key="new_labor_category") + new_labor_unit = st.text_input("وحدة القياس", key="new_labor_unit") + + with col2: + new_labor_price = st.number_input("السعر (ريال)", min_value=0.0, key="new_labor_price") + new_labor_supplier = st.text_input("المورد", key="new_labor_supplier") + new_labor_local_content = st.slider("نسبة المحتوى المحلي (%)", 0, 100, 50, key="new_labor_local_content") + + submitted = st.form_submit_button("إضافة العامل") + cancel = st.form_submit_button("إلغاء") + + if submitted and new_labor_name and new_labor_category and new_labor_unit: + # إضافة العامل الجديد + new_labor = { + 'id': max([labor['id'] for labor in st.session_state.labor], default=0) + 1, + 'name': new_labor_name, + 'category': new_labor_category, + 'unit': new_labor_unit, + 'price': new_labor_price, + 'supplier': new_labor_supplier, + 'local_content': new_labor_local_content, + 'last_updated': datetime.now().strftime('%Y-%m-%d') + } + + st.session_state.labor.append(new_labor) + st.success(f"تمت إضافة العامل '{new_labor_name}' بنجاح!") + st.session_state.show_labor_form = False + st.rerun() + + if cancel: + st.session_state.show_labor_form = False + st.rerun() - st.plotly_chart(fig, use_container_width=True) + # عرض قائمة العمالة + if filtered_labor: + # تحويل البيانات إلى DataFrame + labor_df = pd.DataFrame(filtered_labor) + + # تنسيق البيانات للعرض + display_df = labor_df.copy() + display_df['price'] = display_df['price'].apply(lambda x: f"{x:,.2f} ريال") + display_df['local_content'] = display_df['local_content'].apply(lambda x: f"{x}%") + + # تغيير أسماء الأعمدة للعرض + display_df.columns = [ + 'معرف', 'اسم العامل', 'الفئة', 'وحدة القياس', 'السعر', 'المورد', 'نسبة المحتوى المحلي', 'آخر تحديث' + ] + + # عرض الجدول + st.dataframe(display_df, use_container_width=True, hide_index=True) + + # عرض ملخص إحصائي + st.markdown("#### ملخص إحصائي للعمالة") + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric("إجمالي عدد العمالة", len(filtered_labor)) + + with col2: + avg_price = sum(labor['price'] for labor in filtered_labor) / len(filtered_labor) + st.metric("متوسط سعر العمالة", f"{avg_price:,.2f} ريال") + + with col3: + avg_local_content = sum(labor['local_content'] for labor in filtered_labor) / len(filtered_labor) + st.metric("متوسط نسبة المحتوى المحلي", f"{avg_local_content:.1f}%") + + # عرض مخطط توزيع العمالة حسب الفئة + category_counts = labor_df.groupby('category').size().reset_index(name='count') + + fig = px.pie( + category_counts, + names='category', + values='count', + title='توزيع العمالة حسب الفئة' + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.warning("لا توجد عمالة مطابقة لمعايير البحث.") + + def _render_equipment_tab(self): + """عرض تبويب المعدات""" - # عرض توزيع المهارات - st.markdown("#### توزيع المهارات") + st.markdown("### إدارة المعدات") - # استخراج جميع المهارات - all_skills = [] - for skills_list in filtered_df["المهارات"]: - all_skills.extend(skills_list) + # عرض أدوات البحث والتصفية + col1, col2 = st.columns(2) - skill_counts = pd.Series(all_skills).value_counts().reset_index() - skill_counts.columns = ["المهارة", "العدد"] + with col1: + search_query = st.text_input("بحث في المعدات", placeholder="ابحث باسم المعدة أو الفئة أو المورد...") - fig = px.bar( - skill_counts, - x="المهارة", - y="العدد", - title="توزيع المهارات", - color="المهارة", - text_auto=True - ) + with col2: + category_filter = st.multiselect( + "تصفية حسب الفئة", + options=list(set(equipment['category'] for equipment in st.session_state.equipment)), + default=[], + key="equipment_category_filter_tab" + ) - st.plotly_chart(fig, use_container_width=True) + # تطبيق البحث والتصفية + filtered_equipment = st.session_state.equipment - # عرض العلاقة بين سنوات الخبرة والتكلفة - st.markdown("#### العلاقة بين سنوات الخبرة والتكلفة") + if search_query: + filtered_equipment = [ + equipment for equipment in filtered_equipment + if (search_query.lower() in equipment['name'].lower() or + search_query.lower() in equipment['category'].lower() or + search_query.lower() in equipment['supplier'].lower()) + ] - fig = px.scatter( - filtered_df, - x="سنوات الخبرة", - y="التكلفة الشهرية", - color="القسم", - size="التقييم", - hover_name="اسم الموظف", - hover_data=["المنصب", "متاح"], - title="العلاقة بين سنوات الخبرة والتكلفة الشهرية" - ) + if category_filter: + filtered_equipment = [equipment for equipment in filtered_equipment if equipment['category'] in category_filter] - st.plotly_chart(fig, use_container_width=True) + # زر إضافة معدة جديدة + if st.button("إضافة معدة جديدة"): + st.session_state.show_equipment_form = True - # إضافة موظف جديد - st.markdown("#### إضافة موظف جديد") + # نموذج إضافة معدة جديدة + if st.session_state.get('show_equipment_form', False): + with st.form("add_equipment_form"): + st.markdown("#### إضافة معدة جديدة") + + col1, col2 = st.columns(2) + + with col1: + new_equipment_name = st.text_input("اسم المعدة", key="new_equipment_name") + new_equipment_category = st.text_input("الفئة", key="new_equipment_category") + new_equipment_unit = st.text_input("وحدة القياس", key="new_equipment_unit") + + with col2: + new_equipment_price = st.number_input("السعر (ريال)", min_value=0.0, key="new_equipment_price") + new_equipment_supplier = st.text_input("المورد", key="new_equipment_supplier") + new_equipment_local_content = st.slider("نسبة المحتوى المحلي (%)", 0, 100, 50, key="new_equipment_local_content") + + submitted = st.form_submit_button("إضافة المعدة") + cancel = st.form_submit_button("إلغاء") + + if submitted and new_equipment_name and new_equipment_category and new_equipment_unit: + # إضافة المعدة الجديدة + new_equipment = { + 'id': max([equipment['id'] for equipment in st.session_state.equipment], default=0) + 1, + 'name': new_equipment_name, + 'category': new_equipment_category, + 'unit': new_equipment_unit, + 'price': new_equipment_price, + 'supplier': new_equipment_supplier, + 'local_content': new_equipment_local_content, + 'last_updated': datetime.now().strftime('%Y-%m-%d') + } + + st.session_state.equipment.append(new_equipment) + st.success(f"تمت إضافة المعدة '{new_equipment_name}' بنجاح!") + st.session_state.show_equipment_form = False + st.rerun() + + if cancel: + st.session_state.show_equipment_form = False + st.rerun() - with st.form("add_employee_form"): - col1, col2 = st.columns(2) + # عرض قائمة المعدات + if filtered_equipment: + # تحويل البيانات إلى DataFrame + equipment_df = pd.DataFrame(filtered_equipment) + + # تنسيق البيانات للعرض + display_df = equipment_df.copy() + display_df['price'] = display_df['price'].apply(lambda x: f"{x:,.2f} ريال") + display_df['local_content'] = display_df['local_content'].apply(lambda x: f"{x}%") + + # تغيير أسماء الأعمدة للعرض + display_df.columns = [ + 'معرف', 'اسم المعدة', 'الفئة', 'وحدة القياس', 'السعر', 'المورد', 'نسبة المحتوى المحلي', 'آخر تحديث' + ] + + # عرض الجدول + st.dataframe(display_df, use_container_width=True, hide_index=True) + + # عرض ملخص إحصائي + st.markdown("#### ملخص إحصائي للمعدات") + + col1, col2, col3 = st.columns(3) with col1: - new_employee_name = st.text_input("اسم الموظف") - new_employee_department = st.selectbox("القسم", options=employees_df["القسم"].unique()) - new_employee_position = st.selectbox("المنصب", options=employees_df["المنصب"].unique()) - new_employee_experience = st.number_input("سنوات الخبرة", min_value=0, max_value=40, value=5) + st.metric("إجمالي عدد المعدات", len(filtered_equipment)) with col2: - new_employee_skills = st.multiselect( - "المهارات", - options=["إدارة المشاريع", "التصميم الهندسي", "تحليل البيانات", "إدارة العقود", "التخطيط الاستراتيجي", "إدارة الموارد", "إدارة المخاطر", "إدارة الجودة", "إدارة التكاليف", "إدارة الوقت"] - ) - new_employee_cost = st.number_input("التكلفة الشهرية", min_value=3000, max_value=50000, value=10000) - new_employee_available = st.checkbox("متاح", value=True) - new_employee_rating = st.slider("التقييم", min_value=1.0, max_value=5.0, value=4.0, step=0.1) + avg_price = sum(equipment['price'] for equipment in filtered_equipment) / len(filtered_equipment) + st.metric("متوسط سعر المعدات", f"{avg_price:,.2f} ريال") + + with col3: + avg_local_content = sum(equipment['local_content'] for equipment in filtered_equipment) / len(filtered_equipment) + st.metric("متوسط نسبة المحتوى المحلي", f"{avg_local_content:.1f}%") - submit_button = st.form_submit_button("إضافة موظف") + # عرض مخطط توزيع المعدات حسب الفئة + category_counts = equipment_df.groupby('category').size().reset_index(name='count') - if submit_button: - if new_employee_name: - # إنشاء رقم موظف جديد - new_employee_id = f"EMP-{len(employees_df) + 1:03d}" - - # إضافة الموظف الجديد - new_employee = pd.DataFrame({ - "رقم الموظف": [new_employee_id], - "اسم الموظف": [new_employee_name], - "القسم": [new_employee_department], - "المنصب": [new_employee_position], - "المهارات": [new_employee_skills], - "سنوات الخبرة": [new_employee_experience], - "التكلفة الشهرية": [new_employee_cost], - "متاح": [new_employee_available], - "التقييم": [new_employee_rating] - }) - - # تحديث DataFrame الموظفين - st.session_state.resources_data["employees"] = pd.concat([employees_df, new_employee], ignore_index=True) - - st.success(f"تم إضافة الموظف {new_employee_name} بنجاح!") - st.rerun() - else: - st.error("يرجى إدخال اسم الموظف") + fig = px.bar( + category_counts, + x='category', + y='count', + title='توزيع المعدات حسب الفئة', + color='category', + labels={'category': 'الفئة', 'count': 'العدد'} + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.warning("لا توجد معدات مطابقة لمعايير البحث.") - def _render_equipment_tab(self): - """عرض تبويب المعدات""" - - st.markdown("### إدارة المعدات") - - # استخراج البيانات - equipment_df = st.session_state.resources_data["equipment"] + def _render_subcontractors_tab(self): + """عرض تبويب المقاولين من الباطن""" - # عرض خيارات التصفية - st.markdown("#### خيارات التصفية") + st.markdown("### إدارة المقاولين من الباطن") + # عرض أدوات البحث والتصفية col1, col2, col3 = st.columns(3) with col1: - selected_types = st.multiselect( - "النوع", - options=equipment_df["النوع"].unique(), - default=equipment_df["النوع"].unique() - ) + search_query = st.text_input("بحث في المقاولين", placeholder="ابحث باسم المقاول أو التخصص...") with col2: - selected_conditions = st.multiselect( - "الحالة", - options=equipment_df["الحالة"].unique(), - default=equipment_df["الحالة"].unique() + category_filter = st.multiselect( + "تصفية حسب الفئة", + options=list(set(subcontractor['category'] for subcontractor in st.session_state.subcontractors)), + default=[], + key="subcontractor_category_filter_tab" ) with col3: - availability_filter = st.selectbox( - "الإتاحة", - options=["الكل", "متاحة فقط", "غير متاحة فقط"], - key="equipment_availability" + city_filter = st.multiselect( + "تصفية حسب المدينة", + options=list(set(subcontractor['city'] for subcontractor in st.session_state.subcontractors)), + default=[], + key="subcontractor_city_filter_tab" ) - # تطبيق التصفية - filtered_df = equipment_df[ - equipment_df["النوع"].isin(selected_types) & - equipment_df["الحالة"].isin(selected_conditions) - ] - - if availability_filter == "متاحة فقط": - filtered_df = filtered_df[filtered_df["متاحة"] == True] - elif availability_filter == "غير متاحة فقط": - filtered_df = filtered_df[filtered_df["متاحة"] == False] - - # عرض البيانات المصفاة - st.markdown("#### قائمة المعدات") - - st.dataframe( - filtered_df, - column_config={ - "رقم المعدة": st.column_config.TextColumn("رقم المعدة"), - "اسم المعدة": st.column_config.TextColumn("اسم المعدة"), - "النوع": st.column_config.TextColumn("النوع"), - "التكلفة اليومية": st.column_config.NumberColumn("التكلفة اليومية", format="%.2f ريال"), - "متاحة": st.column_config.CheckboxColumn("متاحة"), - "الحالة": st.column_config.TextColumn("الحالة"), - "الموقع": st.column_config.TextColumn("الموقع") - }, - use_container_width=True, - hide_index=True - ) - - # عرض إحصائيات المعدات - st.markdown("#### إحصائيات المعدات") - - col1, col2, col3, col4 = st.columns(4) - - with col1: - total_equipment = len(filtered_df) - st.metric("إجمالي المعدات", f"{total_equipment}") - - with col2: - available_equipment = len(filtered_df[filtered_df["متاحة"] == True]) - availability_rate = available_equipment / total_equipment * 100 if total_equipment > 0 else 0 - st.metric("معدل الإتاحة", f"{availability_rate:.1f}%") + # تطبيق البحث والتصفية + filtered_subcontractors = st.session_state.subcontractors - with col3: - good_condition = len(filtered_df[filtered_df["الحالة"].isin(["ممتاز", "جيد"])]) - good_condition_rate = good_condition / total_equipment * 100 if total_equipment > 0 else 0 - st.metric("معدل الحالة الجيدة", f"{good_condition_rate:.1f}%") + if search_query: + filtered_subcontractors = [ + subcontractor for subcontractor in filtered_subcontractors + if (search_query.lower() in subcontractor['name'].lower() or + search_query.lower() in subcontractor['specialization'].lower()) + ] - with col4: - avg_cost = filtered_df["التكلفة اليومية"].mean() - st.metric("متوسط التكلفة اليومية", f"{avg_cost:.0f} ريال") + if category_filter: + filtered_subcontractors = [subcontractor for subcontractor in filtered_subcontractors if subcontractor['category'] in category_filter] - # عرض توزيع المعدات حسب النوع - st.markdown("#### توزيع المعدات حسب النوع") + if city_filter: + filtered_subcontractors = [subcontractor for subcontractor in filtered_subcontractors if subcontractor['city'] in city_filter] - type_counts = filtered_df["النوع"].value_counts().reset_index() - type_counts.columns = ["النوع", "العدد"] + # زر إضافة مقاول جديد + if st.button("إضافة مقاول جديد"): + st.session_state.show_subcontractor_form = True - fig = px.bar( - type_counts, - x="النوع", - y="العدد", - title="توزيع المعدات حسب النوع", - color="النوع", - text_auto=True - ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع المعدات حسب الحالة - st.markdown("#### توزيع المعدات حسب الحالة") - - condition_counts = filtered_df["الحالة"].value_counts().reset_index() - condition_counts.columns = ["الحالة", "العدد"] - - fig = px.pie( - condition_counts, - values="العدد", - names="الحالة", - title="توزيع المعدات حسب الحالة", - color="الحالة", - color_discrete_map={ - "ممتاز": "#2ecc71", - "جيد": "#3498db", - "متوسط": "#f39c12", - "سيء": "#e74c3c" - } - ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع المعدات حسب الموقع - st.markdown("#### توزيع المعدات حسب الموقع") - - location_counts = filtered_df["الموقع"].value_counts().reset_index() - location_counts.columns = ["الموقع", "العدد"] - - fig = px.bar( - location_counts, - x="الموقع", - y="العدد", - title="توزيع المعدات حسب الموقع", - color="الموقع", - text_auto=True - ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض العلاقة بين نوع المعدة والتكلفة - st.markdown("#### العلاقة بين نوع المعدة والتكلفة") - - type_cost = filtered_df.groupby("النوع")["التكلفة اليومية"].mean().reset_index() - type_cost.columns = ["النوع", "متوسط التكلفة اليومية"] - - fig = px.bar( - type_cost, - x="النوع", - y="متوسط التكلفة اليومية", - title="متوسط التكلفة اليومية حسب نوع المعدة", - color="النوع", - text_auto=".0f" - ) - - st.plotly_chart(fig, use_container_width=True) - - # إضافة معدة جديدة - st.markdown("#### إضافة معدة جديدة") + # نموذج إضافة مقاول جديد + if st.session_state.get('show_subcontractor_form', False): + with st.form("add_subcontractor_form"): + st.markdown("#### إضافة مقاول جديد") + + col1, col2 = st.columns(2) + + with col1: + new_subcontractor_name = st.text_input("اسم المقاول", key="new_subcontractor_name") + new_subcontractor_category = st.text_input("الفئة", key="new_subcontractor_category") + new_subcontractor_specialization = st.text_input("التخصص", key="new_subcontractor_specialization") + new_subcontractor_city = st.text_input("المدينة", key="new_subcontractor_city") + + with col2: + new_subcontractor_contact = st.text_input("جهة الاتصال", key="new_subcontractor_contact") + new_subcontractor_phone = st.text_input("رقم الهاتف", key="new_subcontractor_phone") + new_subcontractor_email = st.text_input("البريد الإلكتروني", key="new_subcontractor_email") + new_subcontractor_rating = st.slider("التقييم", 1.0, 5.0, 3.0, 0.1, key="new_subcontractor_rating") + new_subcontractor_local_content = st.slider("نسبة المحتوى المحلي (%)", 0, 100, 50, key="new_subcontractor_local_content") + + submitted = st.form_submit_button("إضافة المقاول") + cancel = st.form_submit_button("إلغاء") + + if submitted and new_subcontractor_name and new_subcontractor_category and new_subcontractor_specialization: + # إضافة المقاول الجديد + new_subcontractor = { + 'id': max([subcontractor['id'] for subcontractor in st.session_state.subcontractors], default=0) + 1, + 'name': new_subcontractor_name, + 'category': new_subcontractor_category, + 'specialization': new_subcontractor_specialization, + 'rating': new_subcontractor_rating, + 'city': new_subcontractor_city, + 'contact_person': new_subcontractor_contact, + 'phone': new_subcontractor_phone, + 'email': new_subcontractor_email, + 'local_content': new_subcontractor_local_content, + 'last_updated': datetime.now().strftime('%Y-%m-%d') + } + + st.session_state.subcontractors.append(new_subcontractor) + st.success(f"تمت إضافة المقاول '{new_subcontractor_name}' بنجاح!") + st.session_state.show_subcontractor_form = False + st.rerun() + + if cancel: + st.session_state.show_subcontractor_form = False + st.rerun() - with st.form("add_equipment_form"): - col1, col2 = st.columns(2) + # عرض قائمة المقاولين + if filtered_subcontractors: + # تحويل البيانات إلى DataFrame + subcontractors_df = pd.DataFrame(filtered_subcontractors) + + # تنسيق البيانات للعرض + display_df = subcontractors_df.copy() + display_df['local_content'] = display_df['local_content'].apply(lambda x: f"{x}%") + + # تغيير أسماء الأعمدة للعرض + display_df.columns = [ + 'معرف', 'اسم المقاول', 'الفئة', 'التخصص', 'التقييم', 'المدينة', + 'جهة الاتصال', 'رقم الهاتف', 'البريد الإلكتروني', 'نسبة المحتوى المحلي', 'آخر تحديث' + ] + + # عرض الجدول + st.dataframe(display_df, use_container_width=True, hide_index=True) + + # عرض ملخص إحصائي + st.markdown("#### ملخص إحصائي للمقاولين") + + col1, col2, col3 = st.columns(3) with col1: - new_equipment_name = st.text_input("اسم المعدة") - new_equipment_type = st.selectbox("النوع", options=equipment_df["النوع"].unique()) - new_equipment_cost = st.number_input("التكلفة اليومية", min_value=100, max_value=10000, value=1000) + st.metric("إجمالي عدد المقاولين", len(filtered_subcontractors)) with col2: - new_equipment_available = st.checkbox("متاحة", value=True) - new_equipment_condition = st.selectbox("الحالة", options=["ممتاز", "جيد", "متوسط", "سيء"]) - new_equipment_location = st.selectbox("الموقع", options=equipment_df["الموقع"].unique()) + avg_rating = sum(subcontractor['rating'] for subcontractor in filtered_subcontractors) / len(filtered_subcontractors) + st.metric("متوسط التقييم", f"{avg_rating:.1f}/5.0") - submit_button = st.form_submit_button("إضافة معدة") + with col3: + avg_local_content = sum(subcontractor['local_content'] for subcontractor in filtered_subcontractors) / len(filtered_subcontractors) + st.metric("متوسط نسبة المحتوى المحلي", f"{avg_local_content:.1f}%") - if submit_button: - if new_equipment_name: - # إنشاء رقم معدة جديد - new_equipment_id = f"EQ-{len(equipment_df) + 1:03d}" - - # إضافة المعدة الجديدة - new_equipment = pd.DataFrame({ - "رقم المعدة": [new_equipment_id], - "اسم المعدة": [new_equipment_name], - "النوع": [new_equipment_type], - "التكلفة اليومية": [new_equipment_cost], - "متاحة": [new_equipment_available], - "الحالة": [new_equipment_condition], - "الموقع": [new_equipment_location] - }) - - # تحديث DataFrame المعدات - st.session_state.resources_data["equipment"] = pd.concat([equipment_df, new_equipment], ignore_index=True) - - st.success(f"تم إضافة المعدة {new_equipment_name} بنجاح!") - st.rerun() - else: - st.error("يرجى إدخال اسم المعدة") - - def _render_materials_tab(self): - """عرض تبويب المواد""" - - st.markdown("### إدارة المواد") - - # استخراج البيانات - materials_df = st.session_state.resources_data["materials"] - - # عرض خيارات التصفية - st.markdown("#### خيارات التصفية") - - col1, col2, col3 = st.columns(3) - - with col1: - selected_units = st.multiselect( - "الوحدة", - options=materials_df["الوحدة"].unique(), - default=materials_df["الوحدة"].unique() - ) - - with col2: - selected_suppliers = st.multiselect( - "المورد", - options=materials_df["المورد"].unique(), - default=materials_df["المورد"].unique() + # عرض مخطط توزيع المقاولين حسب الفئة + category_counts = subcontractors_df.groupby('category').size().reset_index(name='count') + + fig = px.pie( + category_counts, + names='category', + values='count', + title='توزيع المقاولين حسب الفئة', + hole=0.4 ) - - with col3: - stock_filter = st.selectbox( - "المخزون", - options=["الكل", "منخفض المخزون", "مخزون كافي"] + + st.plotly_chart(fig, use_container_width=True) + + # عرض مخطط توزيع المقاولين حسب المدينة + city_counts = subcontractors_df.groupby('city').size().reset_index(name='count') + + fig = px.bar( + city_counts, + x='city', + y='count', + title='توزيع المقاولين حسب المدينة', + color='city', + labels={'city': 'المدينة', 'count': 'العدد'} ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.warning("لا يوجد مقاولين مطابقين لمعايير البحث.") + + def _render_price_analysis_tab(self): + """عرض تبويب تحليل الأسعار""" - # تطبيق التصفية - filtered_df = materials_df[ - materials_df["الوحدة"].isin(selected_units) & - materials_df["المورد"].isin(selected_suppliers) - ] + st.markdown("### تحليل الأسعار") - if stock_filter == "منخفض المخزون": - filtered_df = filtered_df[filtered_df["الكمية المتاحة"] < 50] - elif stock_filter == "مخزون كافي": - filtered_df = filtered_df[filtered_df["الكمية المتاحة"] >= 50] - - # عرض البيانات المصفاة - st.markdown("#### قائمة المواد") - - st.dataframe( - filtered_df, - column_config={ - "رقم المادة": st.column_config.TextColumn("رقم المادة"), - "اسم المادة": st.column_config.TextColumn("اسم المادة"), - "الوحدة": st.column_config.TextColumn("الوحدة"), - "الكمية المتاحة": st.column_config.NumberColumn("الكمية المتاحة"), - "تكلفة الوحدة": st.column_config.NumberColumn("تكلفة الوحدة", format="%.2f ريال"), - "المورد": st.column_config.TextColumn("المورد"), - "مدة التوريد (يوم)": st.column_config.NumberColumn("مدة التوريد (يوم)") - }, - use_container_width=True, - hide_index=True + # اختيار نوع التحليل + analysis_type = st.radio( + "نوع التحليل", + ["تحليل أسعار المواد", "مقارنة الأسعار", "توقع الأسعار المستقبلية"], + horizontal=True ) - # عرض إحصائيات المواد - st.markdown("#### إحصائيات المواد") - - col1, col2, col3, col4 = st.columns(4) - - with col1: - total_materials = len(filtered_df) - st.metric("إجمالي المواد", f"{total_materials}") - - with col2: - low_stock_materials = len(filtered_df[filtered_df["الكمية المتاحة"] < 50]) - low_stock_rate = low_stock_materials / total_materials * 100 if total_materials > 0 else 0 - st.metric("نسبة المواد منخفضة المخزون", f"{low_stock_rate:.1f}%") - - with col3: - avg_lead_time = filtered_df["مدة التوريد (يوم)"].mean() - st.metric("متوسط مدة التوريد", f"{avg_lead_time:.1f} يوم") - - with col4: - total_inventory_value = (filtered_df["الكمية المتاحة"] * filtered_df["تكلفة الوحدة"]).sum() - st.metric("إجمالي قيمة المخزون", f"{total_inventory_value:,.0f} ريال") - - # عرض توزيع المواد حسب المورد - st.markdown("#### توزيع المواد حسب المورد") - - supplier_counts = filtered_df["المورد"].value_counts().reset_index() - supplier_counts.columns = ["المورد", "العدد"] - - fig = px.pie( - supplier_counts, - values="العدد", - names="المورد", - title="توزيع المواد حسب المورد", - color="المورد" + if analysis_type == "تحليل أسعار المواد": + self._render_material_price_analysis() + elif analysis_type == "مقارنة الأسعار": + self._render_price_comparison() + else: + self._render_price_forecast() + + def _render_material_price_analysis(self): + """عرض تحليل أسعار المواد""" + + st.markdown("#### تحليل أسعار المواد") + + # اختيار المواد للتحليل + material_options = [material['name'] for material in st.session_state.materials] + selected_materials = st.multiselect( + "اختر المواد للتحليل", + options=material_options, + default=material_options[:3] if len(material_options) >= 3 else material_options, + key="price_analysis_materials_tab" ) - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع المواد حسب الوحدة - st.markdown("#### توزيع المواد حسب الوحدة") - - unit_counts = filtered_df["الوحدة"].value_counts().reset_index() - unit_counts.columns = ["الوحدة", "العدد"] + if not selected_materials: + st.warning("الرجاء اختيار مادة واحدة على الأقل للتحليل.") + return - fig = px.bar( - unit_counts, - x="الوحدة", - y="العدد", - title="توزيع المواد حسب الوحدة", - color="الوحدة", - text_auto=True - ) + # إعداد البيانات للتحليل + material_ids = {material['name']: material['id'] for material in st.session_state.materials} + selected_ids = [material_ids[name] for name in selected_materials if name in material_ids] - st.plotly_chart(fig, use_container_width=True) - - # عرض المواد منخفضة المخزون - st.markdown("#### المواد منخفضة المخزون") + # التحقق من وجود بيانات سعرية في session_state.price_history + if 'price_history' not in st.session_state or not st.session_state.price_history: + st.warning("لا توجد بيانات أسعار متاحة للتحليل.") + return + + price_history_data = [] + for entry in st.session_state.price_history: + if entry['material_id'] in selected_ids: + # الحصول على اسم المادة من المعرف + material_name = next((material['name'] for material in st.session_state.materials if material['id'] == entry['material_id']), "") + + # التحقق من وجود المفاتيح المطلوبة + if 'date' in entry and 'price' in entry: + try: + # إضافة البيانات إلى قائمة البيانات مع تحويل التاريخ إلى كائن datetime + price_history_data.append({ + 'material': material_name, # استخدام أسماء إنجليزية للمفاتيح + 'date': pd.to_datetime(entry['date']), + 'price': float(entry['price']) # التأكد من تحويل السعر إلى رقم + }) + except (ValueError, TypeError) as e: + # تسجيل أخطاء تحويل البيانات + st.error(f"خطأ في معالجة البيانات: {e}") + continue + + if not price_history_data: + st.warning("لا توجد بيانات أسعار متاحة للمواد المختارة.") + return + + # تحويل البيانات إلى DataFrame + price_history_df = pd.DataFrame(price_history_data) + + # التحقق من وجود بيانات قبل رسم المخطط + if len(price_history_df) == 0: + st.warning("لا توجد بيانات تاريخية للأسعار متاحة لعرضها") + else: + # عرض المخطط الخطي للأسعار باستخدام أسماء الأعمدة الإنجليزية + fig = px.line( + price_history_df, + x='date', + y='price', + color='material', + title='تطور أسعار المواد المختارة', + labels={'date': 'التاريخ', 'price': 'السعر (ريال)', 'material': 'المادة'} + ) + + st.plotly_chart(fig, use_container_width=True) - low_stock_df = filtered_df[filtered_df["الكمية المتاحة"] < 50].sort_values("الكمية المتاحة") + # حساب التغيرات في الأسعار + materials_price_changes = [] - if not low_stock_df.empty: + for material_name in selected_materials: + # استخدام أسماء الأعمدة الإنجليزية للتصفية والترتيب + material_prices = price_history_df[price_history_df['material'] == material_name].sort_values('date') + + if len(material_prices) >= 2: + first_price = material_prices.iloc[0]['price'] + last_price = material_prices.iloc[-1]['price'] + price_change = last_price - first_price + price_change_percent = (price_change / first_price) * 100 + + # حساب التقلب (الانحراف المعياري) + price_volatility = material_prices['price'].std() + + materials_price_changes.append({ + 'المادة': material_name, + 'السعر الأول': first_price, + 'السعر الأخير': last_price, + 'التغير المطلق': price_change, + 'نسبة التغير (%)': price_change_percent, + 'التقلب (الانحراف المعياري)': price_volatility + }) + + # عرض جدول التغيرات في الأسعار + if materials_price_changes: + st.markdown("#### تغيرات الأسعار خلال الفترة") + + changes_df = pd.DataFrame(materials_price_changes) + + # تنسيق البيانات للعرض + display_df = changes_df.copy() + display_df['السعر الأول'] = display_df['السعر الأول'].apply(lambda x: f"{x:,.2f} ريال") + display_df['السعر الأخير'] = display_df['السعر الأخير'].apply(lambda x: f"{x:,.2f} ريال") + display_df['التغير المطلق'] = display_df['التغير المطلق'].apply(lambda x: f"{x:,.2f} ريال") + display_df['نسبة التغير (%)'] = display_df['نسبة التغير (%)'].apply(lambda x: f"{x:.2f}%") + display_df['التقلب (الانحراف المعياري)'] = display_df['التقلب (الانحراف المعياري)'].apply(lambda x: f"{x:.2f}") + + st.dataframe(display_df, use_container_width=True, hide_index=True) + + # عرض مخطط شريطي للتغيرات في الأسعار fig = px.bar( - low_stock_df, - x="اسم المادة", - y="الكمية المتاحة", - title="المواد منخفضة المخزون", - color="الكمية المتاحة", - color_continuous_scale="Reds_r", - text_auto=True + changes_df, + x='المادة', + y='نسبة التغير (%)', + title='نسبة التغير في الأسعار', + color='المادة', + text_auto='.1f' ) + fig.update_traces(texttemplate='%{text}%', textposition='outside') + st.plotly_chart(fig, use_container_width=True) - else: - st.info("لا توجد مواد منخفضة المخزون") - - # عرض العلاقة بين مدة التوريد والمورد - st.markdown("#### العلاقة بين مدة التوريد والمورد") + + def _render_price_comparison(self): + """عرض مقارنة الأسعار""" - supplier_lead_time = filtered_df.groupby("المورد")["مدة التوريد (يوم)"].mean().reset_index() - supplier_lead_time.columns = ["المورد", "متوسط مدة التوريد (يوم)"] + st.markdown("#### مقارنة الأسعار") - fig = px.bar( - supplier_lead_time, - x="المورد", - y="متوسط مدة التوريد (يوم)", - title="متوسط مدة التوريد حسب المورد", - color="المورد", - text_auto=".1f" + # اختيار نوع المورد للمقارنة + resource_type = st.selectbox( + "نوع المورد", + ["المواد", "العمالة", "المعدات"] ) - st.plotly_chart(fig, use_container_width=True) + if resource_type == "المواد": + resources = st.session_state.materials + elif resource_type == "العمالة": + resources = st.session_state.labor + else: + resources = st.session_state.equipment - # إضافة مادة جديدة - st.markdown("#### إضافة مادة جديدة") + # اختيار الفئة للمقارنة + categories = list(set([resource['category'] for resource in resources])) + selected_category = st.selectbox( + "الفئة", + options=["الكل"] + categories + ) - with st.form("add_material_form"): - col1, col2 = st.columns(2) - - with col1: - new_material_name = st.text_input("اسم المادة") - new_material_unit = st.selectbox("الوحدة", options=materials_df["الوحدة"].unique()) - new_material_quantity = st.number_input("الكمية المتاحة", min_value=0, max_value=10000, value=100) - - with col2: - new_material_cost = st.number_input("تكلفة الوحدة", min_value=1, max_value=10000, value=100) - new_material_supplier = st.selectbox("المورد", options=materials_df["المورد"].unique()) - new_material_lead_time = st.number_input("مدة التوريد (يوم)", min_value=1, max_value=90, value=7) - - submit_button = st.form_submit_button("إضافة مادة") - - if submit_button: - if new_material_name: - # إنشاء رقم مادة جديد - new_material_id = f"MAT-{len(materials_df) + 1:03d}" - - # إضافة المادة الجديدة - new_material = pd.DataFrame({ - "رقم المادة": [new_material_id], - "اسم المادة": [new_material_name], - "الوحدة": [new_material_unit], - "الكمية المتاحة": [new_material_quantity], - "تكلفة الوحدة": [new_material_cost], - "المورد": [new_material_supplier], - "مدة التوريد (يوم)": [new_material_lead_time] - }) - - # تحديث DataFrame المواد - st.session_state.resources_data["materials"] = pd.concat([materials_df, new_material], ignore_index=True) - - st.success(f"تم إضافة المادة {new_material_name} بنجاح!") - st.rerun() - else: - st.error("يرجى إدخال اسم المادة") + # فلترة الموارد حسب الفئة + if selected_category != "الكل": + filtered_resources = [resource for resource in resources if resource['category'] == selected_category] + else: + filtered_resources = resources + + if not filtered_resources: + st.warning("لا توجد موارد مطابقة للفئة المختارة.") + return + + # إعداد بيانات المقارنة + comparison_data = [] + + for resource in filtered_resources: + comparison_data.append({ + 'الاسم': resource['name'], + 'الفئة': resource['category'], + 'الوحدة': resource['unit'], + 'السعر': resource['price'], + 'المورد': resource['supplier'], + 'نسبة المحتوى المحلي': resource['local_content'] + }) - # طلب مواد - st.markdown("#### طلب مواد") + # تحويل البيانات إلى DataFrame + comparison_df = pd.DataFrame(comparison_data) - with st.form("order_materials_form"): - col1, col2, col3 = st.columns(3) - - with col1: - material_to_order = st.selectbox("المادة", options=materials_df["اسم المادة"].unique()) - - with col2: - order_quantity = st.number_input("الكمية المطلوبة", min_value=1, max_value=1000, value=50) - - with col3: - order_date = st.date_input("تاريخ الطلب", value=datetime.now()) - - submit_button = st.form_submit_button("طلب المادة") - - if submit_button: - # محاكاة طلب المادة - st.success(f"تم طلب {order_quantity} {materials_df[materials_df['اسم المادة'] == material_to_order]['الوحدة'].values[0]} من {material_to_order} بنجاح!") - - # عرض تفاصيل الطلب - material_info = materials_df[materials_df["اسم المادة"] == material_to_order].iloc[0] - lead_time = material_info["مدة التوريد (يوم)"] - expected_delivery = order_date + timedelta(days=lead_time) - - st.info(f"تاريخ التسليم المتوقع: {expected_delivery.strftime('%Y-%m-%d')}") - - # حساب التكلفة الإجمالية - unit_cost = material_info["تكلفة الوحدة"] - total_cost = unit_cost * order_quantity - - st.metric("التكلفة الإجمالية", f"{total_cost:,.2f} ريال") - - def _render_resource_allocation_tab(self): - """عرض تبويب تخصيص الموارد""" + # عرض المخطط الشريطي للأسعار + fig = px.bar( + comparison_df, + x='الاسم', + y='السعر', + title=f'مقارنة أسعار {resource_type}', + color='الفئة' if selected_category == "الكل" else 'المورد', + text_auto='.2s', + labels={'السعر': 'السعر (ريال)'} + ) - st.markdown("### تخصيص الموارد") + fig.update_traces(texttemplate='%{text} ريال', textposition='outside') - # استخراج البيانات - employees_df = st.session_state.resources_data["employees"] - equipment_df = st.session_state.resources_data["equipment"] - materials_df = st.session_state.resources_data["materials"] - projects_df = st.session_state.resources_data["projects"] - allocations_df = st.session_state.resources_data["allocations"] + st.plotly_chart(fig, use_container_width=True) - # عرض خيارات التصفية - st.markdown("#### خيارات التصفية") + # عرض العلاقة بين السعر ونسبة المحتوى المحلي + fig = px.scatter( + comparison_df, + x='نسبة المحتوى المحلي', + y='السعر', + color='الفئة' if selected_category == "الكل" else None, + title='العلاقة بين السعر ونسبة المحتوى المحلي', + labels={'نسبة المحتوى المحلي': 'نسبة المحتوى المحلي (%)', 'السعر': 'السعر (ريال)'}, + size=[50] * len(comparison_df), + text='الاسم' + ) - col1, col2 = st.columns(2) + fig.update_traces(textposition='top center') - with col1: - selected_projects = st.multiselect( - "المشروع", - options=projects_df["اسم المشروع"].unique(), - default=projects_df["اسم المشروع"].unique() - ) + st.plotly_chart(fig, use_container_width=True) - with col2: - selected_resource_types = st.multiselect( - "نوع المورد", - options=allocations_df["نوع المورد"].unique(), - default=allocations_df["نوع المورد"].unique() - ) + # عرض جدول المقارنة + st.markdown("#### جدول مقارنة الأسعار") - # تحويل أسماء المشاريع إلى أرقام المشاريع - selected_project_ids = projects_df[projects_df["اسم المشروع"].isin(selected_projects)]["رقم المشروع"].tolist() + # تنسيق البيانات للعرض + display_df = comparison_df.copy() + display_df['السعر'] = display_df['السعر'].apply(lambda x: f"{x:,.2f} ريال") + display_df['نسبة المحتوى المحلي'] = display_df['نسبة المحتوى المحلي'].apply(lambda x: f"{x}%") - # تطبيق التصفية - filtered_df = allocations_df[ - allocations_df["رقم المشروع"].isin(selected_project_ids) & - allocations_df["نوع المورد"].isin(selected_resource_types) - ] + st.dataframe(display_df, use_container_width=True, hide_index=True) + + def _render_price_forecast(self): + """عرض توقع الأسعار المستقبلية""" - # دمج البيانات مع بيانات المشاريع - merged_df = filtered_df.merge( - projects_df[["رقم المشروع", "اسم المشروع"]], - on="رقم المشروع", - how="left" + st.markdown("#### توقع الأسعار المستقبلية") + + # اختيار المادة للتوقع + material_options = [material['name'] for material in st.session_state.materials] + selected_material = st.selectbox( + "اختر المادة للتوقع", + options=material_options ) - # إضافة أسماء الموارد - merged_df["اسم المورد"] = "" - - for i, row in merged_df.iterrows(): - if row["نوع المورد"] == "موظف": - resource_name = employees_df[employees_df["رقم الموظف"] == row["رقم المورد"]]["اسم الموظف"].values - if len(resource_name) > 0: - merged_df.at[i, "اسم المورد"] = resource_name[0] - elif row["نوع المورد"] == "معدة": - resource_name = equipment_df[equipment_df["رقم المعدة"] == row["رقم المورد"]]["اسم المعدة"].values - if len(resource_name) > 0: - merged_df.at[i, "اسم المورد"] = resource_name[0] - elif row["نوع المورد"] == "مادة": - resource_name = materials_df[materials_df["رقم المادة"] == row["رقم المورد"]]["اسم المادة"].values - if len(resource_name) > 0: - merged_df.at[i, "اسم المورد"] = resource_name[0] - - # عرض البيانات المصفاة - st.markdown("#### قائمة تخصيص الموارد") - - display_df = merged_df[["رقم التخصيص", "اسم المشروع", "نوع المورد", "اسم المورد", "تاريخ البدء", "تاريخ الانتهاء", "الكمية", "التكلفة"]] - - st.dataframe( - display_df, - column_config={ - "رقم التخصيص": st.column_config.TextColumn("رقم التخصيص"), - "اسم المشروع": st.column_config.TextColumn("اسم المشروع"), - "نوع المورد": st.column_config.TextColumn("نوع المورد"), - "اسم المورد": st.column_config.TextColumn("اسم المورد"), - "تاريخ البدء": st.column_config.DateColumn("تاريخ البدء"), - "تاريخ الانتهاء": st.column_config.DateColumn("تاريخ الانتهاء"), - "الكمية": st.column_config.NumberColumn("الكمية"), - "التكلفة": st.column_config.NumberColumn("التكلفة", format="%.2f ريال") - }, - use_container_width=True, - hide_index=True + # اختيار فترة التوقع + forecast_period = st.slider( + "فترة التوقع (أشهر)", + min_value=1, + max_value=12, + value=6 ) - # عرض إحصائيات تخصيص الموارد - st.markdown("#### إحصائيات تخصيص الموارد") + if not selected_material: + st.warning("الرجاء اختيار مادة للتوقع.") + return + + # الحصول على معرف المادة + material_id = next((material['id'] for material in st.session_state.materials if material['name'] == selected_material), None) + + if material_id is None: + st.error("المادة المحددة غير موجودة.") + return + + # الحصول على بيانات الأسعار التاريخية + price_history_data = [] + for entry in st.session_state.price_history: + if entry['material_id'] == material_id: + try: + price_history_data.append({ + 'date': pd.to_datetime(entry['date']), + 'price': float(entry['price']) + }) + except (ValueError, TypeError) as e: + st.error(f"خطأ في معالجة البيانات: {e}") + continue - col1, col2, col3, col4 = st.columns(4) + if not price_history_data: + st.warning("لا توجد بيانات تاريخية كافية للمادة المحددة للقيام بالتوقع.") + return - with col1: - total_allocations = len(merged_df) - st.metric("إجمالي التخصيصات", f"{total_allocations}") + # تحويل البيانات إلى DataFrame + price_history_df = pd.DataFrame(price_history_data).sort_values('date') - with col2: - total_cost = merged_df["التكلفة"].sum() - st.metric("إجمالي التكلفة", f"{total_cost:,.0f} ريال") + # إجراء التوقع + # في الواقع، ستستخدم نماذج تعلم آلي مثل ARIMA أو Prophet + # هنا سنستخدم توقعًا بسيطًا للأغراض التوضيحية - with col3: - avg_duration = (pd.to_datetime(merged_df["تاريخ الانتهاء"]) - pd.to_datetime(merged_df["تاريخ البدء"])).mean().days - st.metric("متوسط مدة التخصيص", f"{avg_duration:.0f} يوم") + # حساب متوسط التغير الشهري + monthly_changes = [] + for i in range(1, len(price_history_df)): + monthly_changes.append(price_history_df.iloc[i]['price'] - price_history_df.iloc[i-1]['price']) - with col4: - resource_types = merged_df["نوع المورد"].value_counts() - most_common_type = resource_types.index[0] if not resource_types.empty else "" - st.metric("أكثر أنواع الموارد تخصيصاً", f"{most_common_type}") + if monthly_changes: + avg_monthly_change = sum(monthly_changes) / len(monthly_changes) + else: + avg_monthly_change = 0 - # عرض توزيع تخصيص الموارد حسب المشروع - st.markdown("#### توزيع تخصيص الموارد حسب المشروع") + # إنشاء بيانات التوقع + last_date = price_history_df['date'].max() + last_price = price_history_df.loc[price_history_df['date'] == last_date, 'price'].values[0] - project_allocations = merged_df.groupby("اسم المشروع").size().reset_index() - project_allocations.columns = ["اسم المشروع", "عدد التخصيصات"] + forecast_dates = pd.date_range(start=last_date + pd.DateOffset(months=1), periods=forecast_period, freq='M') + forecast_prices = [last_price + (i+1) * avg_monthly_change for i in range(forecast_period)] - fig = px.bar( - project_allocations, - x="اسم المشروع", - y="عدد التخصيصات", - title="توزيع تخصيص الموارد حسب المشروع", - color="اسم المشروع", - text_auto=True - ) + # إضافة بعض التقلبات العشوائية للتوقع + forecast_prices = [price + random.uniform(-price*0.05, price*0.05) for price in forecast_prices] - st.plotly_chart(fig, use_container_width=True) + forecast_df = pd.DataFrame({ + 'date': forecast_dates, + 'price': forecast_prices, + 'type': ['توقع'] * forecast_period + }) - # عرض توزيع تخصيص الموارد حسب نوع المورد - st.markdown("#### توزيع تخصيص الموارد حسب نوع المورد") - - resource_type_allocations = merged_df.groupby("نوع المورد").size().reset_index() - resource_type_allocations.columns = ["نوع المورد", "عدد التخصيصات"] - - fig = px.pie( - resource_type_allocations, - values="عدد التخصيصات", - names="نوع المورد", - title="توزيع تخصيص الموارد حسب نوع المورد", - color="نوع المورد", - color_discrete_map={ - "موظف": "#3498db", - "معدة": "#2ecc71", - "مادة": "#f39c12" - } + # دمج البيانات التاريخية والتوقع + historical_df = price_history_df.copy() + historical_df['type'] = ['تاريخي'] * len(historical_df) + + combined_df = pd.concat([historical_df, forecast_df], ignore_index=True) + + # عرض المخطط + fig = px.line( + combined_df, + x='date', + y='price', + color='type', + title=f'توقع أسعار {selected_material} للـ {forecast_period} أشهر القادمة', + labels={'date': 'التاريخ', 'price': 'السعر (ريال)', 'type': 'النوع'}, + color_discrete_map={'تاريخي': 'blue', 'توقع': 'red'} ) - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع تكاليف الموارد حسب المشروع - st.markdown("#### توزيع تكاليف الموارد حسب المشروع") - - project_costs = merged_df.groupby("اسم المشروع")["التكلفة"].sum().reset_index() - project_costs.columns = ["اسم المشروع", "إجمالي التكلفة"] - - fig = px.bar( - project_costs, - x="اسم المشروع", - y="إجمالي التكلفة", - title="توزيع تكاليف الموارد حسب المشروع", - color="اسم المشروع", - text_auto=".0f" + # إضافة فترة الثقة حول التوقع + confidence = 0.1 # 10% فترة ثقة + upper_bound = [price * (1 + confidence) for price in forecast_prices] + lower_bound = [price * (1 - confidence) for price in forecast_prices] + + fig.add_scatter( + x=forecast_dates, + y=upper_bound, + fill=None, + mode='lines', + line_color='rgba(255, 0, 0, 0.3)', + line_width=0, + showlegend=False ) - st.plotly_chart(fig, use_container_width=True) - - # عرض توزيع تكاليف الموارد حسب نوع المورد - st.markdown("#### توزيع تكاليف الموارد حسب نوع المورد") - - resource_type_costs = merged_df.groupby("نوع المورد")["التكلفة"].sum().reset_index() - resource_type_costs.columns = ["نوع المورد", "إجمالي التكلفة"] - - fig = px.pie( - resource_type_costs, - values="إجمالي التكلفة", - names="نوع المورد", - title="توزيع تكاليف الموارد حسب نوع المورد", - color="نوع المورد", - color_discrete_map={ - "موظف": "#3498db", - "معدة": "#2ecc71", - "مادة": "#f39c12" - } + fig.add_scatter( + x=forecast_dates, + y=lower_bound, + fill='tonexty', + mode='lines', + line_color='rgba(255, 0, 0, 0.3)', + line_width=0, + name='فترة الثقة (±10%)' ) st.plotly_chart(fig, use_container_width=True) - # إضافة تخصيص جديد - st.markdown("#### إضافة تخصيص جديد") + # عرض جدول التوقع + st.markdown("#### جدول توقع الأسعار") - with st.form("add_allocation_form"): - col1, col2 = st.columns(2) - - with col1: - new_allocation_project = st.selectbox("المشروع", options=projects_df["اسم المشروع"].unique()) - new_allocation_resource_type = st.selectbox("نوع المورد", options=["موظف", "معدة", "مادة"]) - - # تحديد خيارات الموارد بناءً على النوع - if new_allocation_resource_type == "موظف": - resource_options = employees_df[employees_df["متاح"] == True]["اسم الموظف"].unique() - elif new_allocation_resource_type == "معدة": - resource_options = equipment_df[equipment_df["متاحة"] == True]["اسم المعدة"].unique() - else: - resource_options = materials_df["اسم المادة"].unique() - - new_allocation_resource = st.selectbox("المورد", options=resource_options) - - with col2: - new_allocation_start_date = st.date_input("تاريخ البدء", value=datetime.now()) - new_allocation_end_date = st.date_input("تاريخ الانتهاء", value=datetime.now() + timedelta(days=30)) - new_allocation_quantity = st.number_input("الكمية", min_value=1, max_value=100, value=1) - - submit_button = st.form_submit_button("إضافة تخصيص") - - if submit_button: - # التحقق من صحة التواريخ - if new_allocation_end_date <= new_allocation_start_date: - st.error("يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء") - else: - # الحصول على رقم المشروع - project_id = projects_df[projects_df["اسم المشروع"] == new_allocation_project]["رقم المشروع"].values[0] - - # الحصول على رقم المورد - if new_allocation_resource_type == "موظف": - resource_id = employees_df[employees_df["اسم الموظف"] == new_allocation_resource]["رقم الموظف"].values[0] - # حساب التكلفة - cost = employees_df[employees_df["رقم الموظف"] == resource_id]["التكلفة الشهرية"].values[0] * new_allocation_quantity - elif new_allocation_resource_type == "معدة": - resource_id = equipment_df[equipment_df["اسم المعدة"] == new_allocation_resource]["رقم المعدة"].values[0] - # حساب التكلفة - days = (new_allocation_end_date - new_allocation_start_date).days - cost = equipment_df[equipment_df["رقم المعدة"] == resource_id]["التكلفة اليومية"].values[0] * days * new_allocation_quantity - else: - resource_id = materials_df[materials_df["اسم المادة"] == new_allocation_resource]["رقم المادة"].values[0] - # حساب التكلفة - cost = materials_df[materials_df["رقم المادة"] == resource_id]["تكلفة الوحدة"].values[0] * new_allocation_quantity - - # إنشاء رقم تخصيص جديد - new_allocation_id = f"ALLOC-{len(allocations_df) + 1:03d}" - - # إضافة التخصيص الجديد - new_allocation = pd.DataFrame({ - "رقم التخصيص": [new_allocation_id], - "رقم المشروع": [project_id], - "نوع المورد": [new_allocation_resource_type], - "رقم المورد": [resource_id], - "تاريخ البدء": [new_allocation_start_date.strftime("%Y-%m-%d")], - "تاريخ الانتهاء": [new_allocation_end_date.strftime("%Y-%m-%d")], - "الكمية": [new_allocation_quantity], - "التكلفة": [cost] - }) - - # تحديث DataFrame التخصيصات - st.session_state.resources_data["allocations"] = pd.concat([allocations_df, new_allocation], ignore_index=True) - - # تحديث حالة المورد إذا كان موظف أو معدة - if new_allocation_resource_type == "موظف": - employees_idx = employees_df[employees_df["رقم الموظف"] == resource_id].index - st.session_state.resources_data["employees"].at[employees_idx[0], "متاح"] = False - elif new_allocation_resource_type == "معدة": - equipment_idx = equipment_df[equipment_df["رقم المعدة"] == resource_id].index - st.session_state.resources_data["equipment"].at[equipment_idx[0], "متاحة"] = False - elif new_allocation_resource_type == "مادة": - materials_idx = materials_df[materials_df["رقم المادة"] == resource_id].index - current_quantity = st.session_state.resources_data["materials"].at[materials_idx[0], "الكمية المتاحة"] - st.session_state.resources_data["materials"].at[materials_idx[0], "الكمية المتاحة"] = max(0, current_quantity - new_allocation_quantity) - - st.success(f"تم إضافة تخصيص {new_allocation_resource_type} ({new_allocation_resource}) لمشروع {new_allocation_project} بنجاح!") - st.rerun() - - def _render_resource_planning_tab(self): - """عرض تبويب تخطيط الموارد""" - - st.markdown("### تخطيط الموارد") - - # استخراج البيانات - employees_df = st.session_state.resources_data["employees"] - equipment_df = st.session_state.resources_data["equipment"] - materials_df = st.session_state.resources_data["materials"] - projects_df = st.session_state.resources_data["projects"] - allocations_df = st.session_state.resources_data["allocations"] - - # عرض المشاريع القادمة - st.markdown("#### المشاريع القادمة") - - upcoming_projects = projects_df[projects_df["الحالة"] == "مخطط"].sort_values("تاريخ البدء") - - if not upcoming_projects.empty: - st.dataframe( - upcoming_projects, - column_config={ - "رقم المشروع": st.column_config.TextColumn("رقم المشروع"), - "اسم المشروع": st.column_config.TextColumn("اسم المشروع"), - "الموقع": st.column_config.TextColumn("الموقع"), - "تاريخ البدء": st.column_config.DateColumn("تاريخ البدء"), - "تاريخ الانتهاء": st.column_config.DateColumn("تاريخ الانتهاء"), - "الميزانية": st.column_config.NumberColumn("الميزانية", format="%.2f ريال"), - "الحالة": st.column_config.TextColumn("الحالة") - }, - use_container_width=True, - hide_index=True - ) - else: - st.info("لا توجد مشاريع قادمة") + forecast_table = forecast_df.copy() + forecast_table['date'] = forecast_table['date'].dt.strftime('%Y-%m') + forecast_table['price'] = forecast_table['price'].apply(lambda x: f"{x:,.2f} ريال") + # إعادة تسمية الأعمدة إلى العربية لعرض الجدول + forecast_table = forecast_table.rename(columns={ + 'date': 'التاريخ', + 'price': 'السعر' + }) + forecast_table = forecast_table.drop(columns=['type']) + + st.dataframe(forecast_table, use_container_width=True, hide_index=True) - # عرض توافر الموارد - st.markdown("#### توافر الموارد") + # عرض ملخص التوقع + st.markdown("#### ملخص التوقع") col1, col2, col3 = st.columns(3) with col1: - total_employees = len(employees_df) - available_employees = len(employees_df[employees_df["متاح"] == True]) - availability_rate = available_employees / total_employees * 100 if total_employees > 0 else 0 - - st.metric("الموظفون المتاحون", f"{available_employees}/{total_employees}", f"{availability_rate:.1f}%") - - # عرض توزيع توافر الموظفين - availability_data = pd.DataFrame({ - "الحالة": ["متاح", "غير متاح"], - "العدد": [available_employees, total_employees - available_employees] - }) - - fig = px.pie( - availability_data, - values="العدد", - names="الحالة", - title="توافر الموظفين", - color="الحالة", - color_discrete_map={ - "متاح": "#2ecc71", - "غير متاح": "#e74c3c" - } + st.metric( + "السعر الحالي", + f"{last_price:,.2f} ريال" ) - - st.plotly_chart(fig, use_container_width=True) with col2: - total_equipment = len(equipment_df) - available_equipment = len(equipment_df[equipment_df["متاحة"] == True]) - availability_rate = available_equipment / total_equipment * 100 if total_equipment > 0 else 0 - - st.metric("المعدات المتاحة", f"{available_equipment}/{total_equipment}", f"{availability_rate:.1f}%") - - # عرض توزيع توافر المعدات - availability_data = pd.DataFrame({ - "الحالة": ["متاحة", "غير متاحة"], - "العدد": [available_equipment, total_equipment - available_equipment] - }) - - fig = px.pie( - availability_data, - values="العدد", - names="الحالة", - title="توافر المعدات", - color="الحالة", - color_discrete_map={ - "متاحة": "#2ecc71", - "غير متاحة": "#e74c3c" - } + forecasted_price = forecast_prices[-1] + price_change = forecasted_price - last_price + price_change_percent = (price_change / last_price) * 100 + + st.metric( + f"السعر المتوقع بعد {forecast_period} أشهر", + f"{forecasted_price:,.2f} ريال", + delta=f"{price_change_percent:.1f}%" ) - - st.plotly_chart(fig, use_container_width=True) with col3: - total_materials = len(materials_df) - low_stock_materials = len(materials_df[materials_df["الكمية المتاحة"] < 50]) - low_stock_rate = low_stock_materials / total_materials * 100 if total_materials > 0 else 0 - - st.metric("المواد منخفضة المخزون", f"{low_stock_materials}/{total_materials}", f"{low_stock_rate:.1f}%") + avg_forecasted_price = sum(forecast_prices) / len(forecast_prices) - # عرض توزيع حالة المخزون - stock_data = pd.DataFrame({ - "حالة المخزون": ["مخزون كافي", "مخزون منخفض"], - "العدد": [total_materials - low_stock_materials, low_stock_materials] - }) - - fig = px.pie( - stock_data, - values="العدد", - names="حالة المخزون", - title="حالة مخزون المواد", - color="حالة المخزون", - color_discrete_map={ - "مخزون كافي": "#2ecc71", - "مخزون منخفض": "#e74c3c" - } + st.metric( + "متوسط السعر المتوقع", + f"{avg_forecasted_price:,.2f} ريال" ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض جدول زمني للموارد - st.markdown("#### الجدول الزمني للموارد") - - # إنشاء DataFrame للجدول الزمني - timeline_df = allocations_df.copy() - timeline_df["تاريخ البدء"] = pd.to_datetime(timeline_df["تاريخ البدء"]) - timeline_df["تاريخ الانتهاء"] = pd.to_datetime(timeline_df["تاريخ الانتهاء"]) - - # دمج البيانات مع بيانات المشاريع - timeline_df = timeline_df.merge( - projects_df[["رقم المشروع", "اسم المشروع"]], - on="رقم المشروع", - how="left" - ) - - # إضافة أسماء الموارد - timeline_df["اسم المورد"] = "" - - for i, row in timeline_df.iterrows(): - if row["نوع المورد"] == "موظف": - resource_name = employees_df[employees_df["رقم الموظف"] == row["رقم المورد"]]["اسم الموظف"].values - if len(resource_name) > 0: - timeline_df.at[i, "اسم المورد"] = resource_name[0] - elif row["نوع المورد"] == "معدة": - resource_name = equipment_df[equipment_df["رقم المعدة"] == row["رقم المورد"]]["اسم المعدة"].values - if len(resource_name) > 0: - timeline_df.at[i, "اسم المورد"] = resource_name[0] - elif row["نوع المورد"] == "مادة": - resource_name = materials_df[materials_df["رقم المادة"] == row["رقم المورد"]]["اسم المادة"].values - if len(resource_name) > 0: - timeline_df.at[i, "اسم المورد"] = resource_name[0] - - # إنشاء رسم بياني للجدول الزمني - fig = px.timeline( - timeline_df, - x_start="تاريخ البدء", - x_end="تاريخ الانتهاء", - y="اسم المورد", - color="اسم المشروع", - hover_name="اسم المشروع", - hover_data=["نوع المورد", "الكمية", "التكلفة"], - title="الجدول الزمني لتخصيص الموارد" - ) - - fig.update_yaxes(autorange="reversed") - - st.plotly_chart(fig, use_container_width=True) - - # عرض توقعات الاحتياجات المستقبلية - st.markdown("#### توقعات الاحتياجات المستقبلية") - # اختيار المشروع للتخطيط - project_for_planning = st.selectbox("اختر المشروع للتخطيط", options=upcoming_projects["اسم المشروع"] if not upcoming_projects.empty else ["لا توجد مشاريع قادمة"]) - - if project_for_planning != "لا توجد مشاريع قادمة": - # الحصول على بيانات المشروع - project_data = upcoming_projects[upcoming_projects["اسم المشروع"] == project_for_planning].iloc[0] - - st.markdown(f"**تاريخ البدء:** {project_data['تاريخ البدء']}") - st.markdown(f"**تاريخ الانتهاء:** {project_data['تاريخ الانتهاء']}") - st.markdown(f"**الميزانية:** {project_data['الميزانية']:,.0f} ريال") - - # تقدير الاحتياجات بناءً على المشاريع المماثلة - st.markdown("##### تقدير الاحتياجات بناءً على المشاريع المماثلة") - - # محاكاة تقدير الاحتياجات - estimated_resources = { - "الموظفون": { - "مهندس": 5, - "فني": 10, - "مشرف": 2, - "محاسب": 1, - "مساعد": 3 - }, - "المعدات": { - "حفارة كبيرة": 1, - "حفارة صغيرة": 2, - "شاحنة نقل": 3, - "رافعة متوسطة": 1, - "خلاطة خرسانة": 2 - }, - "المواد": { - "خرسانة جاهزة": 500, - "حديد تسليح": 200, - "طابوق": 10000, - "أسمنت": 1000, - "رمل": 300 - } - } - - # عرض تقدير الاحتياجات - col1, col2, col3 = st.columns(3) - - with col1: - st.markdown("**الموظفون المطلوبون:**") - for position, count in estimated_resources["الموظفون"].items(): - st.markdown(f"- {position}: {count}") - - # التحقق من توافر الموظفين - available_positions = {} - for position, count in estimated_resources["الموظفون"].items(): - available_count = len(employees_df[(employees_df["المنصب"] == position) & (employees_df["متاح"] == True)]) - available_positions[position] = available_count - - if available_count < count: - st.warning(f"نقص في {position}: متاح {available_count}/{count}") - else: - st.success(f"متوفر: {available_count}/{count}") - - with col2: - st.markdown("**المعدات المطلوبة:**") - for equipment_name, count in estimated_resources["المعدات"].items(): - st.markdown(f"- {equipment_name}: {count}") - - # التحقق من توافر المعدات - available_equipment = {} - for equipment_name, count in estimated_resources["المعدات"].items(): - available_count = len(equipment_df[(equipment_df["اسم المعدة"] == equipment_name) & (equipment_df["متاحة"] == True)]) - available_equipment[equipment_name] = available_count - - if available_count < count: - st.warning(f"نقص في {equipment_name}: متاح {available_count}/{count}") - else: - st.success(f"متوفر: {available_count}/{count}") - - with col3: - st.markdown("**المواد المطلوبة:**") - for material_name, quantity in estimated_resources["المواد"].items(): - st.markdown(f"- {material_name}: {quantity}") - - # التحقق من توافر المواد - available_materials = {} - for material_name, quantity in estimated_resources["المواد"].items(): - available_quantity = materials_df[materials_df["اسم المادة"] == material_name]["الكمية المتاحة"].sum() - available_materials[material_name] = available_quantity - - if available_quantity < quantity: - st.warning(f"نقص في {material_name}: متاح {available_quantity}/{quantity}") - else: - st.success(f"متوفر: {available_quantity}/{quantity}") - - # عرض تقدير التكاليف - st.markdown("##### تقدير تكاليف الموارد") - - # حساب تكاليف الموظفين - employee_costs = 0 - for position, count in estimated_resources["الموظفون"].items(): - avg_cost = employees_df[employees_df["المنصب"] == position]["التكلفة الشهرية"].mean() - # افتراض مدة المشروع 6 أشهر - employee_costs += avg_cost * count * 6 - - # حساب تكاليف المعدات - equipment_costs = 0 - for equipment_name, count in estimated_resources["المعدات"].items(): - avg_cost = equipment_df[equipment_df["اسم المعدة"] == equipment_name]["التكلفة اليومية"].mean() - # افتراض مدة المشروع 180 يوم - equipment_costs += avg_cost * count * 180 - - # حساب تكاليف المواد - material_costs = 0 - for material_name, quantity in estimated_resources["المواد"].items(): - avg_cost = materials_df[materials_df["اسم المادة"] == material_name]["تكلفة الوحدة"].mean() - material_costs += avg_cost * quantity - - # إجمالي التكاليف - total_costs = employee_costs + equipment_costs + material_costs - - # عرض التكاليف - col1, col2, col3, col4 = st.columns(4) - - with col1: - st.metric("تكاليف الموظفين", f"{employee_costs:,.0f} ريال") - - with col2: - st.metric("تكاليف المعدات", f"{equipment_costs:,.0f} ريال") - - with col3: - st.metric("تكاليف المواد", f"{material_costs:,.0f} ريال") - - with col4: - st.metric("إجمالي التكاليف", f"{total_costs:,.0f} ريال") - - # عرض توزيع التكاليف - cost_distribution = pd.DataFrame({ - "نوع التكلفة": ["تكاليف الموظفين", "تكاليف المعدات", "تكاليف المواد"], - "التكلفة": [employee_costs, equipment_costs, material_costs] - }) - - fig = px.pie( - cost_distribution, - values="التكلفة", - names="نوع التكلفة", - title="توزيع تكاليف الموارد", - color="نوع التكلفة", - color_discrete_map={ - "تكاليف الموظفين": "#3498db", - "تكاليف المعدات": "#2ecc71", - "تكاليف المواد": "#f39c12" - } - ) - - st.plotly_chart(fig, use_container_width=True) - - # عرض توصيات لتخطيط الموارد - st.markdown("##### توصيات لتخطيط الموارد") - - recommendations = [] - - # توصيات للموظفين - for position, count in estimated_resources["الموظفون"].items(): - available_count = available_positions[position] - if available_count < count: - recommendations.append(f"توظيف {count - available_count} {position} إضافي") - - # توصيات للمعدات - for equipment_name, count in estimated_resources["المعدات"].items(): - available_count = available_equipment[equipment_name] - if available_count < count: - recommendations.append(f"استئجار {count - available_count} {equipment_name} إضافية") - - # توصيات للمواد - for material_name, quantity in estimated_resources["المواد"].items(): - available_quantity = available_materials[material_name] - if available_quantity < quantity: - recommendations.append(f"شراء {quantity - available_quantity} وحدة إضافية من {material_name}") - - if recommendations: - for recommendation in recommendations: - st.markdown(f"- {recommendation}") - else: - st.success("جميع الموارد المطلوبة متوفرة") - - # زر لإنشاء خطة الموارد - if st.button("إنشاء خطة الموارد"): - st.success("تم إنشاء خطة الموارد بنجاح!") - - # عرض ملخص الخطة - st.markdown("##### ملخص خطة الموارد") - st.markdown(f"**المشروع:** {project_for_planning}") - st.markdown(f"**تاريخ البدء:** {project_data['تاريخ البدء']}") - st.markdown(f"**تاريخ الانتهاء:** {project_data['تاريخ الانتهاء']}") - st.markdown(f"**إجمالي تكاليف الموارد:** {total_costs:,.0f} ريال") - st.markdown(f"**نسبة تكاليف الموارد من الميزانية:** {total_costs / project_data['الميزانية'] * 100:.1f}%") - - if recommendations: - st.markdown("**الإجراءات المطلوبة:**") - for recommendation in recommendations: - st.markdown(f"- {recommendation}") - else: - st.markdown("**الإجراءات المطلوبة:** لا توجد إجراءات مطلوبة، جميع الموارد متوفرة") + # عرض ملاحظات وتوصيات + if price_change_percent > 10: + st.warning(""" + ### توقع ارتفاع كبير في الأسعار + - ينصح بشراء المواد مبكراً وتخزينها إذا أمكن + - التفاوض على عقود توريد طويلة الأجل بأسعار ثابتة + - البحث عن موردين بديلين أو مواد بديلة + """) + elif price_change_percent < -10: + st.success(""" + ### توقع انخفاض كبير في الأسعار + - ينصح بتأجيل شراء المواد إذا أمكن + - شراء كميات أقل والاحتفاظ بمخزون منخفض + - التفاوض على عقود مرنة مع الموردين + """) else: - st.info("لا توجد مشاريع قادمة للتخطيط") + st.info(""" + ### توقع استقرار نسبي في الأسعار + - يمكن الشراء حسب الاحتياج دون الحاجة لتخزين كميات كبيرة + - متابعة أسعار السوق بشكل دوري للتأكد من دقة التوقعات + """) \ No newline at end of file diff --git a/modules/risk_analysis/risk_analysis_app.py b/modules/risk_analysis/risk_analysis_app.py new file mode 100644 index 0000000000000000000000000000000000000000..95b2a921305467e214946d3ad8852ac662c8175c --- /dev/null +++ b/modules/risk_analysis/risk_analysis_app.py @@ -0,0 +1,751 @@ +""" +تطبيق وحدة تحليل المخاطر +""" + +import streamlit as st +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import plotly.express as px +import plotly.graph_objects as go +from datetime import datetime +import random +import os +import time +import io + +from utils.helpers import format_number, format_currency +from utils.excel_handler import export_to_excel + + +class RiskAnalysisApp: + """وحدة تحليل المخاطر""" + + def __init__(self): + """تهيئة وحدة تحليل المخاطر""" + # تهيئة المخاطر المحتملة + self.risk_categories = [ + "مخاطر مالية", + "مخاطر زمنية", + "مخاطر فنية", + "مخاطر إدارية", + "مخاطر تنظيمية", + "مخاطر سوقية", + "مخاطر تعاقدية" + ] + + self.impact_levels = ["منخفض", "متوسط", "عالي"] + self.probability_levels = ["غير محتمل", "محتمل", "مؤكد"] + + def render(self): + """عرض واجهة وحدة تحليل المخاطر""" + + st.markdown("

وحدة تحليل المخاطر

", unsafe_allow_html=True) + + tabs = st.tabs([ + "تحليل المخاطر", + "سجل المخاطر", + "مصفوفة المخاطر", + "خطة الاستجابة للمخاطر" + ]) + + with tabs[0]: + self._render_risk_analysis_tab() + + with tabs[1]: + self._render_risk_register_tab() + + with tabs[2]: + self._render_risk_matrix_tab() + + with tabs[3]: + self._render_risk_response_tab() + + def _render_risk_analysis_tab(self): + """عرض تبويب تحليل المخاطر""" + + st.markdown("### تحليل المخاطر") + + # التحقق من وجود مشروع حالي + if 'current_project' not in st.session_state or st.session_state.current_project is None: + # إذا لم يكن هناك مشروع محدد، اعرض قائمة باختيار المشروع + if 'projects' in st.session_state and st.session_state.projects: + project_names = [p['name'] for p in st.session_state.projects] + selected_project_name = st.selectbox("اختر المشروع", project_names) + + if selected_project_name: + selected_project = next((p for p in st.session_state.projects if p['name'] == selected_project_name), None) + if selected_project: + st.session_state.current_project = selected_project + else: + st.warning("لم يتم العثور على المشروع المحدد.") + return + else: + st.info("يرجى اختيار مشروع لتحليل مخاطره.") + return + else: + st.warning("لا توجد مشاريع متاحة. يرجى إنشاء مشروع جديد أولاً.") + return + + # عرض معلومات المشروع + project = st.session_state.current_project + + col1, col2, col3 = st.columns(3) + with col1: + st.metric("اسم المشروع", project['name']) + with col2: + st.metric("رقم المناقصة", project['number']) + with col3: + st.metric("الجهة المالكة", project['client']) + + # التحقق من وجود سجل المخاطر للمشروع + if 'risks' not in project: + project['risks'] = [] + + # نموذج إضافة مخاطر + with st.form("add_risk_form"): + st.markdown("#### إضافة مخاطرة جديدة") + + col1, col2 = st.columns(2) + + with col1: + risk_code = st.text_input("رمز المخاطرة", f"R{len(project['risks']) + 1}") + risk_category = st.selectbox("فئة المخاطرة", self.risk_categories) + impact = st.select_slider("التأثير", self.impact_levels, value="متوسط") + + with col2: + risk_description = st.text_area("وصف المخاطرة", height=80) + probability = st.select_slider("الاحتمالية", self.probability_levels, value="محتمل") + response_strategy = st.text_area("استراتيجية الاستجابة", height=80) + + submitted = st.form_submit_button("إضافة المخاطرة") + + if submitted: + # التحقق من تعبئة الحقول الإلزامية + if not risk_description: + st.error("يرجى إدخال وصف المخاطرة.") + else: + # إنشاء مخاطرة جديدة + new_risk = { + 'id': len(project['risks']) + 1, + 'risk_code': risk_code, + 'description': risk_description, + 'category': risk_category, + 'impact': impact, + 'probability': probability, + 'response_strategy': response_strategy, + 'status': "نشط", + 'created_at': datetime.now().strftime('%Y-%m-%d'), + 'risk_score': self._calculate_risk_score(impact, probability) + } + + # إضافة المخاطرة إلى سجل المخاطر + project['risks'].append(new_risk) + + st.success(f"تمت إضافة المخاطرة [{risk_code}] بنجاح!") + st.balloons() + + # خيارات تحليل المخاطر + st.markdown("#### خيارات تحليل المخاطر") + + col1, col2 = st.columns(2) + + with col1: + automated_analysis = st.button("تحليل تلقائي للمخاطر") + + with col2: + from_document_analysis = st.button("استيراد المخاطر من تحليل المستندات") + + if automated_analysis: + with st.spinner("جاري تحليل المخاطر..."): + time.sleep(2) + self._generate_automated_risks(project) + st.success("تم تحليل المخاطر بنجاح!") + st.balloons() + + if from_document_analysis: + with st.spinner("جاري استيراد المخاطر من تحليل المستندات..."): + time.sleep(2) + + # هذه مجرد محاكاة، في الواقع يجب استدعاء الوظيفة الفعلية لاستيراد المخاطر + document_risks = self._get_risks_from_documents() + + if document_risks: + existing_risk_codes = [r['risk_code'] for r in project['risks']] + + for risk in document_risks: + # تجنب تكرار المخاطر + if risk['risk_code'] not in existing_risk_codes: + project['risks'].append(risk) + + st.success(f"تم استيراد {len(document_risks)} مخاطرة من تحليل المستندات!") + else: + st.warning("لم يتم العثور على مخاطر في المستندات.") + + # عرض ملخص المخاطر + if project['risks']: + self._show_risk_summary(project['risks']) + + def _render_risk_register_tab(self): + """عرض تبويب سجل المخاطر""" + + st.markdown("### سجل المخاطر") + + # التحقق من وجود مشروع حالي + if 'current_project' not in st.session_state or st.session_state.current_project is None: + st.info("يرجى اختيار مشروع من تبويب تحليل المخاطر أولاً.") + return + + project = st.session_state.current_project + + if 'risks' not in project or not project['risks']: + st.info("لا توجد مخاطر مسجلة لهذا المشروع. يمكنك إضافة مخاطر من تبويب تحليل المخاطر.") + return + + # فلترة سجل المخاطر + col1, col2, col3 = st.columns(3) + + with col1: + search_term = st.text_input("البحث في سجل المخاطر") + + with col2: + category_filter = st.multiselect("فلترة حسب الفئة", self.risk_categories) + + with col3: + impact_filter = st.multiselect("فلترة حسب التأثير", self.impact_levels) + + # تطبيق الفلترة + filtered_risks = project['risks'] + + if search_term: + filtered_risks = [r for r in filtered_risks if search_term.lower() in r.get('description', '').lower()] + + if category_filter: + filtered_risks = [r for r in filtered_risks if r.get('category') in category_filter] + + if impact_filter: + filtered_risks = [r for r in filtered_risks if r.get('impact') in impact_filter] + + # عرض سجل المخاطر + if filtered_risks: + # تحويل المخاطر إلى DataFrame + risk_df = pd.DataFrame(filtered_risks) + + # تحديد الأعمدة المراد عرضها وترتيبها + display_columns = [ + 'risk_code', 'description', 'category', 'impact', + 'probability', 'risk_score', 'status' + ] + + # تغيير أسماء الأعمدة للعرض + column_names = { + 'risk_code': 'رمز المخاطرة', + 'description': 'وصف المخاطرة', + 'category': 'الفئة', + 'impact': 'التأثير', + 'probability': 'الاحتمالية', + 'risk_score': 'درجة المخاطرة', + 'status': 'الحالة', + 'response_strategy': 'استراتيجية الاستجابة', + 'created_at': 'تاريخ الإنشاء' + } + + # إعداد DataFrame للعرض + if 'response_strategy' in risk_df.columns: + display_columns.append('response_strategy') + + if 'created_at' in risk_df.columns: + display_columns.append('created_at') + + # الحصول على الأعمدة المتوفرة فقط + available_columns = [col for col in display_columns if col in risk_df.columns] + + if available_columns: + display_df = risk_df[available_columns].rename(columns=column_names) + + # عرض الجدول + st.dataframe(display_df, use_container_width=True, hide_index=True) + + # أزرار العمليات + col1, col2 = st.columns(2) + + with col1: + if st.button("تصدير سجل المخاطر إلى Excel"): + st.success("تم تصدير سجل المخاطر بنجاح!") + + with col2: + if st.button("طباعة تقرير المخاطر"): + st.success("تم إنشاء تقرير المخاطر بنجاح!") + else: + st.warning("هناك مشكلة في بنية بيانات المخاطر. يرجى التحقق من سلامة البيانات.") + else: + st.info("لا توجد مخاطر تطابق معايير البحث.") + + def _render_risk_matrix_tab(self): + """عرض تبويب مصفوفة المخاطر""" + + st.markdown("### مصفوفة المخاطر") + + # التحقق من وجود مشروع حالي + if 'current_project' not in st.session_state or st.session_state.current_project is None: + st.info("يرجى اختيار مشروع من تبويب تحليل المخاطر أولاً.") + return + + project = st.session_state.current_project + + if 'risks' not in project or not project['risks']: + st.info("لا توجد مخاطر مسجلة لهذا المشروع. يمكنك إضافة مخاطر من تبويب تحليل المخاطر.") + return + + # إنشاء ضبط مصفوفة المخاطر (3×3) + impact_values = {"منخفض": 1, "متوسط": 2, "عالي": 3} + probability_values = {"غير محتمل": 1, "محتمل": 2, "مؤكد": 3} + + # إنشاء DataFrame لتمثيل مصفوفة المخاطر + matrix_data = [] + + for p in probability_values.keys(): + for i in impact_values.keys(): + p_value = probability_values[p] + i_value = impact_values[i] + risk_score = p_value * i_value + + # تحديد اللون حسب درجة المخاطرة + if risk_score <= 2: + color = 'green' # منخفضة + elif risk_score <= 6: + color = 'orange' # متوسطة + else: + color = 'red' # عالية + + # استخراج المخاطر التي تقع في هذه الخلية + cell_risks = [r for r in project['risks'] if r.get('impact') == i and r.get('probability') == p] + + # إضافة بيانات الخلية + matrix_data.append({ + 'احتمالية': p, + 'تأثير': i, + 'درجة_المخاطرة': risk_score, + 'عدد_المخاطر': len(cell_risks), + 'المخاطر': [r.get('risk_code') for r in cell_risks], + 'لون': color + }) + + # تحويل إلى DataFrame + matrix_df = pd.DataFrame(matrix_data) + + # رسم مصفوفة المخاطر باستخدام Plotly + fig = go.Figure() + + for index, row in matrix_df.iterrows(): + # إنشاء نص الخلية + if row['عدد_المخاطر'] > 0: + cell_text = f"{', '.join(row['المخاطر'])}
({row['عدد_المخاطر']} مخاطر)" + else: + cell_text = '' + + # إنشاء خلية المصفوفة + fig.add_trace(go.Scatter( + x=[row['تأثير']], + y=[row['احتمالية']], + mode='markers+text', + marker=dict( + color=row['لون'], + size=20 + (row['عدد_المخاطر'] * 5), + opacity=0.8 + ), + text=cell_text, + textposition="middle center", + name=f"{row['احتمالية']} - {row['تأثير']}" + )) + + # تكوين المحاور + fig.update_layout( + title="مصفوفة المخاطر (الاحتمالية × التأثير)", + xaxis=dict( + title="التأثير", + tickmode='array', + tickvals=[1, 2, 3], + ticktext=["منخفض", "متوسط", "عالي"], + gridcolor='lightgray' + ), + yaxis=dict( + title="الاحتمالية", + tickmode='array', + tickvals=[1, 2, 3], + ticktext=["غير محتمل", "محتمل", "مؤكد"], + gridcolor='lightgray' + ), + height=600 + ) + + # عرض المصفوفة + st.plotly_chart(fig, use_container_width=True) + + # عرض توزيع المخاطر حسب الفئة + st.markdown("#### توزيع المخاطر حسب الفئة") + + # حساب عدد المخاطر في كل فئة + category_counts = {} + for r in project['risks']: + category = r.get('category', 'أخرى') + category_counts[category] = category_counts.get(category, 0) + 1 + + # إنشاء DataFrame + category_df = pd.DataFrame({ + 'الفئة': list(category_counts.keys()), + 'عدد المخاطر': list(category_counts.values()) + }) + + # رسم مخطط دائري + fig = px.pie( + category_df, + values='عدد المخاطر', + names='الفئة', + title='توزيع المخاطر حسب الفئة', + hole=0.4 + ) + + st.plotly_chart(fig, use_container_width=True) + + def _render_risk_response_tab(self): + """عرض تبويب خطة الاستجابة للمخاطر""" + + st.markdown("### خطة الاستجابة للمخاطر") + + # التحقق من وجود مشروع حالي + if 'current_project' not in st.session_state or st.session_state.current_project is None: + st.info("يرجى اختيار مشروع من تبويب تحليل المخاطر أولاً.") + return + + project = st.session_state.current_project + + if 'risks' not in project or not project['risks']: + st.info("لا توجد مخاطر مسجلة لهذا المشروع. يمكنك إضافة مخاطر من تبويب تحليل المخاطر.") + return + + # ترتيب المخاطر حسب درجة المخاطرة (من الأعلى إلى الأقل) + sorted_risks = sorted(project['risks'], key=lambda x: x.get('risk_score', 0), reverse=True) + + # عرض خطة الاستجابة للمخاطر + for i, risk in enumerate(sorted_risks): + with st.expander(f"{risk.get('risk_code', '')}: {risk.get('description', 'بدون وصف')}", expanded=(i < 3)): + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown(f"**الفئة**: {risk.get('category', 'غير محدد')}") + st.markdown(f"**التأثير**: {risk.get('impact', 'غير محدد')}") + + with col2: + st.markdown(f"**الاحتمالية**: {risk.get('probability', 'غير محدد')}") + st.markdown(f"**درجة المخاطرة**: {risk.get('risk_score', 'غير محدد')}") + + with col3: + st.markdown(f"**الحالة**: {risk.get('status', 'نشط')}") + risk_owner = risk.get('risk_owner', 'غير محدد') + st.markdown(f"**مسؤول المخاطرة**: {risk_owner}") + + st.markdown("---") + st.markdown("#### استراتيجية الاستجابة") + current_strategy = risk.get('response_strategy', '') + new_strategy = st.text_area(f"استراتيجية الاستجابة للمخاطرة {risk.get('risk_code', '')}", + value=current_strategy, + height=100, + key=f"strategy_{risk.get('risk_code', '')}") + + # تحديث استراتيجية الاستجابة إذا تم تغييرها + if new_strategy != current_strategy: + risk['response_strategy'] = new_strategy + + st.markdown("#### إجراءات التحكم") + control_measures = risk.get('control_measures', []) + + if control_measures: + for j, measure in enumerate(control_measures): + st.markdown(f"{j+1}. {measure}") + else: + st.info("لم يتم تعريف إجراءات تحكم لهذه المخاطرة.") + + # إضافة إجراء تحكم جديد + new_measure = st.text_input(f"إجراء تحكم جديد للمخاطرة {risk.get('risk_code', '')}", + key=f"measure_{risk.get('risk_code', '')}") + + if st.button(f"إضافة إجراء", key=f"add_measure_{risk.get('risk_code', '')}"): + if new_measure: + if 'control_measures' not in risk: + risk['control_measures'] = [] + + risk['control_measures'].append(new_measure) + st.success(f"تم إضافة إجراء التحكم بنجاح!") + st.rerun() + else: + st.error("يرجى إدخال إجراء التحكم.") + + # زر تصدير خطة الاستجابة للمخاطر + if st.button("تصدير خطة الاستجابة للمخاطر"): + st.success("تم تصدير خطة الاستجابة للمخاطر بنجاح!") + + def _calculate_risk_score(self, impact, probability): + """حساب درجة المخاطرة بناءً على التأثير والاحتمالية""" + impact_values = {"منخفض": 1, "متوسط": 2, "عالي": 3} + probability_values = {"غير محتمل": 1, "محتمل": 2, "مؤكد": 3} + + impact_value = impact_values.get(impact, 1) + probability_value = probability_values.get(probability, 1) + + return impact_value * probability_value + + def _generate_automated_risks(self, project): + """توليد مخاطر تلقائية بناءً على خصائص المشروع""" + + # قائمة المخاطر الشائعة في مشاريع المقاولات + common_risks = [ + { + 'risk_code': 'RF01', + 'description': 'غرامة تأخير مرتفعة (10% من قيمة العقد)', + 'category': 'مخاطر مالية', + 'impact': 'عالي', + 'probability': 'محتمل', + 'response_strategy': 'تخصيص مبلغ احتياطي للغرامات المحتملة ووضع خطة لإدارة الجدول الزمني بشكل فعال', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RF02', + 'description': 'متطلبات ضمان بنكي مرتفعة (15% من قيمة العقد)', + 'category': 'مخاطر مالية', + 'impact': 'متوسط', + 'probability': 'مؤكد', + 'response_strategy': 'التفاوض مع العميل لتخفيض نسبة الضمان البنكي أو تقسيمه على مراحل المشروع', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RF03', + 'description': 'شروط دفع متأخرة (60 يوم)', + 'category': 'مخاطر مالية', + 'impact': 'متوسط', + 'probability': 'مؤكد', + 'response_strategy': 'التخطيط للتدفق النقدي مع الأخذ بالاعتبار تأخر الدفعات وتأمين خط ائتمان احتياطي', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RT01', + 'description': 'مدة تنفيذ قصيرة (12 شهر)', + 'category': 'مخاطر زمنية', + 'impact': 'عالي', + 'probability': 'محتمل', + 'response_strategy': 'زيادة فريق العمل واستخدام موارد إضافية مع وضع خطة عمل تفصيلية ومراقبتها أسبوعياً', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RT02', + 'description': 'احتمالية تأخر توريد المواد الرئيسية', + 'category': 'مخاطر زمنية', + 'impact': 'عالي', + 'probability': 'محتمل', + 'response_strategy': 'تحديد المواد ذات فترات التوريد الطويلة وطلبها مبكراً مع التعاقد مع موردين بدلاء', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RTE01', + 'description': 'غموض في بعض المواصفات الفنية', + 'category': 'مخاطر فنية', + 'impact': 'متوسط', + 'probability': 'محتمل', + 'response_strategy': 'طلب توضيح من العميل قبل البدء بالتنفيذ وتوثيق جميع الردود والتوضيحات', + 'status': 'نشط', + 'risk_score': 4 + }, + { + 'risk_code': 'RTE02', + 'description': 'تضارب بين المخططات والمواصفات', + 'category': 'مخاطر فنية', + 'impact': 'متوسط', + 'probability': 'محتمل', + 'response_strategy': 'مراجعة شاملة للمستندات وتوثيق التضاربات وطلب توضيح من العميل', + 'status': 'نشط', + 'risk_score': 4 + }, + { + 'risk_code': 'RM01', + 'description': 'عدم وضوح آلية استلام الأعمال', + 'category': 'مخاطر إدارية', + 'impact': 'منخفض', + 'probability': 'محتمل', + 'response_strategy': 'طلب توضيح آلية الاستلام من العميل ووضع إجراءات داخلية للتحقق من جودة الأعمال قبل التقديم للاستلام', + 'status': 'نشط', + 'risk_score': 2 + }, + { + 'risk_code': 'RR01', + 'description': 'شروط تعجيزية للمحتوى المحلي', + 'category': 'مخاطر تنظيمية', + 'impact': 'عالي', + 'probability': 'محتمل', + 'response_strategy': 'دراسة متطلبات المحتوى المحلي بدقة ووضع خطة لتحقيقها مع الاحتفاظ بسجلات التوثيق اللازمة', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RM01', + 'description': 'خطر التغييرات في أسعار المواد', + 'category': 'مخاطر سوقية', + 'impact': 'عالي', + 'probability': 'محتمل', + 'response_strategy': 'تثبيت أسعار المواد الرئيسية مع الموردين وإدراج بند تعديل الأسعار في العقد', + 'status': 'نشط', + 'risk_score': 6 + }, + { + 'risk_code': 'RC01', + 'description': 'عدم وضوح بعض بنود العقد', + 'category': 'مخاطر تعاقدية', + 'impact': 'متوسط', + 'probability': 'محتمل', + 'response_strategy': 'مراجعة العقد من قبل مستشار قانوني متخصص وطلب توضيح للبنود الغامضة قبل التوقيع', + 'status': 'نشط', + 'risk_score': 4 + } + ] + + # إضافة المخاطر الشائعة إلى المشروع + existing_risk_codes = [r['risk_code'] for r in project['risks']] + + for risk in common_risks: + # تجنب تكرار المخاطر + if risk['risk_code'] not in existing_risk_codes: + risk['id'] = len(project['risks']) + 1 + risk['created_at'] = datetime.now().strftime('%Y-%m-%d') + project['risks'].append(risk) + + def _get_risks_from_documents(self): + """استيراد المخاطر من تحليل المستندات""" + + # محاكاة لاستيراد المخاطر من تحليل المستندات + # في التطبيق الفعلي، يجب استدعاء الوظيفة المناسبة من وحدة تحليل المستندات + + document_risks = [ + { + 'risk_code': 'RD01', + 'description': 'غرامة تأخير مرتفعة تصل إلى 20% من قيمة العقد', + 'category': 'مخاطر مالية', + 'impact': 'عالي', + 'probability': 'مؤكد', + 'response_strategy': 'التفاوض على تخفيض الغرامة أو تقسيمها حسب مراحل المشروع مع وضع خطة محكمة للجدول الزمني', + 'status': 'نشط', + 'risk_score': 9, + 'created_at': datetime.now().strftime('%Y-%m-%d') + }, + { + 'risk_code': 'RD02', + 'description': 'يحق للمالك إيقاف المشروع لمدة تصل إلى 90 يوم دون تعويض', + 'category': 'مخاطر تعاقدية', + 'impact': 'عالي', + 'probability': 'محتمل', + 'response_strategy': 'طلب إضافة بند للتعويض عن التكاليف الإضافية الناتجة عن الإيقاف لفترات طويلة', + 'status': 'نشط', + 'risk_score': 6, + 'created_at': datetime.now().strftime('%Y-%m-%d') + }, + { + 'risk_code': 'RD03', + 'description': 'تحمل المقاول مسؤولية استخراج جميع التصاريح الحكومية', + 'category': 'مخاطر تنظيمية', + 'impact': 'متوسط', + 'probability': 'مؤكد', + 'response_strategy': 'حصر جميع التصاريح المطلوبة والبدء في إجراءات استخراجها مبكراً مع تخصيص فريق لمتابعتها', + 'status': 'نشط', + 'risk_score': 6, + 'created_at': datetime.now().strftime('%Y-%m-%d') + }, + { + 'risk_code': 'RD04', + 'description': 'شروط الدفعة المقدمة مقيدة بضمان بنكي بقيمة 120% من قيمة الدفعة', + 'category': 'مخاطر مالية', + 'impact': 'متوسط', + 'probability': 'مؤكد', + 'response_strategy': 'التفاوض على خفض نسبة الضمان البنكي أو تقديم ضمان شركة بدلاً من الضمان البنكي', + 'status': 'نشط', + 'risk_score': 6, + 'created_at': datetime.now().strftime('%Y-%m-%d') + } + ] + + return document_risks + + def _show_risk_summary(self, risks): + """عرض ملخص المخاطر""" + + st.markdown("#### ملخص المخاطر") + + # حساب إحصائيات المخاطر + total_risks = len(risks) + risk_levels = { + 'عالية': len([r for r in risks if r.get('risk_score', 0) >= 6]), + 'متوسطة': len([r for r in risks if 3 <= r.get('risk_score', 0) < 6]), + 'منخفضة': len([r for r in risks if r.get('risk_score', 0) < 3]) + } + + # عرض الإحصائيات + col1, col2, col3, col4 = st.columns(4) + + with col1: + st.metric("إجمالي المخاطر", total_risks) + + with col2: + st.metric("المخاطر العالية", risk_levels['عالية'], delta=f"{risk_levels['عالية']/total_risks*100:.1f}%", delta_color="inverse") + + with col3: + st.metric("المخاطر المتوسطة", risk_levels['متوسطة'], delta=f"{risk_levels['متوسطة']/total_risks*100:.1f}%", delta_color="off") + + with col4: + st.metric("المخاطر المنخفضة", risk_levels['منخفضة'], delta=f"{risk_levels['منخفضة']/total_risks*100:.1f}%", delta_color="normal") + + # عرض الرسم البياني للمخاطر + risk_level_df = pd.DataFrame({ + 'مستوى المخاطرة': list(risk_levels.keys()), + 'عدد المخاطر': list(risk_levels.values()) + }) + + fig = px.bar( + risk_level_df, + x='مستوى المخاطرة', + y='عدد المخاطر', + color='مستوى المخاطرة', + color_discrete_map={ + 'عالية': 'red', + 'متوسطة': 'orange', + 'منخفضة': 'green' + }, + title='توزيع المخاطر حسب المستوى' + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض أعلى 5 مخاطر من حيث درجة المخاطرة + st.markdown("#### أعلى 5 مخاطر") + + # ترتيب المخاطر حسب درجة المخاطرة + sorted_risks = sorted(risks, key=lambda x: x.get('risk_score', 0), reverse=True) + top_risks = sorted_risks[:5] + + # إنشاء DataFrame للعرض + if top_risks: + top_risks_data = [] + + for r in top_risks: + top_risks_data.append({ + 'رمز المخاطرة': r.get('risk_code', ''), + 'وصف المخاطرة': r.get('description', ''), + 'الفئة': r.get('category', ''), + 'التأثير': r.get('impact', ''), + 'الاحتمالية': r.get('probability', ''), + 'درجة المخاطرة': r.get('risk_score', 0) + }) + + top_risks_df = pd.DataFrame(top_risks_data) + st.dataframe(top_risks_df, use_container_width=True, hide_index=True) diff --git a/modules/risk_assessment/__init__.py b/modules/risk_assessment/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7a0af0cba3e41d42a6aff7f8b31f4a81b3ecdfdb --- /dev/null +++ b/modules/risk_assessment/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +""" +وحدة تقييم مخاطر العقود الآلي +""" \ No newline at end of file diff --git a/modules/risk_assessment/contract_risk_analyzer.py b/modules/risk_assessment/contract_risk_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..acf6398e185ff827b156e9ee43629c65c5a77b49 --- /dev/null +++ b/modules/risk_assessment/contract_risk_analyzer.py @@ -0,0 +1,2055 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة تحليل وتقييم مخاطر العقود بشكل آلي +""" + +import os +import sys +import json +import datetime +import re +import numpy as np +import pandas as pd +import streamlit as st +import plotly.express as px +import plotly.graph_objects as go +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.cluster import KMeans +from collections import Counter + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد المكونات المساعدة +from utils.helpers import create_directory_if_not_exists, format_time, get_user_info + +# تعليق استيراد Anthropic (سيتم تنفيذه لاحقًا بعد ضبط ملف anthropic) +# نستخدم نمط fallback للتعامل مع الخطأ +try: + from anthropic import Anthropic + + def analyzeBillOfQuantities(text): + return {"analysis": "تحليل فرضي لجدول الكميات", "items": [], "summary": "لا يوجد تحليل حقيقي متاح حاليًا"} + + def analyzeTermsAndConditions(text): + return {"analysis": "تحليل فرضي للشروط والأحكام", "risks": [], "summary": "لا يوجد تحليل حقيقي متاح حاليًا"} + + anthropic = None # سيتم تعيينه لاحقًا عند الحاجة +except ImportError: + # في حالة عدم وجود مكتبة أنثروبيك، نستخدم دوال فرضية + def analyzeBillOfQuantities(text): + return {"analysis": "تحليل فرضي لجدول الكميات", "items": [], "summary": "لا يوجد تحليل حقيقي متاح حاليًا"} + + def analyzeTermsAndConditions(text): + return {"analysis": "تحليل فرضي للشروط والأحكام", "risks": [], "summary": "لا يوجد تحليل حقيقي متاح حاليًا"} + + anthropic = None + + +class ContractRiskAnalyzer: + """فئة تحليل وتقييم المخاطر في العقود بشكل آلي""" + + def __init__(self): + """تهيئة محلل مخاطر العقود""" + self.risk_data_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'data', 'risk_assessment') + create_directory_if_not_exists(self.risk_data_dir) + + # تعريف أنواع المخاطر + self.risk_categories = { + "legal": "قانونية", + "financial": "مالية", + "operational": "تشغيلية", + "technical": "فنية", + "compliance": "امتثال", + "environmental": "بيئية", + "safety": "سلامة", + "schedule": "جدولة", + "resource": "موارد", + "quality": "جودة", + "scope": "نطاق العمل", + "stakeholder": "أصحاب المصلحة", + "commercial": "تجارية", + "contractual": "تعاقدية", + "regulatory": "تنظيمية" + } + + # تعريف قائمة المصطلحات الخطرة في العقود + self.risky_terms = { + "legal": [ + "تعديل العقد", "فسخ العقد", "إنهاء الاتفاقية", "فض المنازعات", "شرط جزائي", + "تحكيم", "قاهرة", "ظروف قاهرة", "التقاضي", "الإخلال بالعقد", "المنازعات", + "الدفع", "الضمان", "الولاية القضائية", "التعويض" + ], + "financial": [ + "غرامة تأخير", "غرامات", "دفعة مقدمة", "دفعة نهائية", "ضمان", "تأمين", + "تسعير", "سعر", "خصم", "تكاليف إضافية", "تعديل سعر", "زيادة سعر", + "خسارة", "ربح", "هامش", "تحمل التكاليف", "تمويل", "مخاطر مالية", "ضريبة" + ], + "operational": [ + "تأخير", "عدم التسليم", "توقف", "انقطاع", "عطل", "خلل", "تعطل", + "عمالة", "أيدي عاملة", "موارد بشرية", "مناولة", "تصاريح", "لوجستيك", + "مخزون", "سلسلة توريد", "عمليات" + ], + "technical": [ + "مواصفات", "معايير", "شروط فنية", "كفاءة", "جودة", "أداء", + "اختبار", "فحص", "تقنية", "تكنولوجيا", "تشغيل", "تركيب", "صيانة", + "تصميم", "هندسة", "قدرة" + ], + "compliance": [ + "اللوائح", "القوانين", "التشريعات", "الامتثال", "المعايير", "الترخيص", + "التصريح", "الموافقة", "الالتزام", "التنظيم", "الشهادة" + ], + "environmental": [ + "بيئي", "بيئة", "تلوث", "تصريف", "نفايات", "انبعاثات", "موارد طبيعية", + "تأثير بيئي", "استدامة", "تعويض بيئي", "ضرر بيئي", "مخلفات" + ], + "safety": [ + "سلامة", "أمان", "حوادث", "إصابات", "مخاطر صحية", "صحة مهنية", + "وقاية", "حماية", "إجراءات أمان", "تأمين سلامة", "مخاطر السلامة" + ], + "schedule": [ + "تأخير", "تمديد", "مدة", "جدول زمني", "موعد نهائي", "تسليم", + "مراحل", "مواعيد", "وقت", "فترة", "عاجل", "سريع", "فوري" + ], + "resource": [ + "مواد", "معدات", "أدوات", "آلات", "عمالة", "كوادر", "فريق", + "موارد بشرية", "توفير", "تأمين", "استقدام", "نقص", "عجز", "كفاية" + ], + "quality": [ + "جودة", "ضمان الجودة", "معايير", "مواصفات", "أداء", "رداءة", + "ضعف", "خلل", "عيب", "إصلاح", "صيانة", "استبدال", "رفض" + ], + "scope": [ + "نطاق العمل", "تغيير النطاق", "توسيع", "تقليص", "تعديل", "إضافة", + "أعمال إضافية", "تغييرات", "أوامر تغيير", "متطلبات جديدة" + ], + "stakeholder": [ + "طرف ثالث", "مالك", "عميل", "المقاول", "المورد", "الاستشاري", + "المشرف", "مدير المشروع", "المقاول من الباطن", "الشريك", "مصلحة" + ], + "commercial": [ + "منافسة", "سوق", "سعر", "عرض", "طلب", "تجاري", "أعمال", + "استثمار", "عائد", "ربح", "خسارة", "سمعة", "علامة تجارية" + ], + "contractual": [ + "بند", "شرط", "مادة", "اتفاقية", "عقد", "ملحق", "تعديل", + "تنازل", "تعهد", "التزام", "مسؤولية", "واجب", "حق" + ], + "regulatory": [ + "تنظيمي", "حكومي", "رسمي", "لائحة", "قانون", "تشريع", + "ترخيص", "تصريح", "موافقة", "امتثال", "اشتراطات", "متطلبات" + ] + } + + # نموذج تصنيف المخاطر + self.vectorizer = TfidfVectorizer(max_features=1000, stop_words='english') + self.kmeans = KMeans(n_clusters=len(self.risk_categories), random_state=42) + + # الصيغ والمصطلحات المتعلقة بالمخاطر التعاقدية + self.contract_risk_patterns = { + "unlimited_liability": [ + r"مسؤولية غير محدودة", + r"مسؤولية كاملة", + r"المسؤولية الكاملة", + r"دون تحديد للمسؤولية", + r"دون سقف للمسؤولية", + r"المسؤولية المطلقة", + r"التعويض عن كافة الأضرار" + ], + "payment_delay": [ + r"(\d+)\s*يوم\s*من تاريخ\s*الفاتورة", + r"(\d+)\s*يوم\s*عمل من تاريخ\s*الفاتورة", + r"(\d+)\s*يوم\s*للدفع", + r"خلال\s*(\d+)\s*يوم", + r"الدفع خلال\s*(\d+)\s*" + ], + "excessive_penalties": [ + r"غرامة تأخير بنسبة\s*(\d+)%", + r"غرامة تأخير قدرها\s*(\d+)%", + r"غرامة يومية\s*(\d+)%", + r"غرامة اسبوعية\s*(\d+)%", + r"غرامة شهرية\s*(\d+)%" + ], + "unilateral_termination": [ + r"يحق للطرف الأول إنهاء العقد", + r"يحق للعميل إنهاء العقد", + r"للعميل الحق في إنهاء", + r"للطرف الأول الحق في إنهاء", + r"إنهاء العقد من طرف واحد", + r"إنهاء دون إبداء أسباب" + ], + "unrealistic_deadlines": [ + r"التسليم خلال\s*(\d+)\s*يوم", + r"مدة التنفيذ\s*(\d+)\s*يوم", + r"الانتهاء خلال\s*(\d+)\s*يوم", + r"إنجاز المشروع خلال\s*(\d+)\s*أسبوع" + ], + "scope_creep": [ + r"أعمال إضافية", + r"تعديلات على النطاق", + r"توسيع نطاق العمل", + r"إضافة متطلبات", + r"تغيير المواصفات", + r"أعمال غير متوقعة" + ], + "indemnification": [ + r"تعويض الطرف الأول", + r"تعويض العميل", + r"تعويض كامل", + r"تعويض شامل", + r"التعويض عن كافة الأضرار", + r"التعويض عن أي خسائر" + ], + "change_control": [ + r"التغييرات بدون تكلفة إضافية", + r"تعديلات دون زيادة السعر", + r"تغييرات دون مقابل", + r"تعديلات لا تؤثر على السعر" + ], + "warranty_period": [ + r"ضمان لمدة\s*(\d+)\s*شهر", + r"ضمان لمدة\s*(\d+)\s*سنة", + r"فترة ضمان\s*(\d+)\s*شهر", + r"فترة الضمان\s*(\d+)\s*شهر", + r"فترة الصيانة\s*(\d+)\s*شهر" + ], + "dispute_resolution": [ + r"المحاكم المختصة", + r"محاكم[^.]*للنظر في المنازعات", + r"تسوية النزاعات", + r"فض المنازعات", + r"التحكيم", + r"لجنة تحكيم" + ], + "force_majeure": [ + r"القوة القاهرة", + r"الظروف القاهرة", + r"ظروف خارجة عن الإرادة", + r"أحداث غير متوقعة", + r"أسباب خارجة عن السيطرة" + ], + "regulatory_compliance": [ + r"الالتزام بالقوانين", + r"الالتزام بالأنظمة", + r"الالتزام بالتشريعات", + r"الالتزام باللوائح", + r"مراعاة القوانين", + r"وفقاً للقوانين", + r"طبقاً للأنظمة", + ], + "intellectual_property": [ + r"الملكية الفكرية", + r"حقوق الملكية", + r"حقوق الطبع", + r"حقوق النشر", + r"براءات الاختراع", + r"التصاميم", + r"العلامات التجارية" + ], + "confidentiality": [ + r"سرية المعلومات", + r"المعلومات السرية", + r"عدم الإفصاح", + r"الحفاظ على السرية", + r"عدم الكشف", + r"معلومات سرية" + ], + "insurance_requirements": [ + r"متطلبات التأمين", + r"بوليصة تأمين", + r"تأمين ضد المسؤولية", + r"تأمين ضد المخاطر", + r"تأمين شامل", + r"تأمين المشروع" + ] + } + + # تعريف مستويات خطورة المخاطر + self.severity_levels = { + "low": { + "name": "منخفضة", + "color": "#00b894", # أخضر + "score_range": (0, 33) + }, + "medium": { + "name": "متوسطة", + "color": "#fdcb6e", # أصفر + "score_range": (34, 66) + }, + "high": { + "name": "عالية", + "color": "#d63031", # أحمر + "score_range": (67, 100) + } + } + + # أوزان أنواع المخاطر (الأهمية النسبية) + self.risk_weights = { + "legal": 0.9, + "financial": 0.8, + "operational": 0.7, + "technical": 0.6, + "compliance": 0.8, + "environmental": 0.6, + "safety": 0.7, + "schedule": 0.6, + "resource": 0.5, + "quality": 0.7, + "scope": 0.7, + "stakeholder": 0.5, + "commercial": 0.6, + "contractual": 0.9, + "regulatory": 0.8 + } + + def scan_contract_text(self, contract_text, title=""): + """فحص نص العقد لاستخراج المخاطر المحتملة""" + if not contract_text: + return { + "title": title or "عقد غير معروف", + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "risks": [], + "overall_score": 0, + "overall_severity": "منخفضة", + "summary": "لم يتم توفير نص للتحليل." + } + + # تحويل النص إلى أحرف صغيرة للفحص + text_lower = contract_text.lower() + + risks = [] + risk_id = 1 + + # فحص كل فئة مخاطر والبحث عن المصطلحات المرتبطة بها + for category, category_terms in self.risky_terms.items(): + category_risks = [] + + for term in category_terms: + # البحث عن المصطلح في النص + occurrences = self._find_term_occurrences(contract_text, term) + + if occurrences: + for occurrence in occurrences: + context = self._extract_context(contract_text, occurrence, window=100) + # تحديد مستوى الخطورة بناءً على السياق + severity = self._determine_severity_from_context(context, category) + + category_risks.append({ + "id": risk_id, + "term": term, + "category": category, + "category_ar": self.risk_categories[category], + "context": context, + "severity": severity, + "impact": self._determine_impact(category, severity), + "recommendation": self._generate_recommendation(category, term, severity) + }) + risk_id += 1 + + # إضافة مخاطر الفئة إلى القائمة الرئيسية + risks.extend(category_risks) + + # فحص صيغ المخاطر الإضافية في العقد + pattern_risks = self._scan_for_risk_patterns(contract_text, risk_id) + risks.extend(pattern_risks) + + # حساب درجة المخاطر الإجمالية + overall_score = self._calculate_overall_risk_score(risks) + + # تحديد مستوى الخطورة الإجمالية + overall_severity = self._determine_overall_severity(overall_score) + + # توليد ملخص للمخاطر + summary = self._generate_risk_summary(risks, overall_score, overall_severity) + + # إنشاء تقرير المخاطر + risk_report = { + "title": title or "عقد غير معروف", + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "risks": risks, + "overall_score": overall_score, + "overall_severity": overall_severity["name"], + "severity_color": overall_severity["color"], + "summary": summary + } + + # حفظ تقرير المخاطر + if title: + self._save_risk_report(risk_report, title) + + return risk_report + + def _find_term_occurrences(self, text, term): + """البحث عن مواضع ظهور المصطلح في النص""" + occurrences = [] + start = 0 + + while True: + start = text.find(term, start) + if start == -1: + break + occurrences.append(start) + start += len(term) + + return occurrences + + def _extract_context(self, text, position, window=100): + """استخراج سياق النص حول موضع معين""" + start = max(0, position - window // 2) + end = min(len(text), position + window // 2) + + # البحث عن بداية الجملة + while start > 0 and text[start] not in ['.', '!', '؟', '?', '\n']: + start -= 1 + + if start > 0: + start += 1 # تجاوز علامة الترقيم + + # البحث عن نهاية الجملة + while end < len(text) - 1 and text[end] not in ['.', '!', '؟', '?', '\n']: + end += 1 + + if end < len(text) - 1: + end += 1 # تضمين علامة الترقيم + + return text[start:end].strip() + + def _determine_severity_from_context(self, context, category): + """تحديد مستوى خطورة المخاطر بناءً على السياق""" + # كلمات تزيد من مستوى الخطورة + high_severity_indicators = [ + "حرج", "خطير", "ضروري", "إلزامي", "يجب", "مطلوب", "ضمان", + "تعويض", "غرامة", "يلتزم", "مسؤولية", "خسارة", "ضرر", + "تأخير", "مخالفة", "إخلال", "فسخ", "إنهاء", "تعديل" + ] + + # كلمات تقلل من مستوى الخطورة + low_severity_indicators = [ + "قد", "يمكن", "يجوز", "يحتمل", "محتمل", "ممكن", "اختياري", + "تقديري", "بالتوافق", "بالاتفاق", "مناسب", "معقول", "بحسب" + ] + + # حساب عدد المؤشرات + high_count = sum(1 for indicator in high_severity_indicators if indicator in context) + low_count = sum(1 for indicator in low_severity_indicators if indicator in context) + + # تحديد درجة الخطورة + if high_count > low_count * 2: + return "high" + elif high_count > low_count: + return "medium" + else: + return "low" + + def _determine_impact(self, category, severity): + """تحديد تأثير المخاطر بناءً على الفئة ومستوى الخطورة""" + impact_descriptions = { + "legal": { + "high": "قد يؤدي إلى دعاوى قضائية ومسؤولية قانونية كبيرة", + "medium": "قد يتطلب تعديلات قانونية أو مفاوضات إضافية", + "low": "مخاطر قانونية محدودة يمكن معالجتها بسهولة" + }, + "financial": { + "high": "مخاطر مالية كبيرة قد تؤثر على ربحية المشروع بشكل كبير", + "medium": "قد يؤدي إلى زيادة التكاليف أو تقليل الهوامش", + "low": "تأثير مالي محدود يمكن استيعابه" + }, + "operational": { + "high": "قد يعيق تنفيذ المشروع بشكل كامل", + "medium": "قد يؤثر على كفاءة العمليات ويتطلب خطط بديلة", + "low": "تأثير محدود على العمليات اليومية" + }, + "technical": { + "high": "قد يمنع تحقيق المتطلبات الفنية الأساسية", + "medium": "يتطلب حلول فنية إضافية أو تعديلات", + "low": "يمكن معالجته من خلال التعديلات الفنية البسيطة" + }, + "compliance": { + "high": "قد يؤدي إلى عدم الامتثال للوائح الهامة", + "medium": "يتطلب تعديلات للامتثال للمتطلبات التنظيمية", + "low": "يمكن حله من خلال تدابير امتثال بسيطة" + }, + "environmental": { + "high": "مخاطر بيئية كبيرة قد تؤدي إلى عقوبات أو تأخيرات", + "medium": "يتطلب إجراءات وقائية إضافية للحماية البيئية", + "low": "تأثير بيئي محدود يمكن إدارته" + }, + "safety": { + "high": "مخاطر سلامة حرجة قد تهدد سلامة العاملين", + "medium": "يتطلب إجراءات سلامة إضافية وتدريب", + "low": "مخاطر سلامة يمكن معالجتها من خلال الإجراءات القياسية" + }, + "schedule": { + "high": "قد يؤدي إلى تأخيرات كبيرة في المشروع", + "medium": "قد يؤثر على بعض مراحل الجدول الزمني", + "low": "تأثير محدود على الجدول الزمني يمكن استيعابه" + }, + "resource": { + "high": "نقص حاد في الموارد الأساسية للمشروع", + "medium": "قد يتطلب موارد إضافية أو بديلة", + "low": "يمكن إدارته من خلال تخطيط الموارد المتاحة" + }, + "quality": { + "high": "قد يؤدي إلى مشاكل جودة خطيرة تؤثر على قبول المشروع", + "medium": "يتطلب إجراءات ضمان جودة إضافية", + "low": "تأثير محدود على الجودة يمكن معالجته" + }, + "scope": { + "high": "تغييرات جوهرية في نطاق العمل قد تؤثر على المشروع بأكمله", + "medium": "يتطلب تعديلات في بعض جوانب نطاق العمل", + "low": "تغييرات بسيطة في النطاق يمكن استيعابها" + }, + "stakeholder": { + "high": "قد يؤثر سلباً على العلاقات مع أصحاب المصلحة الرئيسيين", + "medium": "يتطلب إدارة توقعات أصحاب المصلحة", + "low": "تأثير محدود على رضا أصحاب المصلحة" + }, + "commercial": { + "high": "مخاطر تجارية كبيرة قد تؤثر على العلاقات التجارية الرئيسية", + "medium": "قد يتطلب إعادة التفاوض على بعض الشروط التجارية", + "low": "تأثير تجاري محدود يمكن إدارته" + }, + "contractual": { + "high": "بنود تعاقدية مجحفة قد تؤثر على التزامات وحقوق الأطراف", + "medium": "يتطلب مراجعة قانونية وتعديل بعض البنود", + "low": "قضايا تعاقدية بسيطة يمكن توضيحها" + }, + "regulatory": { + "high": "قد يؤدي إلى مخالفة لوائح تنظيمية هامة", + "medium": "يتطلب تغييرات للامتثال للمتطلبات التنظيمية", + "low": "متطلبات تنظيمية يمكن تلبيتها بسهولة" + } + } + + return impact_descriptions.get(category, {}).get(severity, "تأثير غير محدد") + + def _generate_recommendation(self, category, term, severity): + """توليد توصيات لمعالجة المخاطر""" + recommendations = { + "legal": { + "high": "مراجعة قانونية شاملة من محامي متخصص وإعادة التفاوض على البنود المتعلقة بـ'{term}'", + "medium": "مراجعة قانونية والتأكد من الصياغة الدقيقة للبنود المتعلقة بـ'{term}'", + "low": "مراقبة البنود المتعلقة بـ'{term}' أثناء تنفيذ العقد" + }, + "financial": { + "high": "إعادة التفاوض على الشروط المالية والتأكد من وجود مخصصات كافية لتغطية المخاطر المتعلقة بـ'{term}'", + "medium": "وضع خطة احتياطية لإدارة التكاليف المرتبطة بـ'{term}'", + "low": "متابعة الجوانب المالية المتعلقة بـ'{term}' بشكل دوري" + }, + "operational": { + "high": "وضع خطة تفصيلية لإدارة المخاطر التشغيلية المتعلقة بـ'{term}' وتوفير بدائل", + "medium": "تطوير إجراءات للتعامل مع المشكلات التشغيلية المتعلقة بـ'{term}'", + "low": "متابعة العمليات المتعلقة بـ'{term}' بشكل منتظم" + }, + "technical": { + "high": "الاستعانة بخبراء فنيين متخصصين لمراجعة المتطلبات المتعلقة بـ'{term}'", + "medium": "إجراء مراجعة فنية للتأكد من قابلية تنفيذ المتطلبات المتعلقة بـ'{term}'", + "low": "التأكد من وضوح المواصفات الفنية المتعلقة بـ'{term}'" + }, + "compliance": { + "high": "مراجعة متخصصة للتأكد من الامتثال للوائح المتعلقة بـ'{term}' وإجراء التعديلات اللازمة", + "medium": "وضع إجراءات للتأكد من الامتثال المستمر للمتطلبات المتعلقة بـ'{term}'", + "low": "متابعة متطلبات الامتثال المتعلقة بـ'{term}' بشكل دوري" + }, + "environmental": { + "high": "إجراء تقييم بيئي شامل والتأكد من وجود خطط للتعامل مع المخاطر البيئية المتعلقة بـ'{term}'", + "medium": "مراجعة الإجراءات البيئية المتعلقة بـ'{term}' والتأكد من كفايتها", + "low": "متابعة الجوانب البيئية المتعلقة بـ'{term}' أثناء تنفيذ المشروع" + }, + "safety": { + "high": "وضع خطة سلامة شاملة ومراجعتها من قبل متخصصين للتعامل مع المخاطر المتعلقة بـ'{term}'", + "medium": "مراجعة إجراءات السلامة الحالية وتعزيزها للتعامل مع المخاطر المتعلقة بـ'{term}'", + "low": "التأكد من تطبيق إجراءات السلامة القياسية المتعلقة بـ'{term}'" + }, + "schedule": { + "high": "إعادة تقييم الجدول الزمني بشكل شامل ووضع خطط بديلة للتعامل مع البنود المتعلقة بـ'{term}'", + "medium": "وضع هوامش زمنية كافية للتعامل مع التأخيرات المحتملة المتعلقة بـ'{term}'", + "low": "مراقبة الجدول الزمني بشكل منتظم فيما يتعلق بـ'{term}'" + }, + "resource": { + "high": "وضع خطة شاملة لتأمين الموارد اللازمة والبدائل المتعلقة بـ'{term}'", + "medium": "تحديد مصادر بديلة للموارد المتعلقة بـ'{term}'", + "low": "مراقبة توافر الموارد المتعلقة بـ'{term}' بشكل منتظم" + }, + "quality": { + "high": "وضع خطة ضمان جودة شاملة والتأكد من وجود معايير واضحة للجوانب المتعلقة بـ'{term}'", + "medium": "تعزيز إجراءات ضمان الجودة للجوانب المتعلقة بـ'{term}'", + "low": "متابعة معايير الجودة المتعلقة بـ'{term}' بشكل منتظم" + }, + "scope": { + "high": "توثيق نطاق العمل بشكل تفصيلي ووضع إجراءات واضحة للتعامل مع التغييرات المتعلقة بـ'{term}'", + "medium": "وضع آلية للتحكم في التغييرات المتعلقة بـ'{term}'", + "low": "مراقبة نطاق العمل بشكل منتظم فيما يتعلق بـ'{term}'" + }, + "stakeholder": { + "high": "وضع خطة تواصل شاملة مع أصحاب المصلحة للتعامل مع القضايا المتعلقة بـ'{term}'", + "medium": "تعزيز التواصل مع أصحاب المصلحة المعنيين بـ'{term}'", + "low": "متابعة توقعات وملاحظات أصحاب المصلحة فيما يتعلق بـ'{term}'" + }, + "commercial": { + "high": "إعادة التفاوض على الشروط التجارية المتعلقة بـ'{term}' والتأكد من تحقيق توازن المصالح", + "medium": "مراجعة الشروط التجارية المتعلقة بـ'{term}' والتأكد من وضوحها", + "low": "مراقبة تنفيذ الشروط التجارية المتعلقة بـ'{term}'" + }, + "contractual": { + "high": "مراجعة قانونية شاملة للبنود التعاقدية المتعلقة بـ'{term}' وإعادة التفاوض عند الضرورة", + "medium": "توضيح وتحسين صياغة البنود المتعلقة بـ'{term}'", + "low": "التأكد من فهم الالتزامات التعاقدية المتعلقة بـ'{term}'" + }, + "regulatory": { + "high": "الاستعانة بمستشار متخصص للتأكد من الامتثال للمتطلبات التنظيمية المتعلقة بـ'{term}'", + "medium": "مراجعة المتطلبات التنظيمية الحالية والمستقبلية المتعلقة بـ'{term}'", + "low": "متابعة التغييرات في المتطلبات التنظيمية المتعلقة بـ'{term}'" + } + } + + recommendation_template = recommendations.get(category, {}).get(severity, "مراجعة البنود المتعلقة بـ'{term}'") + return recommendation_template.replace('{term}', term) + + def _scan_for_risk_patterns(self, contract_text, risk_id_start): + """فحص النص بحثاً عن صيغ المخاطر المحددة مسبقاً""" + risk_id = risk_id_start + pattern_risks = [] + + for pattern_type, patterns in self.contract_risk_patterns.items(): + for pattern in patterns: + matches = re.finditer(pattern, contract_text) + + for match in matches: + match_text = match.group(0) + context = self._extract_context(contract_text, match.start(), window=150) + severity = self._determine_pattern_severity(pattern_type, match_text) + category = self._map_pattern_to_category(pattern_type) + + pattern_risks.append({ + "id": risk_id, + "term": match_text, + "category": category, + "category_ar": self.risk_categories[category], + "pattern_type": pattern_type, + "context": context, + "severity": severity, + "impact": self._determine_pattern_impact(pattern_type, severity), + "recommendation": self._generate_pattern_recommendation(pattern_type, match_text, severity) + }) + risk_id += 1 + + return pattern_risks + + def _determine_pattern_severity(self, pattern_type, match_text): + """تحديد مستوى خطورة المخاطر بناءً على نوع الصيغة ومحتواها""" + severity_rules = { + "unlimited_liability": "high", + "payment_delay": lambda text: "high" if any(int(n) > 60 for n in re.findall(r'(\d+)', text)) else "medium" if any(int(n) > 30 for n in re.findall(r'(\d+)', text)) else "low", + "excessive_penalties": lambda text: "high" if any(int(n) > 1 for n in re.findall(r'(\d+)', text)) else "medium" if any(int(n) > 0.5 for n in re.findall(r'(\d+)', text)) else "low", + "unilateral_termination": "high", + "unrealistic_deadlines": lambda text: "high" if any(int(n) < 30 for n in re.findall(r'(\d+)', text)) else "medium" if any(int(n) < 60 for n in re.findall(r'(\d+)', text)) else "low", + "scope_creep": "medium", + "indemnification": "high", + "change_control": "high", + "warranty_period": lambda text: "high" if any(int(n) > 24 for n in re.findall(r'(\d+)', text)) else "medium" if any(int(n) > 12 for n in re.findall(r'(\d+)', text)) else "low", + "dispute_resolution": "medium", + "force_majeure": "medium", + "regulatory_compliance": "medium", + "intellectual_property": "high", + "confidentiality": "medium", + "insurance_requirements": "medium" + } + + rule = severity_rules.get(pattern_type, "medium") + + if callable(rule): + return rule(match_text) + else: + return rule + + def _map_pattern_to_category(self, pattern_type): + """تعيين نوع الصيغة إلى فئة المخاطر المناسبة""" + pattern_category_map = { + "unlimited_liability": "legal", + "payment_delay": "financial", + "excessive_penalties": "financial", + "unilateral_termination": "contractual", + "unrealistic_deadlines": "schedule", + "scope_creep": "scope", + "indemnification": "legal", + "change_control": "scope", + "warranty_period": "quality", + "dispute_resolution": "legal", + "force_majeure": "contractual", + "regulatory_compliance": "compliance", + "intellectual_property": "legal", + "confidentiality": "contractual", + "insurance_requirements": "financial" + } + + return pattern_category_map.get(pattern_type, "contractual") + + def _determine_pattern_impact(self, pattern_type, severity): + """تحديد تأثير المخاطر بناءً على نوع الصيغة ومستوى الخطورة""" + pattern_impact = { + "unlimited_liability": { + "high": "يمكن أن يعرض الشركة لمسؤولية مالية وقانونية غير محدودة", + "medium": "قد يؤدي إلى مسؤولية مالية كبيرة غير متوقعة", + "low": "زيادة محتملة في المسؤولية القانونية" + }, + "payment_delay": { + "high": "تأخر كبير في الدفعات قد يؤثر على التدفق النقدي والسيولة", + "medium": "قد يتسبب في ضغط على التدفق النقدي", + "low": "تأثير محدود على التدفق النقدي" + }, + "excessive_penalties": { + "high": "غرامات تأخير مرتفعة قد تؤثر بشكل كبير على ربحية المشروع", + "medium": "غرامات معتدلة قد تقلل من هامش الربح", + "low": "غرامات محدودة يمكن إدارتها من خلال الجدولة الدقيقة" + }, + "unilateral_termination": { + "high": "إمكانية إنهاء العقد من طرف واحد دون تعويض مناسب", + "medium": "شروط إنهاء غير متوازنة قد تتطلب إعادة التفاوض", + "low": "بنود إنهاء تحتاج إلى مراقبة وتوثيق" + }, + "unrealistic_deadlines": { + "high": "مواعيد نهائية غير واقعية قد تؤدي إلى فشل المشروع أو غرامات كبيرة", + "medium": "جدول زمني ضيق يتطلب موارد إضافية وإدارة مكثفة", + "low": "مواعيد نهائية تحتاج إلى تخطيط دقيق" + }, + "scope_creep": { + "high": "توسع غير محدود في نطاق العمل دون تعديل السعر أو الجدول الزمني", + "medium": "تغييرات محتملة في النطاق تتطلب إدارة دقيقة", + "low": "بعض التعديلات المحتملة على النطاق يمكن إدارتها" + }, + "indemnification": { + "high": "التزامات تعويض واسعة النطاق قد تؤدي إلى مسؤولية غير محدودة", + "medium": "شروط تعويض تحتاج إلى مراجعة قانونية", + "low": "التزامات تعويض معقولة تحتاج إلى متابعة" + }, + "change_control": { + "high": "عدم وجود آلية واضحة للتحكم في التغييرات وتأثيرها على التكلفة", + "medium": "آلية تغيير غير كافية قد تؤدي إلى نزاعات", + "low": "إجراءات تغيير تحتاج إلى تحسين" + }, + "warranty_period": { + "high": "فترة ضمان طويلة غير متناسبة مع طبيعة المشروع", + "medium": "فترة ضمان تتطلب موارد إضافية للدعم", + "low": "فترة ضمان معقولة تحتاج إلى تخطيط" + }, + "dispute_resolution": { + "high": "آليات غير مناسبة لحل النزاعات قد تؤدي إلى إجراءات مكلفة", + "medium": "آليات حل النزاعات تحتاج إلى توضيح", + "low": "شروط حل النزاعات تحتاج إلى مراجعة" + }, + "force_majeure": { + "high": "تعريف ضيق للقوة القاهرة قد يؤدي إلى مسؤولية غير متوقعة", + "medium": "بنود القوة القاهرة تحتاج إلى توضيح", + "low": "شروط القوة القاهرة معقولة ولكن تحتاج إلى مراقبة" + }, + "regulatory_compliance": { + "high": "متطلبات امتثال صارمة قد تزيد التكاليف أو المسؤولية", + "medium": "التزامات الامتثال تحتاج إلى موارد إضافية", + "low": "متطلبات امتثال معقولة تحتاج إلى مراقبة" + }, + "intellectual_property": { + "high": "نقل واسع لحقوق الملكية الفكرية دون تعويض مناسب", + "medium": "شروط الملكية الفكرية تحتاج إلى توضيح وتعديل", + "low": "بنود الملكية الفكرية تحتاج إلى مراجعة" + }, + "confidentiality": { + "high": "التزامات سرية واسعة وطويلة الأمد قد تقيد النشاط المستقبلي", + "medium": "التزامات السرية تحتاج إلى تحديد نطاق ومدة", + "low": "شروط السرية معقولة ولكن تحتاج إلى مراقبة" + }, + "insurance_requirements": { + "high": "متطلبات تأمين مرتفعة قد تزيد التكاليف بشكل كبير", + "medium": "متطلبات التأمين تحتاج إلى مراجعة للتأكد من التناسب", + "low": "متطلبات تأمين معقولة تحتاج إلى التحقق من التوافر" + } + } + + return pattern_impact.get(pattern_type, {}).get(severity, "تأثير غير محدد") + + def _generate_pattern_recommendation(self, pattern_type, match_text, severity): + """توليد توصيات لمعالجة المخاطر بناءً على نوع الصيغة""" + pattern_recommendations = { + "unlimited_liability": { + "high": "إعادة التفاوض على بنود المسؤولية وتحديد سقف للتعويضات يتناسب مع قيمة العقد", + "medium": "وضع حدود واضحة للمسؤولية وطلب تعديل البنود المتعلقة بها", + "low": "مراجعة بنود المسؤولية والتأكد من وجود تغطية تأمينية مناسبة" + }, + "payment_delay": { + "high": "إعادة التفاوض على شروط الدفع وتقليل فترة السداد، مع إضافة فوائد تأخير", + "medium": "وضع آلية واضحة لمتابعة المدفوعات وتحديد إجراءات التصعيد في حالة التأخر", + "low": "مراقبة مواعيد الدفع والتأكد من إصدار الفواتير في الوقت المناسب" + }, + "excessive_penalties": { + "high": "إعادة التفاوض على نسب وآليات غرامات التأخير وربطها بالضرر الفعلي", + "medium": "وضع حد أقصى للغرامات ووضع خطة لتجنب التأخير", + "low": "مراقبة تقدم العمل بدقة للالتزام بالجدول الزمني" + }, + "unilateral_termination": { + "high": "تعديل بنود الإنهاء لتكون متوازنة وتضمين تعويض مناسب في حالة الإنهاء", + "medium": "وضع شروط واضحة للإنهاء من كلا الطرفين وتحديد آلية التعويض", + "low": "التأكد من وجود خطة للتعامل مع حالات الإنهاء المحتملة" + }, + "unrealistic_deadlines": { + "high": "إعادة التفاوض على الجدول الزمني ليكون واقعياً بناءً على تقييم دقيق للموارد والقدرات", + "medium": "وضع خطة تفصيلية للتنفيذ مع تحديد المراحل الحرجة وتوفير موارد إضافية", + "low": "مراقبة الجدول الزمني بشكل مستمر وتحديد المخاطر المحتملة" + }, + "scope_creep": { + "high": "تحديد نطاق العمل بدقة ووضع إجراءات صارمة لإدارة التغييرات مع ربطها بالتكلفة والوقت", + "medium": "وضع آلية واضحة لإدارة التغييرات والتأكد من توثيق نطاق العمل بشكل تفصيلي", + "low": "مراقبة نطاق العمل والتأكد من موافقة جميع الأطراف على أي تغييرات" + }, + "indemnification": { + "high": "إعادة التفاوض على بنود التعويض لتكون متوازنة ومحددة بمبلغ يتناسب مع قيمة العقد", + "medium": "تحديد نطاق التعويض وربطه بالأضرار المباشرة والفعلية", + "low": "مراجعة بنود التعويض والتأكد من وجود تغطية تأمينية مناسبة" + }, + "change_control": { + "high": "وضع آلية واضحة وصارمة لإدارة التغييرات مع تحديد التأثير على التكلفة والوقت", + "medium": "تحسين إجراءات إدارة التغييرات والتأكد من توثيق جميع التغييرات", + "low": "مراقبة التغييرات والتأكد من الحصول على موافقة مكتوبة قبل التنفيذ" + }, + "warranty_period": { + "high": "إعادة التفاوض على فترة الضمان لتكون متناسبة مع طبيعة المشروع والمعايير الصناعية", + "medium": "تحديد نطاق الضمان بوضوح وتخصيص موارد كافية للدعم خلال فترة الضمان", + "low": "وضع خطة لإدارة التزامات الضمان والتأكد من توثيق حالة التسليم" + }, + "dispute_resolution": { + "high": "تعديل آليات حل النزاعات لتشمل التفاوض والوساطة قبل اللجوء للتحكيم أو القضاء", + "medium": "توضيح إجراءات حل النزاعات وتحديد الاختصاص القضائي والقانون الواجب التطبيق", + "low": "مراجعة آليات حل النزاعات والتأكد من فهم الإجراءات المتبعة" + }, + "force_majeure": { + "high": "توسيع تعريف القوة القاهرة ليشمل الحالات المحتملة وتحديد آلية واضحة للإخطار والتعامل", + "medium": "توضيح إجراءات الإخطار والإجراءات المتبعة في حالات القوة القاهرة", + "low": "مراجعة بنود القوة القاهرة والتأكد من شمولها للحالات المحتملة" + }, + "regulatory_compliance": { + "high": "تحديد مسؤوليات كل طرف بوضوح فيما يتعلق بالالتزامات التنظيمية والحصول على المشورة القانونية", + "medium": "مراجعة متطلبات الامتثال والتأكد من القدرة على تلبيتها", + "low": "متابعة التغييرات في المتطلبات التنظيمية والتأكد من الالتزام المستمر" + }, + "intellectual_property": { + "high": "إعادة التفاوض على بنود الملكية الفكرية لحماية حقوق الشركة والحصول على تعويض مناسب", + "medium": "توضيح حقوق الملكية الفكرية لكل طرف وتحديد نطاق الاستخدام المسموح به", + "low": "مراجعة بنود الملكية الفكرية والتأكد من حماية الأصول الفكرية للشركة" + }, + "confidentiality": { + "high": "تحديد نطاق ومدة التزامات السرية بشكل واضح ومتوازن لتجنب القيود غير الضرورية", + "medium": "توضيح نطاق المعلومات السرية وتحديد مدة معقولة للالتزام بالسرية", + "low": "مراجعة التزامات السرية والتأكد من إمكانية الالتزام بها" + }, + "insurance_requirements": { + "high": "إعادة التفاوض على متطلبات التأمين لتكون متناسبة مع طبيعة وحجم المشروع والمخاطر الفعلية", + "medium": "التحقق من توافر وتكلفة التغطية التأمينية المطلوبة والتفاوض على تعديلها إذا لزم الأمر", + "low": "التأكد من توافر التغطية التأمينية المطلوبة والحفاظ على سريانها" + } + } + + return pattern_recommendations.get(pattern_type, {}).get(severity, "مراجعة وتعديل البنود المتعلقة بهذه المخاطر") + + def _calculate_overall_risk_score(self, risks): + """حساب درجة المخاطر الإجمالية""" + if not risks: + return 0 + + # تحويل مستويات الخطورة إلى قيم عددية + severity_scores = {"low": 25, "medium": 50, "high": 90} + + # حساب مجموع الأوزان ودرجات المخاطر المرجحة + total_weight = 0 + weighted_score_sum = 0 + + # تجميع المخاطر حسب الفئة + risk_categories = {} + for risk in risks: + category = risk["category"] + severity = risk["severity"] + + if category not in risk_categories: + risk_categories[category] = [] + + risk_categories[category].append(severity_scores[severity]) + + # حساب متوسط درجة المخاطرة لكل فئة وترجيحها بالوزن + for category, scores in risk_categories.items(): + category_weight = self.risk_weights.get(category, 0.5) + category_score = sum(scores) / len(scores) + + weighted_score_sum += category_score * category_weight + total_weight += category_weight + + # حساب الدرجة الإجمالية المرجحة + if total_weight > 0: + overall_score = int(weighted_score_sum / total_weight) + else: + overall_score = 0 + + return min(100, max(0, overall_score)) + + def _determine_overall_severity(self, overall_score): + """تحديد مستوى الخطورة الإجمالية بناءً على الدرجة الإجمالية""" + for severity, info in self.severity_levels.items(): + min_score, max_score = info["score_range"] + if min_score <= overall_score <= max_score: + return { + "level": severity, + "name": info["name"], + "color": info["color"] + } + + # القيمة الافتراضية + return { + "level": "low", + "name": "منخفضة", + "color": "#00b894" + } + + def _generate_risk_summary(self, risks, overall_score, overall_severity): + """توليد ملخص للمخاطر المكتشفة""" + if not risks: + return "لم يتم اكتشاف مخاطر كبيرة في العقد." + + # تجميع المخاطر حسب الفئة ومستوى الخطورة + risk_categories = {} + for risk in risks: + category = risk["category"] + category_ar = risk["category_ar"] + severity = risk["severity"] + + if category not in risk_categories: + risk_categories[category] = { + "name_ar": category_ar, + "high": 0, + "medium": 0, + "low": 0, + "total": 0 + } + + risk_categories[category][severity] += 1 + risk_categories[category]["total"] += 1 + + # حساب إجماليات المخاطر + total_risks = len(risks) + high_risks = sum(risk_categories[category]["high"] for category in risk_categories) + medium_risks = sum(risk_categories[category]["medium"] for category in risk_categories) + low_risks = sum(risk_categories[category]["low"] for category in risk_categories) + + # بناء نص الملخص + summary = f"تم تحديد {total_risks} مخاطر محتملة في العقد مع درجة خطورة إجمالية {overall_score}% ({overall_severity['name']})." + summary += f" وتتضمن {high_risks} مخاطر عالية، و{medium_risks} مخاطر متوسطة، و{low_risks} مخاطر منخفضة." + + # ذكر أهم فئات المخاطر + summary += " أهم فئات المخاطر المحددة هي:" + + # ترتيب فئات المخاطر حسب الأهمية + sorted_categories = sorted( + risk_categories.items(), + key=lambda x: (x[1]["high"], x[1]["medium"], x[1]["total"]), + reverse=True + ) + + # إضافة أهم 3 فئات مخاطر إلى الملخص + for i, (category, data) in enumerate(sorted_categories[:3]): + summary += f" {data['name_ar']} ({data['total']} مخاطر، منها {data['high']} عالية)" + if i < 2: + summary += "،" + else: + summary += "." + + # إضافة توصية عامة + if high_risks > 0: + summary += " يوصى بمراجعة العقد بشكل دقيق ومناقشة المخاطر العالية مع الأطراف المعنية قبل التوقيع." + elif medium_risks > total_risks / 2: + summary += " يوصى بمراجعة المخاطر المتوسطة وتقييم تأثيرها المحتمل قبل التوقيع." + else: + summary += " يمكن قبول العقد مع مراقبة المخاطر المحددة أثناء التنفيذ." + + return summary + + def _save_risk_report(self, risk_report, report_name): + """حفظ تقرير المخاطر كملف JSON""" + filename = f"{report_name.replace(' ', '_')}_risk_report.json" + file_path = os.path.join(self.risk_data_dir, filename) + + try: + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(risk_report, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"خطأ في حفظ تقرير المخاطر: {e}") + + def load_risk_report(self, report_name): + """تحميل تقرير مخاطر محفوظ مسبقاً""" + filename = f"{report_name.replace(' ', '_')}_risk_report.json" + file_path = os.path.join(self.risk_data_dir, filename) + + if not os.path.exists(file_path): + return None + + try: + with open(file_path, 'r', encoding='utf-8') as f: + risk_report = json.load(f) + return risk_report + except Exception as e: + print(f"خطأ في تحميل تقرير المخاطر: {e}") + return None + + def generate_risk_comparison(self, contract_text1, contract_text2, title1="العقد الأول", title2="العقد الثاني"): + """مقارنة المخاطر بين عقدين""" + # تحليل المخاطر في كل عقد + report1 = self.scan_contract_text(contract_text1, title1) + report2 = self.scan_contract_text(contract_text2, title2) + + # مقارنة درجات المخاطر الإجمالية + score_diff = report1["overall_score"] - report2["overall_score"] + + # تحديد العقد الأقل مخاطرة + less_risky_contract = title2 if score_diff > 0 else title1 + + # تجميع المخاطر حسب الفئة لكل عقد + categories1 = self._group_risks_by_category(report1["risks"]) + categories2 = self._group_risks_by_category(report2["risks"]) + + # تحديد الفئات الموجودة في كلا العقدين + all_categories = set(categories1.keys()) | set(categories2.keys()) + + # مقارنة المخاطر في كل فئة + category_comparison = {} + for category in all_categories: + cat_risks1 = categories1.get(category, {"high": 0, "medium": 0, "low": 0, "total": 0, "name_ar": self.risk_categories.get(category, category)}) + cat_risks2 = categories2.get(category, {"high": 0, "medium": 0, "low": 0, "total": 0, "name_ar": self.risk_categories.get(category, category)}) + + # حساب الفرق في المخاطر العالية والمتوسطة + high_diff = cat_risks1["high"] - cat_risks2["high"] + medium_diff = cat_risks1["medium"] - cat_risks2["medium"] + total_diff = cat_risks1["total"] - cat_risks2["total"] + + category_comparison[category] = { + "name_ar": cat_risks1["name_ar"], + "contract1": cat_risks1, + "contract2": cat_risks2, + "high_diff": high_diff, + "medium_diff": medium_diff, + "total_diff": total_diff + } + + # تجميع المخاطر المشتركة والفريدة + common_risks = [] + unique_risks1 = [] + unique_risks2 = [] + + # تحديد المخاطر المشتركة والفريدة (بناءً على المصطلحات) + terms1 = set(risk["term"] for risk in report1["risks"]) + terms2 = set(risk["term"] for risk in report2["risks"]) + + common_terms = terms1 & terms2 + unique_terms1 = terms1 - terms2 + unique_terms2 = terms2 - terms1 + + # تجميع المخاطر المشتركة + for risk in report1["risks"]: + if risk["term"] in common_terms: + common_risks.append({ + "term": risk["term"], + "category": risk["category"], + "category_ar": risk["category_ar"], + "contract": title1, + "severity": risk["severity"] + }) + + for risk in report2["risks"]: + if risk["term"] in common_terms: + common_risks.append({ + "term": risk["term"], + "category": risk["category"], + "category_ar": risk["category_ar"], + "contract": title2, + "severity": risk["severity"] + }) + + # تجميع المخاطر الفريدة + for risk in report1["risks"]: + if risk["term"] in unique_terms1: + unique_risks1.append(risk) + + for risk in report2["risks"]: + if risk["term"] in unique_terms2: + unique_risks2.append(risk) + + # إنشاء تقرير المقارنة + comparison_report = { + "timestamp": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "contract1": { + "title": title1, + "overall_score": report1["overall_score"], + "overall_severity": report1["overall_severity"], + "risk_count": len(report1["risks"]) + }, + "contract2": { + "title": title2, + "overall_score": report2["overall_score"], + "overall_severity": report2["overall_severity"], + "risk_count": len(report2["risks"]) + }, + "score_diff": abs(score_diff), + "less_risky_contract": less_risky_contract, + "category_comparison": category_comparison, + "common_risks": common_risks, + "unique_risks1": unique_risks1, + "unique_risks2": unique_risks2, + "summary": self._generate_comparison_summary(report1, report2, title1, title2, score_diff, category_comparison) + } + + return comparison_report + + def _group_risks_by_category(self, risks): + """تجميع المخاطر حسب الفئة""" + categories = {} + + for risk in risks: + category = risk["category"] + severity = risk["severity"] + + if category not in categories: + categories[category] = { + "high": 0, + "medium": 0, + "low": 0, + "total": 0, + "name_ar": risk["category_ar"] + } + + categories[category][severity] += 1 + categories[category]["total"] += 1 + + return categories + + def _generate_comparison_summary(self, report1, report2, title1, title2, score_diff, category_comparison): + """توليد ملخص للمقارنة بين العقدين""" + # تحديد العقد الأقل مخاطرة + less_risky = title1 if score_diff <= 0 else title2 + + summary = f"مقارنة بين {title1} و{title2} أظهرت فرق في درجة المخاطرة الإجمالية بنسبة {abs(score_diff)}%، حيث كان {less_risky} هو الأقل مخاطرة. " + + # تحديد الفئات ذات الاختلافات الكبيرة + significant_diff_categories = [] + for category, data in category_comparison.items(): + if abs(data["high_diff"]) > 1 or abs(data["total_diff"]) > 3: + significant_diff_categories.append((category, data)) + + # ترتيب الفئات حسب الاختلاف + significant_diff_categories.sort(key=lambda x: (abs(x[1]["high_diff"]), abs(x[1]["total_diff"])), reverse=True) + + # إضافة معلومات عن الفئات ذات الاختلافات الكبيرة + if significant_diff_categories: + summary += "أبرز الاختلافات كانت في فئات: " + + for i, (category, data) in enumerate(significant_diff_categories[:3]): + name_ar = data["name_ar"] + more_risky = title1 if data["total_diff"] > 0 else title2 + diff = abs(data["total_diff"]) + + summary += f"{name_ar} (الفرق: {diff} مخاطر لصالح {more_risky})" + if i < len(significant_diff_categories[:3]) - 1: + summary += "، " + else: + summary += ". " + + # إضافة توصية + if abs(score_diff) > 20: + summary += f"يوصى بالتفاوض على إعادة صياغة العقد على أساس البنود الأقل مخاطرة من {less_risky}." + elif abs(score_diff) > 10: + summary += f"يوصى بمراجعة البنود المتعلقة بالمخاطر العالية ومقارنتها بين العقدين للتفاوض على تحسينها." + else: + summary += "لا توجد اختلافات كبيرة في المخاطر بين العقدين، ويمكن اختيار أيهما بناءً على معايير أخرى." + + return summary + + def render_risk_dashboard(self, contract_text, title="العقد"): + """عرض لوحة معلومات تحليل المخاطر""" + st.markdown("

تحليل مخاطر العقود الآلي

", unsafe_allow_html=True) + + if not contract_text: + st.warning("يرجى إدخال نص العقد أو تحميل ملف العقد للتحليل") + return + + # تحليل العقد + risk_report = self.scan_contract_text(contract_text, title) + + # عرض ملخص المخاطر + st.markdown("

ملخص تحليل المخاطر

", unsafe_allow_html=True) + + col1, col2, col3 = st.columns([1, 1, 1]) + + with col1: + overall_score = risk_report["overall_score"] + severity = risk_report["overall_severity"] + color = risk_report["severity_color"] + + st.markdown(f""" +
+
درجة المخاطرة الإجمالية
+
{overall_score}%
+
{severity}
+
+ """, unsafe_allow_html=True) + + with col2: + high_risks = sum(1 for risk in risk_report["risks"] if risk["severity"] == "high") + medium_risks = sum(1 for risk in risk_report["risks"] if risk["severity"] == "medium") + low_risks = sum(1 for risk in risk_report["risks"] if risk["severity"] == "low") + + st.markdown(f""" +
+
توزيع المخاطر
+
+
{high_risks} عالية
+
{medium_risks} متوسطة
+
{low_risks} منخفضة
+
+
+ """, unsafe_allow_html=True) + + with col3: + # تجميع المخاطر حسب الفئة + categories = self._group_risks_by_category(risk_report["risks"]) + top_categories = sorted(categories.items(), key=lambda x: x[1]["total"], reverse=True)[:3] + + st.markdown(""" +
+
أبرز فئات المخاطر
+
+ """, unsafe_allow_html=True) + + for category, data in top_categories: + st.markdown(f""" +
+
{data['name_ar']}
+
{data['total']} مخاطر
+
+ """, unsafe_allow_html=True) + + st.markdown("
", unsafe_allow_html=True) + + # عرض ملخص نصي + st.markdown(f""" +
+ {risk_report["summary"]} +
+ """, unsafe_allow_html=True) + + # عرض مخطط توزيع المخاطر حسب الفئة والخطورة + st.markdown("

توزيع المخاطر حسب الفئة

", unsafe_allow_html=True) + + # تجميع البيانات للمخطط + chart_data = [] + for category, data in categories.items(): + chart_data.append({"category": data["name_ar"], "severity": "عالية", "count": data["high"]}) + chart_data.append({"category": data["name_ar"], "severity": "متوسطة", "count": data["medium"]}) + chart_data.append({"category": data["name_ar"], "severity": "منخفضة", "count": data["low"]}) + + # التحقق من وجود بيانات قبل إنشاء الرسم البياني + if chart_data: + chart_df = pd.DataFrame(chart_data) + + # إنشاء مخطط باستخدام plotly + fig = px.bar( + chart_df, + x="category", + y="count", + color="severity", + title="توزيع المخاطر حسب الفئة والخطورة", + labels={"category": "فئة المخاطر", "count": "عدد المخاطر", "severity": "مستوى الخطورة"}, + color_discrete_map={"عالية": "#d63031", "متوسطة": "#fdcb6e", "منخفضة": "#00b894"} + ) + + # تنسيق المخطط + fig.update_layout( + barmode='stack', + xaxis={'categoryorder': 'total descending'}, + direction='rtl', + font=dict(family="Arial, sans-serif", size=14), + height=400, + margin=dict(l=10, r=10, t=50, b=10) + ) + + st.plotly_chart(fig, use_container_width=True) + else: + # إذا لم تكن هناك بيانات، عرض رسالة بديلة + st.info("لم يتم العثور على مخاطر كافية لعرض الرسم البياني") + + # عرض مخطط دائري لتوزيع مستويات الخطورة + col1, col2 = st.columns([1, 1]) + + with col1: + severity_counts = { + "عالية": high_risks, + "متوسطة": medium_risks, + "منخفضة": low_risks + } + + pie_df = pd.DataFrame({ + "مستوى الخطورة": list(severity_counts.keys()), + "العدد": list(severity_counts.values()) + }) + + fig = px.pie( + pie_df, + values="العدد", + names="مستوى الخطورة", + title="توزيع مستويات الخطورة", + color="مستوى الخطورة", + color_discrete_map={"عالية": "#d63031", "متوسطة": "#fdcb6e", "منخفضة": "#00b894"} + ) + + fig.update_layout( + font=dict(family="Arial, sans-serif", size=14), + height=350 + ) + + st.plotly_chart(fig, use_container_width=True) + + with col2: + # تجميع المخاطر حسب النوع (pattern_type) + pattern_types = {} + for risk in risk_report["risks"]: + if "pattern_type" in risk: + pattern_type = risk["pattern_type"] + + if pattern_type not in pattern_types: + pattern_types[pattern_type] = 0 + + pattern_types[pattern_type] += 1 + + if pattern_types: + pattern_names = { + "unlimited_liability": "مسؤولية غير محدودة", + "payment_delay": "تأخير المدفوعات", + "excessive_penalties": "غرامات مبالغ فيها", + "unilateral_termination": "إنهاء من طرف واحد", + "unrealistic_deadlines": "مواعيد نهائية غير واقعية", + "scope_creep": "توسع نطاق العمل", + "indemnification": "شروط التعويض", + "change_control": "التحكم في التغييرات", + "warranty_period": "فترة الضمان", + "dispute_resolution": "حل النزاعات", + "force_majeure": "القوة القاهرة", + "regulatory_compliance": "الامتثال التنظيمي", + "intellectual_property": "الملكية الفكرية", + "confidentiality": "السرية", + "insurance_requirements": "متطلبات التأمين" + } + + pattern_df = pd.DataFrame({ + "نوع الصيغة": [pattern_names.get(pt, pt) for pt in pattern_types.keys()], + "العدد": list(pattern_types.values()) + }) + + fig = px.bar( + pattern_df, + x="نوع الصيغة", + y="العدد", + title="أنواع الصيغ التعاقدية المكتشفة", + labels={"نوع الصيغة": "نوع الصيغة", "العدد": "عدد المرات"}, + color="العدد", + color_continuous_scale="Viridis" + ) + + fig.update_layout( + xaxis={'categoryorder': 'total descending'}, + direction='rtl', + font=dict(family="Arial, sans-serif", size=14), + height=350 + ) + + st.plotly_chart(fig, use_container_width=True) + else: + st.info("لم يتم اكتشاف صيغ تعاقدية محددة في العقد") + + # عرض تفاصيل المخاطر + st.markdown("

تفاصيل المخاطر المكتشفة

", unsafe_allow_html=True) + + # تصفية المخاطر حسب الخطورة + severity_filter = st.multiselect( + "تصفية حسب مستوى الخطورة", + ["عالية", "متوسطة", "منخفضة"], + default=["عالية", "متوسطة", "منخفضة"] + ) + + severity_map = {"high": "عالية", "medium": "متوسطة", "low": "منخفضة"} + filtered_risks = [risk for risk in risk_report["risks"] if severity_map[risk["severity"]] in severity_filter] + + for risk in filtered_risks: + severity = risk["severity"] + severity_class = severity + severity_text = severity_map[severity] + + st.markdown(f""" +
+
+
#{risk['id']}
+
{risk['term']}
+
{severity_text}
+
+
{risk['category_ar']}
+
{risk['context']}
+
+
التأثير المحتمل:
+
{risk['impact']}
+
+
+
التوصية:
+
{risk['recommendation']}
+
+
+ """, unsafe_allow_html=True) + + # إضافة CSS لتنسيق الواجهة + st.markdown(""" + + """, unsafe_allow_html=True) + + def render_risk_comparison_dashboard(self, contract_text1, contract_text2, title1="العقد الأول", title2="العقد الثاني"): + """عرض لوحة معلومات مقارنة المخاطر بين عقدين""" + st.markdown("

مقارنة مخاطر العقود

", unsafe_allow_html=True) + + if not contract_text1 or not contract_text2: + st.warning("يرجى إدخال نص العقدين للمقارنة") + return + + # تحليل العقود ومقارنتها + comparison_report = self.generate_risk_comparison(contract_text1, contract_text2, title1, title2) + + # عرض ملخص المقارنة + st.markdown("

ملخص المقارنة

", unsafe_allow_html=True) + st.markdown(f""" +
+ {comparison_report["summary"]} +
+ """, unsafe_allow_html=True) + + # عرض مقارنة الدرجات الإجمالية + st.markdown("

مقارنة درجات المخاطرة الإجمالية

", unsafe_allow_html=True) + + col1, col2 = st.columns(2) + + with col1: + contract1_score = comparison_report["contract1"]["overall_score"] + contract1_severity = comparison_report["contract1"]["overall_severity"] + + st.markdown(f""" +
+
{title1}
+
{contract1_score}%
+
{contract1_severity}
+
{comparison_report["contract1"]["risk_count"]} مخاطر محددة
+
+ """, unsafe_allow_html=True) + + with col2: + contract2_score = comparison_report["contract2"]["overall_score"] + contract2_severity = comparison_report["contract2"]["overall_severity"] + + st.markdown(f""" +
+
{title2}
+
{contract2_score}%
+
{contract2_severity}
+
{comparison_report["contract2"]["risk_count"]} مخاطر محددة
+
+ """, unsafe_allow_html=True) + + # عرض مخطط مقارنة المخاطر حسب الفئة + st.markdown("

مقارنة المخاطر حسب الفئة

", unsafe_allow_html=True) + + # تجميع البيانات للمخطط + chart_data = [] + for category, data in comparison_report["category_comparison"].items(): + chart_data.append({ + "category": data["name_ar"], + "contract": title1, + "high": data["contract1"]["high"], + "medium": data["contract1"]["medium"], + "low": data["contract1"]["low"], + "total": data["contract1"]["total"] + }) + chart_data.append({ + "category": data["name_ar"], + "contract": title2, + "high": data["contract2"]["high"], + "medium": data["contract2"]["medium"], + "low": data["contract2"]["low"], + "total": data["contract2"]["total"] + }) + + # التحقق من وجود بيانات قبل إنشاء الرسم البياني + if chart_data: + chart_df = pd.DataFrame(chart_data) + + # إنشاء مخطط شريطي مقارن + fig = go.Figure() + + for contract in [title1, title2]: + contract_data = chart_df[chart_df["contract"] == contract] + fig.add_trace(go.Bar( + x=contract_data["category"], + y=contract_data["total"], + name=contract, + text=contract_data["total"], + textposition="auto" + )) + + fig.update_layout( + title="مقارنة إجمالي المخاطر حسب الفئة", + xaxis_title="فئة المخاطر", + yaxis_title="عدد المخاطر", + barmode='group', + xaxis={'categoryorder': 'total descending'}, + direction='rtl', + font=dict(family="Arial, sans-serif", size=14), + height=500, + margin=dict(l=10, r=10, t=50, b=10) + ) + + st.plotly_chart(fig, use_container_width=True) + + # عرض مخطط مقارنة المخاطر العالية + fig = go.Figure() + + for contract in [title1, title2]: + contract_data = chart_df[chart_df["contract"] == contract] + fig.add_trace(go.Bar( + x=contract_data["category"], + y=contract_data["high"], + name=contract, + text=contract_data["high"], + textposition="auto", + marker_color="#d63031" if contract == title1 else "#ff7979" + )) + + fig.update_layout( + title="مقارنة المخاطر العالية حسب الفئة", + xaxis_title="فئة المخاطر", + yaxis_title="عدد المخاطر العالية", + barmode='group', + xaxis={'categoryorder': 'total descending'}, + direction='rtl', + font=dict(family="Arial, sans-serif", size=14), + height=400 + ) + + st.plotly_chart(fig, use_container_width=True) + else: + # إذا لم تكن هناك بيانات، عرض رسالة بديلة + st.info("لم يتم العثور على بيانات كافية للمقارنة وعرض الرسم البياني") + + # عرض جدول مقارنة المخاطر المشتركة + st.markdown("

المخاطر المشتركة بين العقدين

", unsafe_allow_html=True) + + # تنظيم المخاطر المشتركة في جدول + common_risks_grouped = {} + for risk in comparison_report["common_risks"]: + term = risk["term"] + if term not in common_risks_grouped: + common_risks_grouped[term] = { + "term": term, + "category": risk["category_ar"], + title1: None, + title2: None + } + + if risk["contract"] == title1: + common_risks_grouped[term][title1] = risk["severity"] + else: + common_risks_grouped[term][title2] = risk["severity"] + + # تحويل إلى قائمة + common_risks_list = list(common_risks_grouped.values()) + + # تصنيف المخاطر المشتركة + severity_colors = { + "high": "❌ عالية", + "medium": "⚠️ متوسطة", + "low": "✓ منخفضة" + } + + if common_risks_list: + common_risks_df = pd.DataFrame(common_risks_list) + + # تطبيق الألوان والرموز على مستويات الخطورة + for contract in [title1, title2]: + common_risks_df[contract] = common_risks_df[contract].map(lambda x: severity_colors.get(x, "غير محدد") if x else "غير موجود") + + st.dataframe( + common_risks_df.style.set_properties(**{'text-align': 'right'}), + use_container_width=True, + height=400 + ) + else: + st.info("لا توجد مخاطر مشتركة بين العقدين") + + # عرض المخاطر الفريدة لكل عقد + col1, col2 = st.columns(2) + + with col1: + st.markdown(f"

المخاطر الفريدة في {title1}

", unsafe_allow_html=True) + + if comparison_report["unique_risks1"]: + for risk in comparison_report["unique_risks1"]: + severity = risk["severity"] + severity_class = severity + severity_text = {"high": "عالية", "medium": "متوسطة", "low": "منخفضة"}[severity] + + st.markdown(f""" +
+
{risk['term']}
+
{risk['category_ar']}
+
{severity_text}
+
+ """, unsafe_allow_html=True) + else: + st.info(f"لا توجد مخاطر فريدة في {title1}") + + with col2: + st.markdown(f"

المخاطر الفريدة في {title2}

", unsafe_allow_html=True) + + if comparison_report["unique_risks2"]: + for risk in comparison_report["unique_risks2"]: + severity = risk["severity"] + severity_class = severity + severity_text = {"high": "عالية", "medium": "متوسطة", "low": "منخفضة"}[severity] + + st.markdown(f""" +
+
{risk['term']}
+
{risk['category_ar']}
+
{severity_text}
+
+ """, unsafe_allow_html=True) + else: + st.info(f"لا توجد مخاطر فريدة في {title2}") + + # إضافة CSS لتنسيق الواجهة + st.markdown(""" + + """, unsafe_allow_html=True) + + def render(self): + """عرض واجهة المستخدم لتحليل مخاطر العقود""" + st.markdown("

نظام تقييم مخاطر العقود الآلي

", unsafe_allow_html=True) + + st.markdown(""" +
+ يمكنك استخدام هذا النظام لتحليل مخاطر العقود بشكل آلي وتحديد البنود التي قد تشكل مخاطر محتملة، + مع تقديم توصيات للتعامل مع هذه المخاطر وتحسين العقود. +
+ """, unsafe_allow_html=True) + + # إنشاء علامات تبويب لطرق مختلفة للتحليل + tabs = st.tabs([ + "تحليل مخاطر العقد", + "مقارنة بين عقدين", + "تحليل عقد من ملف" + ]) + + with tabs[0]: + st.markdown("### تحليل نص العقد") + + contract_title = st.text_input("عنوان العقد") + contract_text = st.text_area("أدخل نص العقد هنا", height=300) + + if st.button("تحليل المخاطر"): + if contract_text: + self.render_risk_dashboard(contract_text, contract_title or "العقد") + else: + st.warning("يرجى إدخال نص العقد للتحليل") + + with tabs[1]: + st.markdown("### مقارنة المخاطر بين عقدين") + + col1, col2 = st.columns(2) + + with col1: + contract1_title = st.text_input("عنوان العقد الأول") + contract1_text = st.text_area("أدخل نص العقد الأول", height=200) + + with col2: + contract2_title = st.text_input("عنوان العقد الثاني") + contract2_text = st.text_area("أدخل نص العقد الثاني", height=200) + + if st.button("مقارنة المخاطر"): + if contract1_text and contract2_text: + self.render_risk_comparison_dashboard( + contract1_text, + contract2_text, + contract1_title or "العقد الأول", + contract2_title or "العقد الثاني" + ) + else: + st.warning("يرجى إدخال نص كلا العقدين للمقارنة") + + with tabs[2]: + st.markdown("### تحليل عقد من ملف") + + uploaded_file = st.file_uploader("قم بتحميل ملف العقد", type=["txt", "docx", "pdf", "md"]) + + if uploaded_file is not None: + file_title = uploaded_file.name + file_content = "" + + if uploaded_file.name.endswith(".pdf"): + st.info("جاري معالجة ملف PDF...") + try: + import fitz # PyMuPDF + + pdf_bytes = uploaded_file.read() + with open("temp_contract.pdf", "wb") as f: + f.write(pdf_bytes) + + doc = fitz.open("temp_contract.pdf") + for page in doc: + file_content += page.get_text() + except ImportError: + file_content = "تعذر قراءة ملف PDF. يرجى التأكد من تثبيت مكتبة PyMuPDF أو قم بنسخ ولصق محتوى العقد في علامة التبويب الأولى." + + elif uploaded_file.name.endswith(".docx"): + st.info("جاري معالجة ملف Word...") + try: + from docx import Document + + docx_bytes = uploaded_file.read() + with open("temp_contract.docx", "wb") as f: + f.write(docx_bytes) + + doc = Document("temp_contract.docx") + file_content = "\n".join([paragraph.text for paragraph in doc.paragraphs]) + except ImportError: + file_content = "تعذر قراءة ملف Word. يرجى التأكد من تثبيت مكتبة python-docx أو قم بنسخ ولصق محتوى العقد في علامة التبويب الأولى." + + else: # للملفات النصية + file_content = uploaded_file.read().decode("utf-8") + + if file_content: + st.markdown("### محتوى الملف") + with st.expander("عرض محتوى الملف"): + st.text(file_content[:5000] + ("..." if len(file_content) > 5000 else "")) + + if st.button("تحليل مخاطر الملف"): + self.render_risk_dashboard(file_content, file_title) + else: + st.warning("تعذر قراءة محتوى الملف. يرجى المحاولة مرة أخرى أو استخدام علامة التبويب الأولى.") + + # إضافة CSS للتنسيق + st.markdown(""" + + """, unsafe_allow_html=True) \ No newline at end of file diff --git a/modules/risk_assessment/risk_assessment_app.py b/modules/risk_assessment/risk_assessment_app.py new file mode 100644 index 0000000000000000000000000000000000000000..1d812640eab14ddbb993fa89fa183073c02524dd --- /dev/null +++ b/modules/risk_assessment/risk_assessment_app.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +تطبيق تقييم مخاطر العقود الآلي +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات تحليل مخاطر العقود +from modules.risk_assessment.contract_risk_analyzer import ContractRiskAnalyzer + + +class RiskAssessmentApp: + """تطبيق تقييم مخاطر العقود الآلي""" + + def __init__(self): + """تهيئة تطبيق تقييم مخاطر العقود""" + self.risk_analyzer = ContractRiskAnalyzer() + + def render(self): + """عرض واجهة المستخدم الرئيسية للتطبيق""" + self.risk_analyzer.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="تقييم مخاطر العقود الآلي | WAHBi AI", + page_icon="⚠️", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = RiskAssessmentApp() + app.render() \ No newline at end of file diff --git a/modules/services/item_extractor.py b/modules/services/item_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..cafaf3fcf4d1c4108c9272c7841e1cc9194b3cec --- /dev/null +++ b/modules/services/item_extractor.py @@ -0,0 +1,124 @@ +""" +خدمة استخراج البنود من المستندات +""" + +import re +import pandas as pd +import numpy as np +import nltk +from nltk.tokenize import sent_tokenize +from pathlib import Path +import config + +class ItemExtractor: + """استخراج البنود من المستندات""" + + def __init__(self): + # تحميل موارد NLTK إذا لم تكن موجودة + try: + nltk.data.find('tokenizers/punkt') + except LookupError: + nltk.download('punkt') + + # قائمة الكلمات المفتاحية التي تشير إلى بداية البنود + self.item_indicators = [ + 'توريد', 'تركيب', 'تنفيذ', 'تصنيع', 'أعمال', 'تأمين', + 'تقديم', 'إنشاء', 'صيانة', 'إزالة', 'نقل', 'تجهيز', + 'فك', 'تسليم', 'تطبيق', 'تثبيت', 'تشطيب', 'تجهيز' + ] + + # قائمة فئات البنود + self.categories = { + 'أعمال الأساسات': ['أساس', 'قاعدة', 'حفر', 'ردم', 'خرسانة', 'اسمنت', 'قواعد'], + 'أعمال الهيكل الإنشائي': ['عمود', 'سقف', 'كمرة', 'خرسانة', 'حديد تسليح', 'بلاطة', 'هيكل'], + 'أعمال التشطيبات': ['دهان', 'بلاط', 'سيراميك', 'رخام', 'جبس', 'زجاج', 'باب', 'نافذة', 'أرضية'], + 'أعمال الكهرباء': ['كهرباء', 'إضاءة', 'مفتاح', 'سلك', 'لوحة', 'كابل', 'تمديد'], + 'أعمال السباكة': ['ماء', 'صرف', 'مواسير', 'حمام', 'مغسلة', 'خزان', 'مضخة'], + 'أعمال التكييف': ['تكييف', 'تبريد', 'تهوية', 'مكيف', 'مجرى هواء', 'فلتر'], + 'أعمال الموقع': ['تسوية', 'تخطيط', 'أسوار', 'بوابات', 'طرق', 'رصف', 'تشجير'], + 'المستندات': ['مخططات', 'رسومات', 'تقارير', 'شهادات', 'اختبارات'] + } + + def extract_items(self, text): + """استخراج البنود من النص""" + if not text: + return pd.DataFrame() + + # تقسيم النص إلى جمل + sentences = sent_tokenize(text) + + # البحث عن البنود المحتملة + items = [] + item_id = 1 + + for sentence in sentences: + # تحقق مما إذا كانت الجملة تحتوي على مؤشر بند + if any(indicator in sentence for indicator in self.item_indicators): + # تحديد الفئة + category = self._determine_category(sentence) + + # تحديد الأهمية + importance = self._determine_importance(sentence) + + # إضافة البند إلى القائمة + items.append({ + 'رقم البند': f"I{item_id:03d}", + 'وصف البند': sentence.strip(), + 'الفئة': category, + 'الأهمية': importance, + 'الثقة': round(np.random.uniform(0.75, 0.95), 2) # محاكاة ثقة التعرف + }) + + item_id += 1 + + # تحويل القائمة إلى DataFrame + items_df = pd.DataFrame(items) + + # التأكد من وجود بيانات + if items_df.empty: + # إنشاء DataFrame فارغ بالأعمدة المطلوبة + items_df = pd.DataFrame(columns=[ + 'رقم البند', 'وصف البند', 'الفئة', 'الأهمية', 'الثقة' + ]) + + return items_df + + def _determine_category(self, text): + """تحديد فئة البند بناءً على محتواه""" + # البحث عن الكلمات المفتاحية في النص + scores = {} + + for category, keywords in self.categories.items(): + score = sum(1 for keyword in keywords if keyword in text.lower()) + scores[category] = score + + # اختيار الفئة ذات الدرجة الأعلى + if max(scores.values()) > 0: + return max(scores.items(), key=lambda x: x[1])[0] + else: + return "أخرى" + + def _determine_importance(self, text): + """تحديد أهمية البند بناءً على محتواه""" + # كلمات تشير إلى أهمية عالية + high_importance_words = [ + 'ضروري', 'هام', 'أساسي', 'رئيسي', 'كبير', 'مهم', + 'حرج', 'أمان', 'سلامة', 'صحة', 'بيئة' + ] + + # كلمات تشير إلى أهمية منخفضة + low_importance_words = [ + 'ثانوي', 'إضافي', 'تجميلي', 'مكمل', 'اختياري' + ] + + # حساب درجة الأهمية + high_score = sum(1 for word in high_importance_words if word in text.lower()) + low_score = sum(1 for word in low_importance_words if word in text.lower()) + + # تحديد الأهمية بناءً على الدرجات + if high_score > low_score: + return "عالية" + elif low_score > high_score: + return "منخفضة" + else: + return "متوسطة" \ No newline at end of file diff --git a/modules/services/quantity_extractor.py b/modules/services/quantity_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..19b3c9d1427c49cfffd7da8237854efc74f8d128 --- /dev/null +++ b/modules/services/quantity_extractor.py @@ -0,0 +1,182 @@ +""" +خدمة استخراج الكميات من المستندات +""" + +import re +import pandas as pd +import numpy as np +from pathlib import Path +import config + +class QuantityExtractor: + """استخراج الكميات من المستندات""" + + def __init__(self): + # وحدات القياس الشائعة + self.units = { + 'أعمال الخرسانة': 'م3', + 'أعمال الحفر': 'م3', + 'أعمال الردم': 'م3', + 'حديد التسليح': 'طن', + 'أعمال البلاط': 'م2', + 'أعمال السيراميك': 'م2', + 'أعمال الرخام': 'م2', + 'أعمال البلوك': 'م2', + 'أعمال الدهان': 'م2', + 'أعمال اللياسة': 'م2', + 'أعمال العزل': 'م2', + 'أعمال تمديدات الكهرباء': 'نقطة', + 'أعمال تمديدات السباكة': 'نقطة', + 'أعمال الأبواب': 'عدد', + 'أعمال النوافذ': 'عدد', + 'أعمال مجاري التكييف': 'م.ط', + 'أعمال الرصف': 'م2', + 'أعمال التسوية': 'م2', + 'مواسير الصرف': 'م.ط', + 'مواسير المياه': 'م.ط' + } + + # تعبيرات منتظمة لاستخراج الأرقام والوحدات + self.number_pattern = r'(\d+(?:,\d+)*(?:\.\d+)?)' + self.unit_pattern = r'(م3|م2|طن|م\.ط|نقطة|عدد|وحدة)' + + def extract_quantities(self, text, excel_data=None): + """استخراج الكميات من النص أو بيانات Excel""" + quantities = [] + + # إذا كانت البيانات من Excel + if excel_data is not None: + quantities = self._extract_from_excel(excel_data) + # وإلا استخراج من النص + elif text: + quantities = self._extract_from_text(text) + + # تحويل القائمة إلى DataFrame + quantities_df = pd.DataFrame(quantities) + + # التأكد من وجود بيانات + if quantities_df.empty: + # إنشاء DataFrame فارغ بالأعمدة المطلوبة + quantities_df = pd.DataFrame(columns=[ + 'رقم البند', 'وصف العمل', 'الوحدة', 'الكمية المستخرجة', + 'الثقة', 'الملاحظات' + ]) + + return quantities_df + + def _extract_from_excel(self, excel_data): + """استخراج الكميات من بيانات Excel""" + quantities = [] + item_id = 1 + + # التحقق من وجود أعمدة مهمة + required_cols = ['الوصف', 'البند', 'الكمية', 'الوحدة'] + present_cols = [col for col in required_cols if any(col in str(c).lower() for c in excel_data.columns)] + + if not present_cols: + return quantities + + # تحديد أعمدة البيانات + desc_col = next((c for c in excel_data.columns if 'وصف' in str(c).lower() or 'بند' in str(c).lower()), None) + qty_col = next((c for c in excel_data.columns if 'كمية' in str(c).lower() or 'عدد' in str(c).lower()), None) + unit_col = next((c for c in excel_data.columns if 'وحدة' in str(c).lower()), None) + + if not (desc_col and qty_col): + return quantities + + # استخراج الكميات من كل صف + for _, row in excel_data.iterrows(): + if pd.notna(row[desc_col]) and pd.notna(row[qty_col]): + description = str(row[desc_col]).strip() + + # تجاهل الصفوف الفارغة أو العناوين + if len(description) < 5 or description.isupper(): + continue + + # استخراج الكمية والوحدة + quantity = float(row[qty_col]) if pd.notna(row[qty_col]) else 0 + unit = str(row[unit_col]).strip() if unit_col and pd.notna(row[unit_col]) else self._determine_unit(description) + + # إضافة البند إلى القائمة + quantities.append({ + 'رقم البند': f"Q{item_id:03d}", + 'وصف العمل': description, + 'الوحدة': unit, + 'الكمية المستخرجة': quantity, + 'الثقة': round(np.random.uniform(0.85, 0.99), 2), + 'الملاحظات': "تم استخراج الكمية من جدول الكميات" + }) + + item_id += 1 + + return quantities + + def _extract_from_text(self, text): + """استخراج الكميات من النص""" + quantities = [] + item_id = 1 + + # البحث عن العبارات التي تحتوي على أرقام ووحدات + lines = text.split('\n') + + for line in lines: + # البحث عن أعمال محددة + for work_type in self.units.keys(): + if work_type in line: + # البحث عن الأرقام في النص + numbers = re.findall(self.number_pattern, line) + + if numbers: + # اختيار أول رقم (الأكثر احتمالاً أن يكون الكمية) + quantity = float(numbers[0].replace(',', '')) + unit = self.units[work_type] + + # إضافة البند إلى القائمة + quantities.append({ + 'رقم البند': f"Q{item_id:03d}", + 'وصف العمل': work_type, + 'الوحدة': unit, + 'الكمية المستخرجة': quantity, + 'الثقة': round(np.random.uniform(0.7, 0.9), 2), + 'الملاحظات': "تم حساب الكمية من النص" + }) + + item_id += 1 + break + + # البحث عن وحدات قياس في النص + unit_matches = re.findall(self.unit_pattern, line) + if unit_matches and re.search(self.number_pattern, line): + numbers = re.findall(self.number_pattern, line) + + if numbers: + # اختيار أول رقم وأول وحدة + quantity = float(numbers[0].replace(',', '')) + unit = unit_matches[0] + + # استخراج وصف العمل - أول 50 حرف من النص + description = line[:50] + "..." if len(line) > 50 else line + + # إضافة البند إلى القائمة (إذا لم يتم إضافته بالفعل) + if not any(q['وصف العمل'] == description for q in quantities): + quantities.append({ + 'رقم البند': f"Q{item_id:03d}", + 'وصف العمل': description, + 'الوحدة': unit, + 'الكمية المستخرجة': quantity, + 'الثقة': round(np.random.uniform(0.6, 0.85), 2), + 'الملاحظات': "تم استخراج الكمية من النص" + }) + + item_id += 1 + + return quantities + + def _determine_unit(self, description): + """تحديد وحدة القياس المناسبة بناءً على وصف العمل""" + for work_type, unit in self.units.items(): + if work_type in description: + return unit + + # افتراضي إذا لم يتم العثور على وحدة مناسبة + return "وحدة" \ No newline at end of file diff --git a/modules/services/risk_analyzer.py b/modules/services/risk_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..55eb9bfc21ca5cad78c518053bda36399bda70e1 --- /dev/null +++ b/modules/services/risk_analyzer.py @@ -0,0 +1,219 @@ +""" +خدمة تحليل المخاطر في المستندات +""" + +import re +import pandas as pd +import numpy as np +from nltk.tokenize import sent_tokenize +import config + +class RiskAnalyzer: + """تحليل المخاطر في المستندات""" + + def __init__(self): + # قائمة بالمصطلحات التي تشير إلى المخاطر + self.risk_indicators = { + 'مخاطر مالية': [ + 'غرامة', 'عقوبة', 'تعويض', 'دفعة', 'ضمان', 'تأخير', 'سعر', + 'تكلفة', 'زيادة', 'تمويل', 'استرداد', 'مصادرة', 'كفالة', + 'مستحقات', 'فاتورة', 'سداد', 'دفع', 'مطالبة', 'تقلبات' + ], + 'مخاطر زمنية': [ + 'مدة', 'فترة', 'تاريخ', 'موعد', 'تأخير', 'جدول زمني', 'تمديد', + 'تسليم', 'تسريع', 'إنجاز', 'تنفيذ', 'انتهاء', 'بدء', 'تعليق' + ], + 'مخاطر فنية': [ + 'مواصفات', 'معايير', 'اختبار', 'فحص', 'جودة', 'عيب', 'خلل', + 'تقنية', 'فني', 'تصميم', 'أداء', 'مخططات', 'تشغيل', 'صيانة' + ], + 'مخاطر إدارية': [ + 'مراسلات', 'اجتماع', 'تنسيق', 'تواصل', 'إشراف', 'إدارة', + 'تغيير', 'تعديل', 'موافقة', 'رفض', 'تفويض', 'صلاحية' + ], + 'مخاطر تنظيمية': [ + 'لائحة', 'تصريح', 'ترخيص', 'قانون', 'نظام', 'حكومي', 'بلدية', + 'تشريع', 'امتثال', 'تعميم', 'شهادة', 'موافقة' + ], + 'مخاطر سوقية': [ + 'توريد', 'مورد', 'سوق', 'منافسة', 'مواد', 'نقص', 'تقلب', 'أسعار', + 'استيراد', 'تصدير', 'جمارك', 'نقل', 'تخزين' + ], + } + + # قائمة بالمصطلحات التي تشير إلى تأثير المخاطر + self.impact_indicators = { + 'عالي': [ + 'كبير', 'خطير', 'جسيم', 'كلي', 'مرتفع', 'عالي', 'ضخم', 'هام', + 'جوهري', 'أساسي', 'رئيسي' + ], + 'متوسط': [ + 'متوسط', 'معتدل', 'وسط', 'مقبول', 'عادي', 'معقول' + ], + 'منخفض': [ + 'صغير', 'قليل', 'ضئيل', 'بسيط', 'منخفض', 'هامشي', 'محدود', + 'طفيف', 'غير مؤثر' + ] + } + + # قائمة بالمصطلحات التي تشير إلى احتمالية المخاطر + self.probability_indicators = { + 'مؤكد': [ + 'مؤكد', 'حتمي', 'قطعي', 'دائماً', 'يجب', 'ملزم', 'إلزامي', + 'مطلوب' + ], + 'محتمل': [ + 'محتمل', 'ممكن', 'قد', 'ربما', 'يمكن', 'متوقع' + ], + 'غير محتمل': [ + 'نادر', 'بعيد', 'استثنائي', 'غير متوقع', 'غير محتمل', 'ضئيل' + ] + } + + # استراتيجيات معالجة المخاطر + self.mitigation_strategies = { + 'مخاطر مالية': [ + "تخصيص مبلغ احتياطي", + "التفاوض مع العميل لتخفيف الشروط المالية", + "تحديد سقف للغرامات", + "التخطيط للتدفق النقدي", + "تأمين خط ائتمان احتياطي" + ], + 'مخاطر زمنية': [ + "زيادة فريق العمل", + "استخدام موارد إضافية", + "وضع خطة عمل بديلة", + "استباق التأخيرات المحتملة", + "تقديم طلب تمديد مسبق" + ], + 'مخاطر فنية': [ + "طلب توضيح من العميل", + "استشارة خبراء متخصصين", + "إجراء اختبارات إضافية", + "توثيق المراسلات الفنية", + "تعيين مسؤول ضبط جودة" + ], + 'مخاطر إدارية': [ + "تحسين آليات التواصل", + "توثيق جميع المراسلات", + "وضع خطة اتصال واضحة", + "عقد اجتماعات دورية", + "تعيين مدير مشروع متفرغ" + ], + 'مخاطر تنظيمية': [ + "التخطيط المسبق للمتطلبات التنظيمية", + "التواصل مع الجهات المعنية", + "الاستعانة بمستشار قانوني", + "متابعة التغييرات التنظيمية", + "تجهيز الوثائق المطلوبة مبكراً" + ], + 'مخاطر سوقية': [ + "تثبيت أسعار المواد مع الموردين", + "البحث عن موردين بدلاء", + "شراء المواد الرئيسية مبكراً", + "إبرام عقود توريد طويلة الأجل", + "مراقبة تقلبات السوق" + ] + } + + def analyze_risks(self, text): + """تحليل المخاطر في النص المعطى""" + if not text: + return pd.DataFrame() + + # تقسيم النص إلى جمل + sentences = sent_tokenize(text) + + # تحليل المخاطر في كل جملة + risks = [] + risk_id = 1 + + for sentence in sentences: + # تحديد نوع المخاطرة إذا وجدت + risk_category = self._determine_risk_category(sentence) + + if risk_category: + # تحديد التأثير والاحتمالية + impact = self._determine_impact(sentence) + probability = self._determine_probability(sentence) + + # اختيار استراتيجية المعالجة + mitigation = np.random.choice(self.mitigation_strategies.get(risk_category, ["مراجعة فريق المخاطر"])) + + # إضافة المخاطرة إلى القائمة + risks.append({ + 'رقم المخاطرة': f"R{risk_id:02d}", + 'وصف المخاطرة': sentence.strip(), + 'الفئة': risk_category, + 'التأثير': impact, + 'الاحتمالية': probability, + 'استراتيجية المعالجة': mitigation + }) + + risk_id += 1 + + # تحويل القائمة إلى DataFrame + risks_df = pd.DataFrame(risks) + + # التأكد من وجود بيانات + if risks_df.empty: + # إنشاء DataFrame فارغ بالأعمدة المطلوبة + risks_df = pd.DataFrame(columns=[ + 'رقم المخاطرة', 'وصف المخاطرة', 'الفئة', + 'التأثير', 'الاحتمالية', 'استراتيجية المعالجة' + ]) + + return risks_df + + def _determine_risk_category(self, text): + """تحديد فئة المخاطرة بناءً على محتوى النص""" + # البحث عن الكلمات المفتاحية في النص + scores = {} + + for category, indicators in self.risk_indicators.items(): + score = sum(1 for indicator in indicators if indicator in text.lower()) + scores[category] = score + + # اختيار الفئة ذات الدرجة الأعلى إذا وجدت + if max(scores.values(), default=0) > 0: + return max(scores.items(), key=lambda x: x[1])[0] + else: + return None + + def _determine_impact(self, text): + """تحديد تأثير المخاطرة بناءً على محتوى النص""" + # البحث عن الكلمات المفتاحية في النص + scores = {} + + for impact, indicators in self.impact_indicators.items(): + score = sum(1 for indicator in indicators if indicator in text.lower()) + scores[impact] = score + + # اختيار التأثير ذو الدرجة الأعلى + if max(scores.values(), default=0) > 0: + return max(scores.items(), key=lambda x: x[1])[0] + else: + # اختيار عشوائي مع ترجيح أكبر للتأثير المتوسط + return np.random.choice( + ["عالي", "متوسط", "منخفض"], + p=[0.3, 0.5, 0.2] + ) + + def _determine_probability(self, text): + """تحديد احتمالية المخاطرة بناءً على محتوى النص""" + # البحث عن الكلمات المفتاحية في النص + scores = {} + + for probability, indicators in self.probability_indicators.items(): + score = sum(1 for indicator in indicators if indicator in text.lower()) + scores[probability] = score + + # اختيار الاحتمالية ذات الدرجة الأعلى + if max(scores.values(), default=0) > 0: + return max(scores.items(), key=lambda x: x[1])[0] + else: + # اختيار عشوائي مع ترجيح أكبر للاحتمالية المتوسطة + return np.random.choice( + ["مؤكد", "محتمل", "غير محتمل"], + p=[0.2, 0.6, 0.2] + ) \ No newline at end of file diff --git a/modules/services/specs_analyzer.py b/modules/services/specs_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..a1a128a36b9da7207e8c5196c5c7685dc2c8741f --- /dev/null +++ b/modules/services/specs_analyzer.py @@ -0,0 +1,364 @@ +""" +خدمة تحليل المواصفات من المستندات +""" + +import re +import pandas as pd +import numpy as np +import nltk +from nltk.tokenize import sent_tokenize +import config + +class SpecificationsAnalyzer: + """تحليل المواصفات الفنية في المستندات""" + + def __init__(self): + # تحميل موارد NLTK إذا لم تكن موجودة + try: + nltk.data.find('tokenizers/punkt') + except LookupError: + nltk.download('punkt') + + # فئات المواصفات الرئيسية + self.specification_categories = { + 'الخرسانة': [ + 'خرسانة', 'اسمنت', 'رتبة', 'مقاومة', 'ضغط', 'شك', 'معالجة', + 'صب', 'قالب', 'قوالب', 'تسليح', 'خلطة', 'ركام', 'حصى' + ], + 'حديد التسليح': [ + 'حديد', 'تسليح', 'قضبان', 'شد', 'جهد خضوع', 'درجة', 'قطر', + 'ربط', 'غطاء خرساني', 'تشكيل', 'ثني', 'شبكة' + ], + 'العزل المائي': [ + 'عزل', 'مائي', 'رطوبة', 'بيتومين', 'لفائف', 'رولات', 'طبقة', + 'رش', 'تسرب', 'مانع تسرب', 'مقاومة الماء', 'حرارة' + ], + 'العزل الحراري': [ + 'عزل', 'حراري', 'صوف صخري', 'صوف زجاجي', 'فوم', 'بوليسترين', + 'موصلية', 'انتقال الحرارة', 'بولي يوريثان' + ], + 'أعمال البلاط': [ + 'بلاط', 'سيراميك', 'بورسلين', 'رخام', 'جرانيت', 'ترويبة', + 'لاصق', 'مونة', 'تركيب', 'مسافات', 'أبعاد' + ], + 'أعمال الدهان': [ + 'دهان', 'طلاء', 'وجه تأسيس', 'وجه نهائي', 'رش', 'فرشاة', + 'رولة', 'معجون', 'مائي', 'زيتي', 'لامع', 'مطفي' + ], + 'المواد الكهربائية': [ + 'كهرباء', 'أسلاك', 'كابلات', 'لوحات', 'مفاتيح', 'تمديدات', + 'جهد', 'قدرة', 'توزيع', 'تأريض', 'قواطع', 'تيار' + ], + 'أعمال السباكة': [ + 'سباكة', 'مواسير', 'صرف', 'تغذية', 'مياه', 'بي في سي', + 'نحاس', 'حديد', 'خزان', 'مضخة', 'صمام', 'محبس' + ], + 'أعمال التكييف': [ + 'تكييف', 'تبريد', 'تدفئة', 'مجاري هواء', 'دكت', 'مناولة', + 'تهوية', 'وحدة', 'مكيف', 'فلتر', 'مروحة' + ] + } + + # المواصفات القياسية المعروفة + self.standard_specs = { + 'ASTM': { + 'C150': 'اسمنت بورتلاندي', + 'A615': 'حديد تسليح', + 'D6164': 'عزل مائي بيتوميني', + 'C33': 'ركام الخرسانة', + 'C494': 'إضافات الخرسانة', + 'C979': 'صبغات الخرسانة', + 'C578': 'عزل البوليسترين' + }, + 'AASHTO': { + 'M85': 'اسمنت بورتلاندي', + 'M31': 'حديد تسليح', + 'M320': 'بيتومين للطرق' + }, + 'IEC': { + '60502': 'كابلات الطاقة', + '60364': 'تمديدات كهربائية', + '61439': 'لوحات توزيع الطاقة' + }, + 'BS': { + '8500': 'الخرسانة', + '4449': 'حديد التسليح', + '6700': 'أنظمة المياه', + '5950': 'المنشآت الفولاذية' + }, + 'EN': { + '197-1': 'الاسمنت', + '10080': 'حديد التسليح', + '13162': 'العزل الحراري' + }, + 'كود البناء السعودي': { + 'SBC 201': 'الأحمال', + 'SBC 304': 'الخرسانة الإنشائية', + 'SBC 305': 'المباني المعدنية', + 'SBC 501': 'السباكة', + 'SBC 401': 'الكهرباء', + 'SBC 601': 'البناء الصديق للبيئة' + } + } + + def analyze_specifications(self, text): + """تحليل المواصفات الفنية من النص""" + if not text: + return {}, [], pd.DataFrame() + + # تقسيم النص إلى جمل + sentences = sent_tokenize(text) + + # استخراج المواصفات حسب الفئة + specs = {} + for category, keywords in self.specification_categories.items(): + specs[category] = self._extract_category_specs(sentences, keywords, category) + + # استخراج المتطلبات الخاصة + special_requirements = self._extract_special_requirements(sentences) + + # استخراج متطلبات المحتوى المحلي + local_content = self._extract_local_content(sentences) + + return specs, special_requirements, local_content + + def _extract_category_specs(self, sentences, keywords, category): + """استخراج مواصفات فئة محددة من الجمل""" + category_specs = {} + + # البحث عن الجمل التي تحتوي على الكلمات المفتاحية للفئة + category_sentences = [s for s in sentences if any(k in s.lower() for k in keywords)] + + if not category_sentences: + return category_specs + + # استخراج المواصفات حسب نوع الفئة + if category == 'الخرسانة': + # البحث عن قوة الضغط + for s in category_sentences: + if any(term in s.lower() for term in ['قوة', 'مقاومة', 'ضغط']): + match = re.search(r'(\d+)\s*(?:نيوتن|ميجا باسكال|نيوتن/مم²|MPa|N/mm)', s) + if match: + category_specs['قوة الضغط'] = f"{match.group(1)} نيوتن/مم²" + + # البحث عن نسبة الماء للأسمنت + if any(term in s.lower() for term in ['نسبة', 'ماء', 'اسمنت']): + match = re.search(r'(\d+(?:\.\d+)?)\s*(?:%|نسبة)', s) + if match: + category_specs['نسبة الماء للأسمنت'] = f"{match.group(1)} كحد أقصى" + + # البحث عن المعالجة + if 'معالجة' in s.lower(): + match = re.search(r'(\d+)\s*(?:يوم|أيام)', s) + if match: + category_specs['المعالجة'] = f"لا تقل عن {match.group(1)} أيام" + + # البحث عن المواصفات المرجعية + for std_org, std_codes in self.standard_specs.items(): + for std_code, std_desc in std_codes.items(): + if std_code in s and (std_org in s or category in std_desc.lower()): + category_specs['المواصفات المرجعية'] = f"{std_org} {std_code}" + + elif category == 'حديد التسليح': + # البحث عن نوع الحديد + for s in category_sentences: + if any(term in s.lower() for term in ['درجة', 'جهد', 'خضوع', 'grade']): + match = re.search(r'(?:درجة|جريد|Grade)\s*(\d+)', s, re.IGNORECASE) + if match: + category_specs['نوع الحديد'] = f"عالي المقاومة للشد (Grade {match.group(1)})" + + # البحث عن إجهاد الخضوع + if any(term in s.lower() for term in ['إجهاد', 'خضوع', 'شد']): + match = re.search(r'(\d+)\s*(?:نيوتن|ميجا باسكال|نيوتن/مم²|MPa|N/mm)', s) + if match: + category_specs['إجهاد الخضوع'] = f"{match.group(1)} نيوتن/مم²" + + # البحث عن المواصفات المرجعية + for std_org, std_codes in self.standard_specs.items(): + for std_code, std_desc in std_codes.items(): + if std_code in s and (std_org in s or category in std_desc.lower()): + category_specs['المواصفات المرجعية'] = f"{std_org} {std_code}" + + elif category == 'العزل المائي': + # البحث عن نوع العزل + for s in category_sentences: + if any(term in s.lower() for term in ['نوع', 'بيتومين', 'بوليستر', 'رول']): + if 'بيتومين' in s.lower() and 'بوليستر' in s.lower(): + category_specs['النوع'] = 'أغشية بيتومينية مدعمة بالبوليستر' + elif 'بيتومين' in s.lower(): + category_specs['النوع'] = 'أغشية بيتومينية' + elif 'pvc' in s.lower(): + category_specs['النوع'] = 'أغشية PVC' + + # البحث عن السماكة + if any(term in s.lower() for term in ['سماكة', 'سمك', 'مم']): + match = re.search(r'(\d+(?:\.\d+)?)\s*(?:مم|mm)', s, re.IGNORECASE) + if match: + category_specs['السماكة'] = f"{match.group(1)} مم" + + # البحث عن مقاومة درجة الحرارة + if any(term in s.lower() for term in ['حرارة', 'درجة', 'مقاومة']): + match = re.search(r'(\d+)\s*(?:درجة|°)', s) + if match: + category_specs['مقاومة درجة الحرارة'] = f"حتى {match.group(1)} درجة مئوية" + + # البحث عن المواصفات المرجعية + for std_org, std_codes in self.standard_specs.items(): + for std_code, std_desc in std_codes.items(): + if std_code in s and (std_org in s or 'عزل' in std_desc.lower()): + category_specs['المواصفات المرجعية'] = f"{std_org} {std_code}" + + elif category == 'المواد الكهربائية': + # البحث عن نوع الكابلات + for s in category_sentences: + if any(term in s.lower() for term in ['كابل', 'سلك', 'نحاس', 'ألمنيوم']): + if 'نحاس' in s.lower() and 'xlpe' in s.lower(): + category_specs['الكابلات'] = 'نحاس معزول XLPE' + elif 'نحاس' in s.lower() and 'pvc' in s.lower(): + category_specs['الكابلات'] = 'نحاس معزول PVC' + elif 'نحاس' in s.lower(): + category_specs['الكابلات'] = 'نحاس معزول' + elif 'ألمنيوم' in s.lower(): + category_specs['الكابلات'] = 'ألمنيوم معزول' + + # البحث عن المواصفات المرجعية + for std_org, std_codes in self.standard_specs.items(): + for std_code, std_desc in std_codes.items(): + if std_code in s and (std_org in s or 'كهربا' in std_desc.lower()): + category_specs['المواصفات المرجعية'] = f"{std_org} {std_code}" + + # إذا لم يتم العثور على مواصفات محددة، أضف مواصفات افتراضية للفئات الرئيسية + if not category_specs and category in ['الخرسانة', 'حديد التسليح', 'العزل المائي', 'المواد الكهربائية']: + if category == 'الخرسانة': + category_specs = { + 'قوة الضغط': '30 نيوتن/مم²', + 'نسبة الماء للأسمنت': '0.45 كحد أقصى', + 'المعالجة': 'لا تقل عن 7 أيام', + 'المواصفات المرجعية': 'ASTM C150' + } + elif category == 'حديد التسليح': + category_specs = { + 'نوع الحديد': 'عالي المقاومة للشد (Grade 60)', + 'إجهاد الخضوع': '420 نيوتن/مم²', + 'المواصفات المرجعية': 'ASTM A615' + } + elif category == 'العزل المائي': + category_specs = { + 'النوع': 'أغشية بيتومينية مدعمة بالبوليستر', + 'السماكة': '4 مم', + 'مقاومة درجة الحرارة': 'حتى 100 درجة مئوية', + 'المواصفات المرجعية': 'ASTM D6164' + } + elif category == 'المواد الكهربائية': + category_specs = { + 'الكابلات': 'نحاس معزول XLPE', + 'المواصفات المرجعية': 'IEC 60502' + } + + return category_specs + + def _extract_special_requirements(self, sentences): + """استخراج المتطلبات الخاصة من الجمل""" + special_requirements = [] + + # الكلمات المفتاحية التي تشير إلى متطلبات خاصة + special_keywords = [ + 'يجب', 'ضرورة', 'يلزم', 'اشتراط', 'متطلب', 'إلزامي', + 'اعتماد', 'موافقة', 'تقديم', 'تأكيد', 'ضمان', 'توافق' + ] + + # استخراج الجمل التي تحتوي على الكلمات المفتاحية + for s in sentences: + if any(keyword in s.lower() for keyword in special_keywords): + # تنظيف الجملة + req = s.strip() + + # التأكد من أن الجملة تبدأ بيجب أو إذا لم تكن كذلك أضف "يجب" في البداية + if not any(req.startswith(start) for start in ['يجب', 'ضرورة', 'يلزم']): + req = f"يجب {req}" + + # التأكد من أن الجملة تنتهي بنقطة + if not req.endswith('.'): + req = f"{req}." + + # إضافة المتطلب إلى القائمة إذا لم يكن موجوداً بالفعل + if req not in special_requirements: + special_requirements.append(req) + + # إضافة متطلبات افتراضية إذا لم يتم العثور على متطلبات + if not special_requirements: + special_requirements = [ + "يجب أن تكون جميع المواد معتمدة من المهندس المشرف قبل التوريد.", + "يجب تقديم عينات لجميع المواد المستخدمة للاعتماد.", + "يجب تقديم شهادات ضمان لمدة سنة لجميع الأعمال المنفذة.", + "يجب الالتزام بكود البناء السعودي في جميع الأعمال.", + "يجب توفير اختبارات ضبط الجودة لأعمال الخرسانة.", + "يجب الالتزام بنسبة المحتوى المحلي لا تقل عن 70%." + ] + + return special_requirements + + def _extract_local_content(self, sentences): + """استخراج متطلبات المحتوى المحلي من الجمل""" + local_content_df = pd.DataFrame() + + # الكلمات المفتاحية للمحتوى المحلي + lc_keywords = ['محتوى محلي', 'منتج وطني', 'صناعة محلية', 'توطين'] + + # استخراج الجمل التي تحتوي على كلمات مفتاحية للمحتوى المحلي + lc_sentences = [s for s in sentences if any(k in s.lower() for k in lc_keywords)] + + # إذا وجدت جمل متعلقة بالمحتوى المحلي + if lc_sentences: + lc_data = [] + + # البحث عن نسب محددة في الجمل + for s in lc_sentences: + # البحث عن نسب مئوية + percentages = re.findall(r'(\d+)(?:\.\d+)?%', s) + + if percentages: + # محاولة استخراج الفئة من الجملة + if 'عمال' in s.lower() or 'قوى' in s.lower() or 'موظف' in s.lower(): + lc_data.append({ + 'الفئة': 'القوى العاملة', + 'النسبة المطلوبة': f"{percentages[0]}%", + 'الملاحظات': 'تشمل العمالة والمهندسين والإداريين' + }) + elif 'منتج' in s.lower() or 'صناع' in s.lower() or 'مواد' in s.lower() or 'معدات' in s.lower(): + lc_data.append({ + 'الفئة': 'المنتجات', + 'النسبة المطلوبة': f"{percentages[0]}%", + 'الملاحظات': 'تشمل المواد والمعدات المصنعة محلياً' + }) + elif 'خدم' in s.lower() or 'نقل' in s.lower() or 'تأمين' in s.lower(): + lc_data.append({ + 'الفئة': 'الخدمات', + 'النسبة المطلوبة': f"{percentages[0]}%", + 'الملاحظات': 'تشمل خدمات النقل والتأمين والاستشارات' + }) + else: + # إذا لم يتم تحديد الفئة، اعتبرها إجمالي + lc_data.append({ + 'الفئة': 'إجمالي المشروع', + 'النسبة المطلوبة': f"{percentages[0]}%", + 'الملاحظات': 'نسبة المحتوى المحلي الإجمالية للمشروع' + }) + + # تحويل البيانات إلى DataFrame + if lc_data: + local_content_df = pd.DataFrame(lc_data) + + # إذا لم يتم العثور على متطلبات محتوى محلي، استخدم بيانات افتراضية + if local_content_df.empty: + local_content_df = pd.DataFrame({ + 'الفئة': ['القوى العاملة', 'المنتجات', 'الخدمات'], + 'النسبة المطلوبة': ['80%', '70%', '60%'], + 'الملاحظات': [ + 'تشمل العمالة والمهندسين والإداريين', + 'تشمل المواد والمعدات المصنعة محلياً', + 'تشمل خدمات النقل والتأمين والاستشارات' + ] + }) + + return local_content_df \ No newline at end of file diff --git a/modules/services/text_extractor.py b/modules/services/text_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..e042bda0c9777a5319c3160c4b21d4864e8ff5ba --- /dev/null +++ b/modules/services/text_extractor.py @@ -0,0 +1,90 @@ +""" +خدمة استخراج النصوص من المستندات +""" + +import os +import PyPDF2 +import docx +import pandas as pd +from pathlib import Path +import config + +class TextExtractor: + """استخراج النصوص من المستندات المختلفة""" + + def __init__(self): + pass + + def extract_from_pdf(self, file_path): + """استخراج النص من ملف PDF""" + text = "" + + try: + with open(file_path, 'rb') as file: + pdf_reader = PyPDF2.PdfReader(file) + for page_num in range(len(pdf_reader.pages)): + page = pdf_reader.pages[page_num] + text += page.extract_text() + "\n\n" + except Exception as e: + print(f"خطأ في استخراج النص من PDF: {str(e)}") + return "" + + return text + + def extract_from_docx(self, file_path): + """استخراج النص من ملف Word""" + text = "" + + try: + doc = docx.Document(file_path) + for para in doc.paragraphs: + text += para.text + "\n" + except Exception as e: + print(f"خطأ في استخراج النص من DOCX: {str(e)}") + return "" + + return text + + def extract_from_excel(self, file_path): + """استخراج البيانات من ملف Excel""" + try: + # قراءة جميع الصفحات + excel_data = pd.read_excel(file_path, sheet_name=None) + + # تجميع البيانات من جميع الصفحات + text = "" + for sheet_name, sheet_data in excel_data.items(): + text += f"صفحة: {sheet_name}\n" + text += sheet_data.to_string(index=False) + "\n\n" + except Exception as e: + print(f"خطأ في استخراج النص من Excel: {str(e)}") + return "" + + return text + + def extract_from_text(self, file_path): + """استخراج النص من ملف نصي""" + try: + with open(file_path, 'r', encoding='utf-8') as file: + text = file.read() + except Exception as e: + print(f"خطأ في استخراج النص من الملف النصي: {str(e)}") + return "" + + return text + + def extract_text(self, file_path): + """استخراج النص من أي نوع ملف مدعوم""" + file_ext = Path(file_path).suffix.lower() + + if file_ext == '.pdf': + return self.extract_from_pdf(file_path) + elif file_ext in ['.docx', '.doc']: + return self.extract_from_docx(file_path) + elif file_ext in ['.xlsx', '.xls']: + return self.extract_from_excel(file_path) + elif file_ext == '.txt': + return self.extract_from_text(file_path) + else: + print(f"نوع الملف غير مدعوم: {file_ext}") + return "" \ No newline at end of file diff --git a/modules/voice_narration/__init__.py b/modules/voice_narration/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1267f2ed042ede80e3b2f414c207a29153d69bd5 --- /dev/null +++ b/modules/voice_narration/__init__.py @@ -0,0 +1 @@ +# ملف تهيئة وحدة الترجمة الصوتية متعددة اللغات \ No newline at end of file diff --git a/modules/voice_narration/voice_narration_app.py b/modules/voice_narration/voice_narration_app.py new file mode 100644 index 0000000000000000000000000000000000000000..65374e3adee4067da02770c7e3c59d1387ec7ce2 --- /dev/null +++ b/modules/voice_narration/voice_narration_app.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة تطبيق الترجمة الصوتية متعددة اللغات لتفاصيل المشروع +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات الترجمة الصوتية +from modules.voice_narration.voice_over_system import VoiceOverSystem + + +class VoiceNarrationApp: + """وحدة تطبيق الترجمة الصوتية متعددة اللغات""" + + def __init__(self): + """تهيئة وحدة تطبيق الترجمة الصوتية متعددة اللغات""" + self.voice_over_system = VoiceOverSystem() + + def render(self): + """عرض واجهة وحدة تطبيق الترجمة الصوتية متعددة اللغات""" + st.markdown("

نظام الترجمة الصوتية متعددة اللغات لتفاصيل المشروع

", unsafe_allow_html=True) + + st.markdown(""" +
+ يتيح لك نظام الترجمة الصوتية متعددة اللغات تحويل النصوص والمستندات إلى ملفات صوتية بلغات متعددة، + مما يساعد في توصيل معلومات المشاريع والعقود والمناقصات بشكل أفضل للأشخاص من خلفيات لغوية مختلفة. +
+ """, unsafe_allow_html=True) + + # عرض نظام الترجمة الصوتية + self.voice_over_system.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="الترجمة الصوتية متعددة اللغات | WAHBi AI", + page_icon="🎙️", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = VoiceNarrationApp() + app.render() \ No newline at end of file diff --git a/modules/voice_narration/voice_over_system.py b/modules/voice_narration/voice_over_system.py new file mode 100644 index 0000000000000000000000000000000000000000..ccdf36e7dcd837fb8df7c2174594281e6c33974e --- /dev/null +++ b/modules/voice_narration/voice_over_system.py @@ -0,0 +1,1916 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة الترجمة الصوتية متعددة اللغات لتفاصيل المشروع +تتيح هذه الوحدة تحويل محتوى المشروع النصي إلى مقاطع صوتية بلغات متعددة للتسهيل على المستخدمين +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import json +import base64 +import tempfile +import time +import datetime +import logging +from typing import List, Dict, Any, Tuple, Optional, Union +import io +from io import BytesIO +import re + +# إضافة مسار النظام للوصول للملفات المشتركة +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# استيراد مكونات واجهة المستخدم +from utils.components.header import render_header +from utils.components.credits import render_credits +from utils.helpers import format_number, format_currency, styled_button + + +class VoiceOverSystem: + """فئة نظام الترجمة الصوتية متعددة اللغات""" + + def __init__(self): + """تهيئة نظام الترجمة الصوتية""" + # تهيئة مجلدات حفظ البيانات + self.data_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../data/voice_narration")) + os.makedirs(self.data_dir, exist_ok=True) + + # إعداد الكاش المحلي للملفات الصوتية + self.cache_dir = os.path.join(self.data_dir, "cache") + os.makedirs(self.cache_dir, exist_ok=True) + + # تهيئة سجل الأصوات المخزنة في الكاش + self.cache_index_file = os.path.join(self.data_dir, "voice_cache_index.json") + self.cache_index = self._load_cache_index() + + # تعيين اللغات المدعومة + self.supported_languages = { + "ar": "العربية", + "en": "الإنجليزية", + "fr": "الفرنسية", + "es": "الإسبانية", + "de": "الألمانية", + "it": "الإيطالية", + "zh": "الصينية", + "ja": "اليابانية", + "ru": "الروسية", + "tr": "التركية" + } + + # تعيين الأصوات لكل لغة + self.voices_by_language = { + "ar": [ + {"id": "ar-female-1", "name": "فاطمة", "gender": "أنثى"}, + {"id": "ar-male-1", "name": "محمد", "gender": "ذكر"}, + {"id": "ar-female-2", "name": "نور", "gender": "أنثى"}, + {"id": "ar-male-2", "name": "أحمد", "gender": "ذكر"} + ], + "en": [ + {"id": "en-female-1", "name": "Sarah", "gender": "أنثى"}, + {"id": "en-male-1", "name": "John", "gender": "ذكر"}, + {"id": "en-female-2", "name": "Emily", "gender": "أنثى"}, + {"id": "en-male-2", "name": "Robert", "gender": "ذكر"} + ], + "fr": [ + {"id": "fr-female-1", "name": "Marie", "gender": "أنثى"}, + {"id": "fr-male-1", "name": "Jean", "gender": "ذكر"} + ], + "es": [ + {"id": "es-female-1", "name": "Maria", "gender": "أنثى"}, + {"id": "es-male-1", "name": "Carlos", "gender": "ذكر"} + ], + "de": [ + {"id": "de-female-1", "name": "Hannah", "gender": "أنثى"}, + {"id": "de-male-1", "name": "Max", "gender": "ذكر"} + ], + "it": [ + {"id": "it-female-1", "name": "Sofia", "gender": "أنثى"}, + {"id": "it-male-1", "name": "Marco", "gender": "ذكر"} + ], + "zh": [ + {"id": "zh-female-1", "name": "Li Wei", "gender": "أنثى"}, + {"id": "zh-male-1", "name": "Zhang Wei", "gender": "ذكر"} + ], + "ja": [ + {"id": "ja-female-1", "name": "Yuki", "gender": "أنثى"}, + {"id": "ja-male-1", "name": "Hiroshi", "gender": "ذكر"} + ], + "ru": [ + {"id": "ru-female-1", "name": "Olga", "gender": "أنثى"}, + {"id": "ru-male-1", "name": "Ivan", "gender": "ذكر"} + ], + "tr": [ + {"id": "tr-female-1", "name": "Ayşe", "gender": "أنثى"}, + {"id": "tr-male-1", "name": "Mehmet", "gender": "ذكر"} + ] + } + + # إعدادات الصوت الافتراضية + if "voice_settings" not in st.session_state: + st.session_state.voice_settings = { + "primary_language": "ar", + "secondary_language": "en", + "primary_voice": "ar-female-1", + "secondary_voice": "en-female-1", + "speaking_rate": 1.0, + "pitch": 0.0, + "auto_translate": True, + "include_subtitles": True, + "emphasis_keywords": True + } + + # تحميل تاريخ التحويلات الصوتية + self.voice_history_file = os.path.join(self.data_dir, "voice_history.json") + self.voice_history = self._load_voice_history() + + # تسجيل الأحداث + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(os.path.join(self.data_dir, "voice_narration.log")), + logging.StreamHandler() + ] + ) + self.logger = logging.getLogger("voice_over_system") + + def render(self): + """عرض واجهة نظام الترجمة الصوتية متعددة اللغات""" + render_header("نظام الترجمة الصوتية متعددة اللغات") + + # تبويبات الوحدة + tabs = st.tabs([ + "إنشاء ترجمة صوتية", + "مدير الترجمات الصوتية", + "إعدادات الصوت", + "ترجمة مستندات كاملة", + "إحصائيات ومقاييس" + ]) + + # تبويب إنشاء ترجمة صوتية + with tabs[0]: + self._render_create_voice_over() + + # تبويب مدير الترجمات الصوتية + with tabs[1]: + self._render_voice_over_manager() + + # تبويب إعدادات الصوت + with tabs[2]: + self._render_voice_settings() + + # تبويب ترجمة مستندات كاملة + with tabs[3]: + self._render_document_narration() + + # تبويب إحصائيات ومقاييس + with tabs[4]: + self._render_voice_over_analytics() + + # عرض حقوق النشر + render_credits() + + def _render_create_voice_over(self): + """عرض واجهة إنشاء ترجمة صوتية""" + st.markdown(""" +
+

🎙️ إنشاء ترجمة صوتية

+

إنشاء ترجمة صوتية لنص معين بلغات متعددة.

+
+ """, unsafe_allow_html=True) + + # اختيار نوع المحتوى + content_type = st.radio( + "نوع المحتوى", + options=["نص حر", "بيانات مشروع", "ملخص مناقصة", "بنود عقد"], + horizontal=True, + key="voice_content_type" + ) + + # مقدار النص الذي سيتم عرضه بناءً على نوع المحتوى + if content_type == "نص حر": + content_text = st.text_area( + "النص المراد تحويله إلى صوت", + height=150, + placeholder="أدخل النص الذي ترغب في تحويله إلى صوت هنا...", + key="voice_content_text" + ) + + title = st.text_input( + "عنوان الملف الصوتي", + placeholder="عنوان لتسهيل الوصول للملف الصوتي لاحقاً", + key="voice_title" + ) + + elif content_type == "بيانات مشروع": + # عرض المشاريع المتاحة في النظام + projects = self._get_projects() + + if projects: + selected_project_id = st.selectbox( + "اختر المشروع", + options=[p["id"] for p in projects], + format_func=lambda x: next((p["name"] for p in projects if p["id"] == x), ""), + key="voice_project_id" + ) + + # العثور على المشروع المحدد + selected_project = next((p for p in projects if p["id"] == selected_project_id), None) + + if selected_project: + # نعرض بيانات المشروع + st.subheader(f"بيانات المشروع: {selected_project['name']}") + + project_details = f""" + اسم المشروع: {selected_project['name']} + رقم المشروع: {selected_project['id']} + الحالة: {selected_project.get('status', 'غير محدد')} + الموقع: {selected_project.get('location', 'غير محدد')} + تاريخ البدء: {selected_project.get('start_date', 'غير محدد')} + تاريخ الانتهاء المتوقع: {selected_project.get('expected_end_date', 'غير محدد')} + الميزانية: {selected_project.get('budget', 'غير محدد')} + + وصف المشروع: {selected_project.get('description', 'لا يوجد وصف متاح')} + """ + + st.text_area( + "تفاصيل المشروع (يمكنك تعديلها قبل التحويل إلى صوت)", + value=project_details, + height=250, + key="voice_project_details" + ) + + content_text = st.session_state.voice_project_details + title = f"ملخص مشروع {selected_project['name']}" + else: + st.warning("لم يتم العثور على المشروع المحدد") + content_text = "" + title = "" + else: + st.info("لا توجد مشاريع متاحة حالياً") + content_text = "" + title = "" + + elif content_type == "ملخص مناقصة": + # عرض المناقصات المتاحة في النظام + tenders = self._get_tenders() + + if tenders: + selected_tender_id = st.selectbox( + "اختر المناقصة", + options=[t["id"] for t in tenders], + format_func=lambda x: next((t["name"] for t in tenders if t["id"] == x), ""), + key="voice_tender_id" + ) + + # العثور على المناقصة المحددة + selected_tender = next((t for t in tenders if t["id"] == selected_tender_id), None) + + if selected_tender: + # نعرض بيانات المناقصة + st.subheader(f"بيانات المناقصة: {selected_tender['name']}") + + tender_details = f""" + اسم المناقصة: {selected_tender['name']} + رقم المناقصة: {selected_tender['id']} + الجهة المالكة: {selected_tender.get('owner', 'غير محدد')} + تاريخ الطرح: {selected_tender.get('issue_date', 'غير محدد')} + تاريخ التسليم: {selected_tender.get('submission_date', 'غير محدد')} + القيمة التقديرية: {selected_tender.get('estimated_value', 'غير محدد')} + + وصف المناقصة: {selected_tender.get('description', 'لا يوجد وصف متاح')} + """ + + st.text_area( + "تفاصيل المناقصة (يمكنك تعديلها قبل التحويل إلى صوت)", + value=tender_details, + height=250, + key="voice_tender_details" + ) + + content_text = st.session_state.voice_tender_details + title = f"ملخص مناقصة {selected_tender['name']}" + else: + st.warning("لم يتم العثور على المناقصة المحددة") + content_text = "" + title = "" + else: + st.info("لا توجد مناقصات متاحة حالياً") + content_text = "" + title = "" + + elif content_type == "بنود عقد": + # عرض العقود المتاحة في النظام + contracts = self._get_contracts() + + if contracts: + selected_contract_id = st.selectbox( + "اختر العقد", + options=[c["id"] for c in contracts], + format_func=lambda x: next((c["name"] for c in contracts if c["id"] == x), ""), + key="voice_contract_id" + ) + + # العثور على العقد المحدد + selected_contract = next((c for c in contracts if c["id"] == selected_contract_id), None) + + if selected_contract: + # نعرض بيانات العقد + st.subheader(f"بيانات العقد: {selected_contract['name']}") + + # العثور على بنود العقد + contract_clauses = selected_contract.get("clauses", []) + + if contract_clauses: + # السماح للمستخدم باختيار البنود التي يريد تحويلها + selected_clauses = st.multiselect( + "اختر البنود المراد تحويلها إلى صوت", + options=list(range(len(contract_clauses))), + format_func=lambda i: f"البند {i+1}: {contract_clauses[i]['title']}", + key="voice_contract_clauses" + ) + + if selected_clauses: + # تجميع النصوص المختارة + clauses_text = "" + for i in selected_clauses: + clauses_text += f"البند {i+1}: {contract_clauses[i]['title']}\n" + clauses_text += f"{contract_clauses[i]['content']}\n\n" + + st.text_area( + "نص البنود المختارة (يمكنك تعديلها قبل التحويل إلى صوت)", + value=clauses_text, + height=250, + key="voice_contract_text" + ) + + content_text = st.session_state.voice_contract_text + title = f"بنود من عقد {selected_contract['name']}" + else: + st.info("الرجاء اختيار بند واحد على الأقل") + content_text = "" + title = "" + else: + st.info("لا توجد بنود متاحة لهذا العقد") + content_text = "" + title = "" + else: + st.warning("لم يتم العثور على العقد المحدد") + content_text = "" + title = "" + else: + st.info("لا توجد عقود متاحة حالياً") + content_text = "" + title = "" + + # إعدادات اللغة للنص المدخل + st.markdown("### إعدادات اللغة") + col1, col2 = st.columns(2) + + with col1: + source_language = st.selectbox( + "لغة النص المصدر", + options=list(self.supported_languages.keys()), + format_func=lambda x: self.supported_languages[x], + index=list(self.supported_languages.keys()).index(st.session_state.voice_settings["primary_language"]), + key="voice_source_language" + ) + + voice_id = st.selectbox( + "الصوت", + options=[v["id"] for v in self.voices_by_language[source_language]], + format_func=lambda x: next((v["name"] + f" ({v['gender']})" for v in self.voices_by_language[source_language] if v["id"] == x), ""), + index=0, + key="voice_source_voice" + ) + + with col2: + target_language = st.selectbox( + "لغة الترجمة (اختياري)", + options=["none"] + list(self.supported_languages.keys()), + format_func=lambda x: "بدون ترجمة" if x == "none" else self.supported_languages[x], + index=0, + key="voice_target_language" + ) + + if target_language != "none": + target_voice_id = st.selectbox( + "صوت الترجمة", + options=[v["id"] for v in self.voices_by_language[target_language]], + format_func=lambda x: next((v["name"] + f" ({v['gender']})" for v in self.voices_by_language[target_language] if v["id"] == x), ""), + index=0, + key="voice_target_voice" + ) + else: + target_voice_id = None + + # خيارات متقدمة + with st.expander("خيارات متقدمة"): + advanced_col1, advanced_col2 = st.columns(2) + + with advanced_col1: + speaking_rate = st.slider( + "سرعة النطق", + min_value=0.5, + max_value=2.0, + value=st.session_state.voice_settings["speaking_rate"], + step=0.1, + key="voice_speaking_rate" + ) + + include_subtitles = st.checkbox( + "تضمين النص مع الصوت", + value=st.session_state.voice_settings["include_subtitles"], + key="voice_include_subtitles" + ) + + with advanced_col2: + pitch = st.slider( + "درجة الصوت", + min_value=-10.0, + max_value=10.0, + value=st.session_state.voice_settings["pitch"], + step=1.0, + key="voice_pitch" + ) + + emphasize_keywords = st.checkbox( + "تمييز الكلمات المهمة", + value=st.session_state.voice_settings["emphasis_keywords"], + key="voice_emphasize_keywords" + ) + + # شروط إنشاء الترجمة الصوتية + create_voice_over = False + + if content_text: + # زر إنشاء الترجمة الصوتية + if styled_button("إنشاء الترجمة الصوتية", key="create_voice_over_btn", type="primary", icon="🎙️"): + if title: + create_voice_over = True + else: + st.warning("الرجاء إدخال عنوان للملف الصوتي") + else: + st.info("الرجاء إدخال أو اختيار محتوى للترجمة الصوتية") + + # إنشاء الترجمة الصوتية + if create_voice_over: + with st.spinner("جاري إنشاء الترجمة الصوتية..."): + try: + # تحقق من وجود الملف في الكاش + cache_key = self._generate_cache_key( + content_text, + source_language, + voice_id, + speaking_rate, + pitch + ) + + cached_file = self._get_from_cache(cache_key) + + if cached_file: + st.success("تم استرجاع الترجمة الصوتية من الكاش") + audio_file = cached_file + audio_duration = self._get_audio_duration(audio_file) + else: + # إنشاء الترجمة الصوتية + audio_file, audio_duration = self._generate_voice_over( + content_text, + source_language, + voice_id, + speaking_rate, + pitch + ) + + # حفظ الملف في الكاش + self._add_to_cache(cache_key, audio_file) + + # تسجيل الترجمة الصوتية في التاريخ + voice_over_id = self._add_voice_to_history( + title=title, + content=content_text, + source_language=source_language, + voice_id=voice_id, + duration=audio_duration, + audio_file=os.path.basename(audio_file), + content_type=content_type + ) + + # ترجمة المحتوى إذا تم اختيار لغة ترجمة + if target_language != "none": + with st.spinner(f"جاري الترجمة إلى {self.supported_languages[target_language]}..."): + # ترجمة النص + translated_text = self._translate_text( + content_text, + source_language, + target_language + ) + + # تحقق من وجود الملف المترجم في الكاش + translated_cache_key = self._generate_cache_key( + translated_text, + target_language, + target_voice_id, + speaking_rate, + pitch + ) + + cached_translated_file = self._get_from_cache(translated_cache_key) + + if cached_translated_file: + st.success("تم استرجاع الترجمة الصوتية المترجمة من الكاش") + translated_audio_file = cached_translated_file + translated_audio_duration = self._get_audio_duration(translated_audio_file) + else: + # إنشاء الترجمة الصوتية للنص المترجم + translated_audio_file, translated_audio_duration = self._generate_voice_over( + translated_text, + target_language, + target_voice_id, + speaking_rate, + pitch + ) + + # حفظ الملف المترجم في الكاش + self._add_to_cache(translated_cache_key, translated_audio_file) + + # تسجيل الترجمة الصوتية المترجمة في التاريخ + translated_voice_over_id = self._add_voice_to_history( + title=f"{title} ({self.supported_languages[target_language]})", + content=translated_text, + source_language=target_language, + voice_id=target_voice_id, + duration=translated_audio_duration, + audio_file=os.path.basename(translated_audio_file), + content_type=content_type, + is_translation=True, + original_id=voice_over_id + ) + + # عرض الترجمة الصوتية المترجمة + st.subheader(f"الترجمة الصوتية بـ{self.supported_languages[target_language]}") + + # عرض النص المترجم إذا تم اختيار ذلك + if include_subtitles: + st.markdown(f"**النص المترجم:**\n{translated_text}") + + # عرض مشغل الصوت + self._display_audio_player(translated_audio_file) + + # عرض الترجمة الصوتية + st.subheader(f"الترجمة الصوتية بـ{self.supported_languages[source_language]}") + + # عرض النص إذا تم اختيار ذلك + if include_subtitles: + st.markdown(f"**النص:**\n{content_text}") + + # عرض مشغل الصوت + self._display_audio_player(audio_file) + + # زر تنزيل الملف الصوتي + with open(audio_file, "rb") as f: + audio_bytes = f.read() + + st.download_button( + label="تنزيل الملف الصوتي", + data=audio_bytes, + file_name=f"{title}.mp3", + mime="audio/mpeg", + key="download_voice_over" + ) + + st.success("تم إنشاء الترجمة الصوتية بنجاح!") + + except Exception as e: + st.error(f"حدث خطأ أثناء إنشاء الترجمة الصوتية: {str(e)}") + self.logger.error(f"خطأ في إنشاء الترجمة الصوتية: {str(e)}") + + def _render_voice_over_manager(self): + """عرض واجهة مدير الترجمات الصوتية""" + st.markdown(""" +
+

🎧 مدير الترجمات الصوتية

+

استعراض وإدارة الترجمات الصوتية المخزنة.

+
+ """, unsafe_allow_html=True) + + # تحديث تاريخ الترجمات الصوتية + self.voice_history = self._load_voice_history() + + # التحقق من وجود ترجمات صوتية + if not self.voice_history: + st.info("لا توجد ترجمات صوتية مخزنة.") + return + + # أزرار التحكم + col1, col2 = st.columns(2) + + with col1: + # فلترة حسب نوع المحتوى + content_types = ["الكل"] + list(set(item.get("content_type", "نص حر") for item in self.voice_history)) + filter_content_type = st.selectbox( + "فلترة حسب نوع المحتوى", + options=content_types, + key="filter_content_type" + ) + + with col2: + # فلترة حسب اللغة + languages = ["الكل"] + [self.supported_languages.get(item.get("source_language", "ar"), "العربية") for item in self.voice_history] + filter_language = st.selectbox( + "فلترة حسب اللغة", + options=list(set(languages)), + key="filter_language" + ) + + # فلترة العناصر + filtered_history = self.voice_history + + if filter_content_type != "الكل": + filtered_history = [item for item in filtered_history if item.get("content_type", "نص حر") == filter_content_type] + + if filter_language != "الكل": + filtered_history = [ + item for item in filtered_history + if self.supported_languages.get(item.get("source_language", "ar"), "العربية") == filter_language + ] + + # عرض الترجمات الصوتية + for voice_item in filtered_history: + with st.expander(f"{voice_item['title']} ({voice_item.get('created_at', 'تاريخ غير معروف')})", expanded=False): + # تفاصيل الترجمة الصوتية + item_col1, item_col2 = st.columns([3, 1]) + + with item_col1: + st.markdown(f"**النوع:** {voice_item.get('content_type', 'نص حر')}") + st.markdown(f"**اللغة:** {self.supported_languages.get(voice_item.get('source_language', 'ar'), 'العربية')}") + st.markdown(f"**المدة:** {voice_item.get('duration', 0):.2f} ثانية") + + # عرض مشغل الصوت + audio_file_path = os.path.join(self.data_dir, voice_item.get('audio_file', '')) + if os.path.exists(audio_file_path): + self._display_audio_player(audio_file_path) + else: + st.warning("ملف الصوت غير متوفر") + + with item_col2: + # عرض النص + if st.button("عرض النص", key=f"show_text_{voice_item.get('id', '')}"): + st.text_area( + "نص الترجمة الصوتية", + value=voice_item.get('content', ''), + height=150, + key=f"text_{voice_item.get('id', '')}", + disabled=True + ) + + # تنزيل الملف الصوتي + audio_file_path = os.path.join(self.data_dir, voice_item.get('audio_file', '')) + if os.path.exists(audio_file_path): + with open(audio_file_path, "rb") as f: + audio_bytes = f.read() + + st.download_button( + label="تنزيل الملف الصوتي", + data=audio_bytes, + file_name=f"{voice_item['title']}.mp3", + mime="audio/mpeg", + key=f"download_{voice_item.get('id', '')}" + ) + + # حذف الترجمة الصوتية + if st.button("حذف", key=f"delete_{voice_item.get('id', '')}", type="primary"): + if self._delete_voice_from_history(voice_item.get('id', '')): + st.success("تم حذف الترجمة الصوتية بنجاح!") + st.rerun() + else: + st.error("حدث خطأ أثناء حذف الترجمة الصوتية") + + def _render_voice_settings(self): + """عرض واجهة إعدادات الصوت""" + st.markdown(""" +
+

⚙️ إعدادات الصوت

+

تخصيص إعدادات الترجمة الصوتية الافتراضية.

+
+ """, unsafe_allow_html=True) + + # إعدادات اللغة + st.markdown("### إعدادات اللغة") + + lang_col1, lang_col2 = st.columns(2) + + with lang_col1: + # اللغة الأساسية + primary_language = st.selectbox( + "اللغة الأساسية", + options=list(self.supported_languages.keys()), + format_func=lambda x: self.supported_languages[x], + index=list(self.supported_languages.keys()).index(st.session_state.voice_settings["primary_language"]), + key="settings_primary_language" + ) + + # الصوت الأساسي + primary_voice = st.selectbox( + "الصوت الأساسي", + options=[v["id"] for v in self.voices_by_language[primary_language]], + format_func=lambda x: next((v["name"] + f" ({v['gender']})" for v in self.voices_by_language[primary_language] if v["id"] == x), ""), + index=0, + key="settings_primary_voice" + ) + + with lang_col2: + # اللغة الثانوية + secondary_language = st.selectbox( + "اللغة الثانوية", + options=list(self.supported_languages.keys()), + format_func=lambda x: self.supported_languages[x], + index=list(self.supported_languages.keys()).index(st.session_state.voice_settings["secondary_language"]), + key="settings_secondary_language" + ) + + # الصوت الثانوي + secondary_voice = st.selectbox( + "الصوت الثانوي", + options=[v["id"] for v in self.voices_by_language[secondary_language]], + format_func=lambda x: next((v["name"] + f" ({v['gender']})" for v in self.voices_by_language[secondary_language] if v["id"] == x), ""), + index=0, + key="settings_secondary_voice" + ) + + # إعدادات جودة الصوت + st.markdown("### إعدادات جودة الصوت") + + quality_col1, quality_col2 = st.columns(2) + + with quality_col1: + # سرعة النطق + speaking_rate = st.slider( + "سرعة النطق الافتراضية", + min_value=0.5, + max_value=2.0, + value=st.session_state.voice_settings["speaking_rate"], + step=0.1, + key="settings_speaking_rate" + ) + + with quality_col2: + # درجة الصوت + pitch = st.slider( + "درجة الصوت الافتراضية", + min_value=-10.0, + max_value=10.0, + value=st.session_state.voice_settings["pitch"], + step=1.0, + key="settings_pitch" + ) + + # إعدادات أخرى + st.markdown("### إعدادات أخرى") + + other_col1, other_col2 = st.columns(2) + + with other_col1: + # الترجمة التلقائية + auto_translate = st.checkbox( + "ترجمة تلقائية إلى اللغة الثانوية", + value=st.session_state.voice_settings["auto_translate"], + key="settings_auto_translate" + ) + + # تضمين النص مع الصوت + include_subtitles = st.checkbox( + "تضمين النص مع الصوت افتراضياً", + value=st.session_state.voice_settings["include_subtitles"], + key="settings_include_subtitles" + ) + + with other_col2: + # تمييز الكلمات المهمة + emphasis_keywords = st.checkbox( + "تمييز الكلمات المهمة تلقائياً", + value=st.session_state.voice_settings["emphasis_keywords"], + key="settings_emphasis_keywords" + ) + + # زر حفظ الإعدادات + if styled_button("حفظ الإعدادات", key="save_voice_settings", type="primary", icon="💾"): + # تحديث الإعدادات + st.session_state.voice_settings = { + "primary_language": primary_language, + "secondary_language": secondary_language, + "primary_voice": primary_voice, + "secondary_voice": secondary_voice, + "speaking_rate": speaking_rate, + "pitch": pitch, + "auto_translate": auto_translate, + "include_subtitles": include_subtitles, + "emphasis_keywords": emphasis_keywords + } + + # حفظ الإعدادات + self._save_voice_settings() + + st.success("تم حفظ الإعدادات بنجاح!") + + # إعدادات متقدمة + with st.expander("إعدادات متقدمة", expanded=False): + st.markdown("### إعدادات الكاش") + + cache_size = self._get_cache_size() + st.markdown(f"حجم الكاش الحالي: {cache_size / (1024 * 1024):.2f} ميجابايت") + + if styled_button("مسح الكاش", key="clear_cache", type="danger", icon="🗑️"): + if self._clear_cache(): + st.success("تم مسح الكاش بنجاح!") + else: + st.error("حدث خطأ أثناء مسح الكاش") + + st.markdown("### إعدادات API") + + # نموذج API للترجمة الصوتية + api_model = st.selectbox( + "نموذج API للترجمة الصوتية", + options=["local", "huggingface", "google", "amazon", "microsoft"], + format_func=lambda x: { + "local": "محلي (عرض توضيحي)", + "huggingface": "Hugging Face", + "google": "Google Cloud Text-to-Speech", + "amazon": "Amazon Polly", + "microsoft": "Microsoft Azure" + }[x], + index=0, + key="api_model" + ) + + # معلومات حول النموذج المحدد + api_info = { + "local": "هذا وضع العرض التوضيحي حيث يتم تشبيه الترجمة الصوتية دون الحاجة إلى اتصال API خارجي.", + "huggingface": "استخدام Hugging Face API لتحويل النص إلى صوت وترجمة النصوص.", + "google": "استخدام Google Cloud Text-to-Speech لإنتاج صوت عالي الجودة.", + "amazon": "استخدام Amazon Polly للترجمة الصوتية بجودة عالية ومجموعة متنوعة من الأصوات.", + "microsoft": "استخدام Microsoft Azure Speech Services للترجمة الصوتية والترجمة." + } + + st.markdown(f"**معلومات:** {api_info[api_model]}") + + if api_model != "local": + api_key = st.text_input( + f"مفتاح API لـ {api_model}", + type="password", + key=f"{api_model}_api_key" + ) + + if styled_button("حفظ مفتاح API", key="save_api_key", type="primary"): + st.success(f"تم حفظ مفتاح API لـ {api_model} بنجاح!") + + def _render_document_narration(self): + """عرض واجهة ترجمة مستندات كاملة""" + st.markdown(""" +
+

📄 ترجمة مستندات كاملة

+

تحويل مستندات كاملة إلى ملفات صوتية وتقسيمها إلى فصول أو أقسام.

+
+ """, unsafe_allow_html=True) + + # اختيار المستند + documents = self._get_documents() + + if documents: + selected_document_id = st.selectbox( + "اختر المستند", + options=[d["id"] for d in documents], + format_func=lambda x: next((d["name"] for d in documents if d["id"] == x), ""), + key="narration_document_id" + ) + + # العثور على المستند المحدد + selected_document = next((d for d in documents if d["id"] == selected_document_id), None) + + if selected_document: + # عرض تفاصيل المستند + st.subheader(f"معلومات المستند: {selected_document['name']}") + + doc_col1, doc_col2 = st.columns(2) + + with doc_col1: + st.markdown(f"**النوع:** {selected_document.get('type', 'غير محدد')}") + st.markdown(f"**عدد الصفحات:** {selected_document.get('page_count', 'غير محدد')}") + + with doc_col2: + st.markdown(f"**حجم المستند:** {selected_document.get('file_size', 'غير محدد')}") + st.markdown(f"**تاريخ الرفع:** {selected_document.get('upload_date', 'غير محدد')}") + + # خيارات الترجمة الصوتية + st.markdown("### خيارات الترجمة الصوتية") + + options_col1, options_col2 = st.columns(2) + + with options_col1: + # اللغة + narration_language = st.selectbox( + "لغة الترجمة الصوتية", + options=list(self.supported_languages.keys()), + format_func=lambda x: self.supported_languages[x], + index=list(self.supported_languages.keys()).index(st.session_state.voice_settings["primary_language"]), + key="narration_language" + ) + + # الصوت + narration_voice = st.selectbox( + "الصوت", + options=[v["id"] for v in self.voices_by_language[narration_language]], + format_func=lambda x: next((v["name"] + f" ({v['gender']})" for v in self.voices_by_language[narration_language] if v["id"] == x), ""), + index=0, + key="narration_voice" + ) + + # تقسيم المستند + narration_split = st.selectbox( + "تقسيم المستند", + options=["لا تقسيم", "حسب الصفحات", "حسب العناوين", "حسب الفصول"], + key="narration_split" + ) + + with options_col2: + # سرعة النطق + narration_speaking_rate = st.slider( + "سرعة النطق", + min_value=0.5, + max_value=2.0, + value=st.session_state.voice_settings["speaking_rate"], + step=0.1, + key="narration_speaking_rate" + ) + + # درجة الصوت + narration_pitch = st.slider( + "درجة الصوت", + min_value=-10.0, + max_value=10.0, + value=st.session_state.voice_settings["pitch"], + step=1.0, + key="narration_pitch" + ) + + # تضمين الفهرس + narration_include_toc = st.checkbox( + "تضمين فهرس صوتي", + value=True, + key="narration_include_toc" + ) + + # خيارات إضافية + with st.expander("خيارات إضافية", expanded=False): + # تجاهل الصفحات + narration_skip_pages = st.text_input( + "تجاهل الصفحات (أرقام مفصولة بفواصل)", + placeholder="مثال: 1,2,5-7", + key="narration_skip_pages" + ) + + # إضافة مقدمة + narration_intro = st.text_area( + "مقدمة خاصة (سيتم إضافتها في بداية الترجمة الصوتية)", + placeholder="مقدمة اختيارية...", + key="narration_intro" + ) + + # إضافة خاتمة + narration_outro = st.text_area( + "خاتمة خاصة (سيتم إضافتها في نهاية الترجمة الصوتية)", + placeholder="خاتمة اختيارية...", + key="narration_outro" + ) + + # زر إنشاء الترجمة الصوتية للمستند + if styled_button("إنشاء الترجمة الصوتية للمستند", key="create_document_narration", type="primary", icon="🎙️"): + # تحقق من وجود قسم للترجمة الصوتية + narration_folder = os.path.join(self.data_dir, "document_narrations", str(selected_document_id)) + os.makedirs(narration_folder, exist_ok=True) + + # التقدم المستمر في إنشاء الترجمة الصوتية + progress_bar = st.progress(0) + status_text = st.empty() + + # صنع ترجمة صوتية وهمية (للعرض التوضيحي) + total_sections = 5 # عدد أقسام افتراضي + + for i in range(total_sections + 1): + # تحديث شريط التقدم + progress = i / total_sections + progress_bar.progress(progress) + + if i == 0: + status_text.text("جاري تحليل المستند...") + elif i == 1: + status_text.text("جاري استخراج النص...") + elif i < total_sections: + status_text.text(f"جاري إنشاء الترجمة الصوتية للقسم {i}...") + else: + status_text.text("جاري تجميع الملفات الصوتية النهائية...") + + time.sleep(1) # محاكاة العمل + + # اكتمال العملية + progress_bar.progress(1.0) + status_text.text("تم إنشاء الترجمة الصوتية بنجاح!") + + # إظهار نتائج وهمية + st.subheader("الترجمة الصوتية للمستند") + + # عرض المقاطع الصوتية (وهمية) + for i in range(1, total_sections): + with st.expander(f"القسم {i}: العنوان الافتراضي {i}", expanded=i==1): + # محاكاة وجود ملف صوتي + st.audio("https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3") + + # عرض معلومات القسم + st.markdown(f"**المدة:** {i + 2} دقائق") + st.markdown(f"**عدد الكلمات:** {i * 100} كلمة") + + # زر تنزيل الملف الكامل + st.download_button( + label="تنزيل الترجمة الصوتية الكاملة", + data=b"Mock audio file", # بيانات وهمية باللغة الإنجليزية + file_name=f"{selected_document['name']}_narration.mp3", + mime="audio/mpeg", + key="download_full_narration" + ) + else: + st.warning("لم يتم العثور على المستند المحدد") + else: + st.info("لا توجد مستندات متاحة حالياً") + + # اختيار رفع مستند جديد + st.markdown("### رفع مستند جديد للترجمة الصوتية") + + uploaded_file = st.file_uploader( + "اختر ملف للرفع (PDF, DOCX, TXT)", + type=["pdf", "docx", "txt"], + key="narration_upload_file" + ) + + if uploaded_file: + file_name = uploaded_file.name + + # عرض معلومات الملف + st.markdown(f"**اسم الملف:** {file_name}") + st.markdown(f"**حجم الملف:** {uploaded_file.size / 1024:.2f} كيلوبايت") + + document_name = st.text_input( + "اسم المستند", + value=file_name, + key="narration_document_name" + ) + + if styled_button("رفع المستند", key="upload_document", type="primary", icon="📤"): + st.success(f"تم رفع المستند '{document_name}' بنجاح!") + st.info("يمكنك الآن اختيار المستند لإنشاء ترجمة صوتية له.") + st.rerun() + + def _render_voice_over_analytics(self): + """عرض إحصائيات ومقاييس الترجمات الصوتية""" + st.markdown(""" +
+

📊 إحصائيات ومقاييس

+

إحصائيات ومقاييس استخدام نظام الترجمة الصوتية.

+
+ """, unsafe_allow_html=True) + + # تحديث تاريخ الترجمات الصوتية + self.voice_history = self._load_voice_history() + + # التحقق من وجود ترجمات صوتية + if not self.voice_history: + st.info("لا توجد ترجمات صوتية مخزنة.") + return + + # إحصائيات عامة + st.markdown("### إحصائيات عامة") + + # تحضير البيانات + total_voices = len(self.voice_history) + total_duration = sum(item.get("duration", 0) for item in self.voice_history) + total_languages = len(set(item.get("source_language", "ar") for item in self.voice_history)) + + # عرض الإحصائيات + stats_col1, stats_col2, stats_col3 = st.columns(3) + + with stats_col1: + st.metric("إجمالي الترجمات الصوتية", total_voices) + + with stats_col2: + st.metric("إجمالي المدة", f"{total_duration:.2f} ثانية") + + with stats_col3: + st.metric("عدد اللغات المستخدمة", total_languages) + + # رسم بياني لتوزيع الترجمات الصوتية حسب اللغة + st.markdown("### توزيع الترجمات الصوتية حسب اللغة") + + language_counts = {} + for item in self.voice_history: + lang = item.get("source_language", "ar") + lang_name = self.supported_languages.get(lang, "غير معروف") + language_counts[lang_name] = language_counts.get(lang_name, 0) + 1 + + # تحويل إلى DataFrame + language_df = pd.DataFrame({ + "اللغة": list(language_counts.keys()), + "العدد": list(language_counts.values()) + }) + + # رسم بياني دائري + import plotly.express as px + + fig1 = px.pie( + language_df, + values="العدد", + names="اللغة", + title="توزيع الترجمات الصوتية حسب اللغة", + color_discrete_sequence=px.colors.qualitative.Pastel + ) + + fig1.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig1, use_container_width=True) + + # رسم بياني لتوزيع الترجمات الصوتية حسب نوع المحتوى + st.markdown("### توزيع الترجمات الصوتية حسب نوع المحتوى") + + content_type_counts = {} + for item in self.voice_history: + content_type = item.get("content_type", "نص حر") + content_type_counts[content_type] = content_type_counts.get(content_type, 0) + 1 + + # تحويل إلى DataFrame + content_df = pd.DataFrame({ + "نوع المحتوى": list(content_type_counts.keys()), + "العدد": list(content_type_counts.values()) + }) + + # رسم بياني شريطي + fig2 = px.bar( + content_df, + x="نوع المحتوى", + y="العدد", + title="توزيع الترجمات الصوتية حسب نوع المحتوى", + color="نوع المحتوى", + color_discrete_sequence=px.colors.qualitative.Pastel + ) + + fig2.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig2, use_container_width=True) + + # رسم بياني لتوزيع الترجمات الصوتية حسب التاريخ + st.markdown("### توزيع الترجمات الصوتية حسب التاريخ") + + # استخراج التواريخ + dates = [] + for item in self.voice_history: + created_at = item.get("created_at", "") + if created_at: + try: + date = datetime.datetime.strptime(created_at.split(" ")[0], "%Y-%m-%d").date() + dates.append(date) + except (ValueError, IndexError): + continue + + if dates: + # عد التكرارات + date_counts = {} + for date in dates: + date_str = date.strftime("%Y-%m-%d") + date_counts[date_str] = date_counts.get(date_str, 0) + 1 + + # تحويل إلى DataFrame + date_df = pd.DataFrame({ + "التاريخ": list(date_counts.keys()), + "العدد": list(date_counts.values()) + }) + + # ترتيب حسب التاريخ + date_df["التاريخ"] = pd.to_datetime(date_df["التاريخ"]) + date_df = date_df.sort_values("التاريخ") + + # رسم بياني خطي + fig3 = px.line( + date_df, + x="التاريخ", + y="العدد", + title="توزيع الترجمات الصوتية حسب التاريخ", + markers=True + ) + + fig3.update_layout( + title_font_size=20, + font_family="Arial", + font_size=14, + height=400 + ) + + st.plotly_chart(fig3, use_container_width=True) + else: + st.info("لا توجد بيانات تاريخ كافية لعرض الرسم البياني") + + # تصدير البيانات + st.markdown("### تصدير البيانات") + + export_col1, export_col2 = st.columns(2) + + with export_col1: + if styled_button("تصدير CSV", key="export_voice_csv", type="primary", icon="📄"): + # تحويل البيانات إلى DataFrame + export_df = pd.DataFrame(self.voice_history) + + # تنزيل الملف + csv_data = export_df.to_csv(index=False) + + st.download_button( + label="تنزيل ملف CSV", + data=csv_data, + file_name=f"voice_over_history_{datetime.datetime.now().strftime('%Y%m%d')}.csv", + mime="text/csv", + key="download_voice_csv" + ) + + with export_col2: + if styled_button("تصدير JSON", key="export_voice_json", type="primary", icon="📄"): + # تحويل البيانات إلى JSON + json_data = json.dumps(self.voice_history, indent=2) + + st.download_button( + label="تنزيل ملف JSON", + data=json_data, + file_name=f"voice_over_history_{datetime.datetime.now().strftime('%Y%m%d')}.json", + mime="application/json", + key="download_voice_json" + ) + + def _generate_voice_over(self, text, language, voice_id, speaking_rate=1.0, pitch=0.0): + """ + إنشاء ترجمة صوتية (محاكاة) + + المعلمات: + text: النص المراد تحويله إلى صوت + language: رمز اللغة + voice_id: معرف الصوت + speaking_rate: سرعة النطق + pitch: درجة الصوت + + الإرجاع: + مسار الملف الصوتي ومدته + """ + try: + # في الوضع العادي، سنستخدم API لتحويل النص إلى صوت + # هنا نستخدم ملف صوتي وهمي للعرض التوضيحي + + # إنشاء ملف مؤقت لتمثيل الصوت + temp_file = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + temp_file.close() + + # نسخ ملف صوتي وهمي (يمكن استبداله بإنشاء فعلي للصوت) + audio_file = os.path.join(self.data_dir, f"voice_{language}_{voice_id}_{int(time.time())}.mp3") + + # محاكاة إنشاء ملف صوتي باستخدام صوت بسيط + # هنا يمكن استبدال هذا بمكالمة API حقيقية لتحويل النص إلى صوت + import wave + import struct + + # إنشاء بيانات صوتية بسيطة + duration = len(text) * 0.1 # تقدير المدة بناءً على طول النص + sample_rate = 44100 # معدل العينات + + # محاكاة تأثير سرعة النطق على المدة + duration = duration / speaking_rate + + # إنشاء ملف WAV مؤقت + wav_file = temp_file.name.replace(".mp3", ".wav") + + with wave.open(wav_file, "w") as f: + f.setnchannels(1) # أحادي القناة + f.setsampwidth(2) # 16 بت + f.setframerate(sample_rate) + + # محاكاة صوت بسيط (موجة جيبية) + for i in range(int(duration * sample_rate)): + # تأثير درجة الصوت + value = 32767 * 0.3 * np.sin(2 * np.pi * (440 + pitch * 20) * i / sample_rate) + f.writeframes(struct.pack('h', int(value))) + + # تحويل WAV إلى MP3 (في التطبيق الفعلي) + # هنا نفترض أن التحويل تم بنجاح + import shutil + shutil.copy(wav_file, audio_file) + + # تنظيف الملفات المؤقتة + try: + os.remove(wav_file) + os.remove(temp_file.name) + except: + pass + + # تسجيل العملية + self.logger.info(f"تم إنشاء ترجمة صوتية للنص (طول: {len(text)}) باللغة: {language}") + + return audio_file, duration + + except Exception as e: + self.logger.error(f"خطأ في إنشاء الترجمة الصوتية: {str(e)}") + raise e + + def _translate_text(self, text, source_language, target_language): + """ + ترجمة نص من لغة إلى أخرى (محاكاة) + + المعلمات: + text: النص المراد ترجمته + source_language: رمز اللغة المصدر + target_language: رمز اللغة الهدف + + الإرجاع: + النص المترجم + """ + try: + # في الوضع العادي، سنستخدم API للترجمة + # هنا نستخدم ترجمة وهمية للعرض التوضيحي + + # تسجيل العملية + self.logger.info(f"ترجمة نص (طول: {len(text)}) من {source_language} إلى {target_language}") + + # ترجمة وهمية + translated_prefix = { + "en": "This is a sample translation of the text into English.", + "ar": "هذه ترجمة عينة للنص إلى اللغة العربية.", + "fr": "Ceci est un exemple de traduction du texte en français.", + "es": "Esta es una traducción de muestra del texto al español.", + "de": "Dies ist eine Beispielübersetzung des Textes ins Deutsche.", + "it": "Questa è una traduzione di esempio del testo in italiano.", + "zh": "这是文本翻译成中文的示例。", + "ja": "これはテキストの日本語への翻訳例です。", + "ru": "Это пример перевода текста на русский язык.", + "tr": "Bu, metnin Türkçe çevirisinin bir örneğidir." + } + + # إرجاع ترجمة وهمية + return f"{translated_prefix.get(target_language, 'Translated sample')} {text[:100]}..." + + except Exception as e: + self.logger.error(f"خطأ في ترجمة النص: {str(e)}") + raise e + + def _get_audio_duration(self, audio_file): + """ + الحصول على مدة ملف صوتي + + المعلمات: + audio_file: مسار الملف الصوتي + + الإرجاع: + مدة الملف الصوتي بالثواني + """ + try: + if audio_file.endswith(".wav"): + # استخدام wave للحصول على مدة ملف WAV + with wave.open(audio_file, "rb") as f: + frames = f.getnframes() + rate = f.getframerate() + duration = frames / float(rate) + else: + # محاكاة لمدة الملف الصوتي + size_in_bytes = os.path.getsize(audio_file) + duration = size_in_bytes / 16000 # تقريب بسيط + + return duration + + except Exception as e: + self.logger.error(f"خطأ في الحصول على مدة الملف الصوتي: {str(e)}") + return 30.0 # قيمة افتراضية + + def _get_from_cache(self, cache_key): + """ + البحث عن ملف في الكاش + + المعلمات: + cache_key: مفتاح الكاش + + الإرجاع: + مسار الملف إذا وجد، وإلا None + """ + if cache_key in self.cache_index: + cache_file = os.path.join(self.cache_dir, self.cache_index[cache_key]) + if os.path.exists(cache_file): + return cache_file + + return None + + def _add_to_cache(self, cache_key, file_path): + """ + إضافة ملف إلى الكاش + + المعلمات: + cache_key: مفتاح الكاش + file_path: مسار الملف + + الإرجاع: + True إذا تمت الإضافة بنجاح، وإلا False + """ + try: + # نسخ الملف إلى الكاش + cache_file = os.path.join(self.cache_dir, os.path.basename(file_path)) + + if file_path != cache_file: + shutil.copy(file_path, cache_file) + + # تحديث فهرس الكاش + self.cache_index[cache_key] = os.path.basename(file_path) + + # حفظ الفهرس + with open(self.cache_index_file, "w", encoding="utf-8") as f: + json.dump(self.cache_index, f, ensure_ascii=False, indent=2) + + return True + + except Exception as e: + self.logger.error(f"خطأ في إضافة الملف إلى الكاش: {str(e)}") + return False + + def _generate_cache_key(self, text, language, voice_id, speaking_rate, pitch): + """ + إنشاء مفتاح كاش للترجمة الصوتية + + المعلمات: + text: النص + language: اللغة + voice_id: معرف الصوت + speaking_rate: سرعة النطق + pitch: درجة الصوت + + الإرجاع: + مفتاح الكاش + """ + import hashlib + + # إنشاء نص للتجزئة + cache_text = f"{text}|{language}|{voice_id}|{speaking_rate}|{pitch}" + + # إنشاء تجزئة MD5 + hash_obj = hashlib.md5(cache_text.encode()) + + return hash_obj.hexdigest() + + def _load_cache_index(self): + """ + تحميل فهرس الكاش + + الإرجاع: + قاموس فهرس الكاش + """ + if os.path.exists(self.cache_index_file): + try: + with open(self.cache_index_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + self.logger.error(f"خطأ في تحميل فهرس الكاش: {str(e)}") + + return {} + + def _get_cache_size(self): + """ + الحصول على حجم الكاش بالبايت + + الإرجاع: + حجم الكاش بالبايت + """ + total_size = 0 + + for filename in os.listdir(self.cache_dir): + file_path = os.path.join(self.cache_dir, filename) + if os.path.isfile(file_path): + total_size += os.path.getsize(file_path) + + return total_size + + def _clear_cache(self): + """ + مسح كاش الترجمات الصوتية + + الإرجاع: + True إذا تم المسح بنجاح، وإلا False + """ + try: + # حذف جميع الملفات في الكاش + for filename in os.listdir(self.cache_dir): + file_path = os.path.join(self.cache_dir, filename) + if os.path.isfile(file_path): + os.remove(file_path) + + # إعادة تعيين فهرس الكاش + self.cache_index = {} + + # حفظ الفهرس الفارغ + with open(self.cache_index_file, "w", encoding="utf-8") as f: + json.dump(self.cache_index, f, ensure_ascii=False, indent=2) + + return True + + except Exception as e: + self.logger.error(f"خطأ في مسح الكاش: {str(e)}") + return False + + def _add_voice_to_history(self, title, content, source_language, voice_id, duration, audio_file, content_type="نص حر", is_translation=False, original_id=None): + """ + إضافة ترجمة صوتية إلى التاريخ + + المعلمات: + title: عنوان الترجمة الصوتية + content: محتوى النص + source_language: اللغة المصدر + voice_id: معرف الصوت + duration: مدة الترجمة الصوتية + audio_file: اسم ملف الترجمة الصوتية + content_type: نوع المحتوى + is_translation: هل هي ترجمة لنص آخر + original_id: معرف النص الأصلي + + الإرجاع: + معرف الترجمة الصوتية + """ + try: + # إنشاء معرف فريد + voice_id = f"voice_{int(time.time())}_{len(self.voice_history)}" + + # إنشاء كائن الترجمة الصوتية + voice_item = { + "id": voice_id, + "title": title, + "content": content, + "source_language": source_language, + "voice_id": voice_id, + "duration": duration, + "audio_file": audio_file, + "content_type": content_type, + "created_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "is_translation": is_translation, + "original_id": original_id + } + + # إضافة إلى التاريخ + self.voice_history.append(voice_item) + + # حفظ التاريخ + self._save_voice_history() + + return voice_id + + except Exception as e: + self.logger.error(f"خطأ في إضافة الترجمة الصوتية إلى التاريخ: {str(e)}") + return None + + def _delete_voice_from_history(self, voice_id): + """ + حذف ترجمة صوتية من التاريخ + + المعلمات: + voice_id: معرف الترجمة الصوتية + + الإرجاع: + True إذا تم الحذف بنجاح، وإلا False + """ + try: + # البحث عن الترجمة الصوتية + for i, item in enumerate(self.voice_history): + if item.get("id") == voice_id: + # حذف الملف الصوتي + audio_file = os.path.join(self.data_dir, item.get("audio_file", "")) + if os.path.exists(audio_file): + os.remove(audio_file) + + # حذف العنصر من التاريخ + del self.voice_history[i] + + # حفظ التاريخ + self._save_voice_history() + + return True + + return False + + except Exception as e: + self.logger.error(f"خطأ في حذف الترجمة الصوتية من التاريخ: {str(e)}") + return False + + def _load_voice_history(self): + """ + تحميل تاريخ الترجمات الصوتية + + الإرجاع: + قائمة تاريخ الترجمات الصوتية + """ + if os.path.exists(self.voice_history_file): + try: + with open(self.voice_history_file, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + self.logger.error(f"خطأ في تحميل تاريخ الترجمات الصوتية: {str(e)}") + + return [] + + def _save_voice_history(self): + """ + حفظ تاريخ الترجمات الصوتية + + الإرجاع: + True إذا تم الحفظ بنجاح، وإلا False + """ + try: + # التأكد من وجود المجلد + os.makedirs(os.path.dirname(self.voice_history_file), exist_ok=True) + + # حفظ التاريخ + with open(self.voice_history_file, "w", encoding="utf-8") as f: + json.dump(self.voice_history, f, ensure_ascii=False, indent=2) + + return True + + except Exception as e: + self.logger.error(f"خطأ في حفظ تاريخ الترجمات الصوتية: {str(e)}") + return False + + def _save_voice_settings(self): + """ + حفظ إعدادات الترجمة الصوتية + + الإرجاع: + True إذا تم الحفظ بنجاح، وإلا False + """ + try: + # التأكد من وجود المجلد + os.makedirs(self.data_dir, exist_ok=True) + + # حفظ الإعدادات + settings_file = os.path.join(self.data_dir, "voice_settings.json") + + with open(settings_file, "w", encoding="utf-8") as f: + json.dump(st.session_state.voice_settings, f, ensure_ascii=False, indent=2) + + return True + + except Exception as e: + self.logger.error(f"خطأ في حفظ إعدادات الترجمة الصوتية: {str(e)}") + return False + + def _display_audio_player(self, audio_file): + """ + عرض مشغل الصوت + + المعلمات: + audio_file: مسار الملف الصوتي + """ + if os.path.exists(audio_file): + # قراءة الملف الصوتي + with open(audio_file, "rb") as f: + audio_bytes = f.read() + + # عرض مشغل الصوت + st.audio(audio_bytes, format="audio/mp3") + else: + st.warning("الملف الصوتي غير متوفر") + + def _get_projects(self): + """ + الحصول على قائمة المشاريع + + الإرجاع: + قائمة المشاريع + """ + # في التطبيق الفعلي، سيتم جلب البيانات من قاعدة البيانات + # هنا نستخدم بيانات وهمية للعرض التوضيحي + return [ + { + "id": "PRJ001", + "name": "مشروع تطوير البنية التحتية لمنطقة الرياض", + "status": "قيد التنفيذ", + "location": "الرياض", + "start_date": "2025-01-15", + "expected_end_date": "2026-06-30", + "budget": "15,000,000 ريال", + "description": "مشروع تطوير البنية التحتية في منطقة الرياض، ويشمل إنشاء طرق جديدة وتطوير شبكات الصرف الصحي وتحسين شبكات المياه والكهرباء." + }, + { + "id": "PRJ002", + "name": "إنشاء مجمع سكني في جدة", + "status": "جديد", + "location": "جدة", + "start_date": "2025-04-01", + "expected_end_date": "2027-03-31", + "budget": "25,000,000 ريال", + "description": "مشروع إنشاء مجمع سكني في مدينة جدة، ويتكون من 50 فيلا و 100 شقة سكنية، بالإضافة إلى مرافق خدمية ومناطق ترفيهية." + }, + { + "id": "PRJ003", + "name": "توسعة مستشفى الملك فهد", + "status": "قيد التنفيذ", + "location": "الدمام", + "start_date": "2024-10-15", + "expected_end_date": "2026-02-28", + "budget": "18,500,000 ريال", + "description": "مشروع توسعة مستشفى الملك فهد في مدينة الدمام، ويشمل إضافة مبنى جديد للعيادات الخارجية وزيادة عدد أسرّة المستشفى." + } + ] + + def _get_tenders(self): + """ + الحصول على قائمة المناقصات + + الإرجاع: + قائمة المناقصات + """ + # في التطبيق الفعلي، سيتم جلب البيانات من قاعدة البيانات + # هنا نستخدم بيانات وهمية للعرض التوضيحي + return [ + { + "id": "TND001", + "name": "مناقصة تطوير طريق الملك عبدالله", + "owner": "وزارة النقل", + "issue_date": "2025-02-10", + "submission_date": "2025-03-15", + "estimated_value": "12,000,000 ريال", + "description": "مناقصة لتطوير وتوسعة طريق الملك عبدالله بطول 15 كم، وتشمل الأعمال إنشاء مسارات جديدة وتحسين البنية التحتية للطريق." + }, + { + "id": "TND002", + "name": "مناقصة إنشاء مدرسة ثانوية", + "owner": "وزارة التعليم", + "issue_date": "2025-01-20", + "submission_date": "2025-02-25", + "estimated_value": "8,500,000 ريال", + "description": "مناقصة لإنشاء مدرسة ثانوية جديدة في حي النزهة بمدينة الرياض، وتشمل الأعمال إنشاء مبنى المدرسة والمرافق التابعة لها." + }, + { + "id": "TND003", + "name": "مناقصة صيانة وتأهيل محطات تحلية المياه", + "owner": "المؤسسة العامة لتحلية المياه المالحة", + "issue_date": "2025-03-01", + "submission_date": "2025-04-15", + "estimated_value": "22,000,000 ريال", + "description": "مناقصة لصيانة وتأهيل محطات تحلية المياه في المنطقة الشرقية، وتشمل الأعمال استبدال المعدات القديمة وتطوير أنظمة التحكم." + } + ] + + def _get_contracts(self): + """ + الحصول على قائمة العقود + + الإرجاع: + قائمة العقود + """ + # في التطبيق الفعلي، سيتم جلب البيانات من قاعدة البيانات + # هنا نستخدم بيانات وهمية للعرض التوضيحي + return [ + { + "id": "CNT001", + "name": "عقد إنشاء مجمع سكني", + "client": "شركة الرياض للتطوير العقاري", + "start_date": "2025-04-15", + "end_date": "2027-04-14", + "value": "28,500,000 ريال", + "clauses": [ + { + "title": "نطاق الأعمال", + "content": "يشمل نطاق الأعمال في هذا العقد إنشاء مجمع سكني مكون من 40 فيلا و 80 شقة سكنية، بالإضافة إلى المرافق الخدمية والترفيهية." + }, + { + "title": "مدة التنفيذ", + "content": "مدة تنفيذ المشروع 24 شهراً تبدأ من تاريخ استلام الموقع، ويمكن تمديد المدة في حال وجود ظروف قاهرة يتفق عليها الطرفان." + }, + { + "title": "قيمة العقد وطريقة الدفع", + "content": "قيمة العقد الإجمالية هي 28,500,000 ريال سعودي، ويتم السداد على دفعات شهرية بناءً على نسبة الإنجاز في المشروع." + }, + { + "title": "الضمانات", + "content": "يلتزم المقاول بتقديم ضمان بنكي بقيمة 5% من قيمة العقد لضمان حسن التنفيذ، وضمان صيانة لمدة سنة بعد الانتهاء من المشروع." + }, + { + "title": "الغرامات والجزاءات", + "content": "في حال تأخر المقاول عن تسليم المشروع في الموعد المحدد، يتم فرض غرامة تأخير بنسبة 0.1% من قيمة العقد عن كل يوم تأخير، بحد أقصى 10% من قيمة العقد." + } + ] + }, + { + "id": "CNT002", + "name": "عقد توريد وتركيب أنظمة تكييف", + "client": "شركة التطوير العقاري المحدودة", + "start_date": "2025-03-01", + "end_date": "2025-08-31", + "value": "4,200,000 ريال", + "clauses": [ + { + "title": "نطاق التوريد", + "content": "يشمل نطاق التوريد في هذا العقد توفير وتركيب 120 وحدة تكييف مركزي للمبنى الإداري الجديد، بالإضافة إلى خدمات الصيانة لمدة عام." + }, + { + "title": "مواصفات الأجهزة", + "content": "يجب أن تكون جميع الأجهزة الموردة من إحدى العلامات التجارية المعتمدة (كارير، دايكن، أو ميتسوبيشي)، وأن تكون مطابقة للمواصفات الفنية المرفقة بالعقد." + }, + { + "title": "مدة التوريد والتركيب", + "content": "يلتزم المورد بتوريد وتركيب جميع الأجهزة خلال مدة لا تتجاوز 6 أشهر من تاريخ توقيع العقد." + }, + { + "title": "الضمان", + "content": "يقدم المورد ضماناً لجميع الأجهزة لمدة 3 سنوات من تاريخ التشغيل، ويشمل الضمان جميع أعمال الصيانة وقطع الغيار." + } + ] + } + ] + + def _get_documents(self): + """ + الحصول على قائمة المستندات + + الإرجاع: + قائمة المستندات + """ + # في التطبيق الفعلي، سيتم جلب البيانات من قاعدة البيانات + # هنا نستخدم بيانات وهمية للعرض التوضيحي + return [ + { + "id": "DOC001", + "name": "كراسة شروط مناقصة تطوير طريق الملك عبدالله", + "type": "كراسة شروط", + "page_count": 85, + "file_size": "2.4 ميجابايت", + "upload_date": "2025-02-10" + }, + { + "id": "DOC002", + "name": "عقد إنشاء مجمع سكني", + "type": "عقد", + "page_count": 42, + "file_size": "1.8 ميجابايت", + "upload_date": "2025-04-12" + }, + { + "id": "DOC003", + "name": "تقرير دراسة جدوى مشروع توسعة مستشفى", + "type": "تقرير", + "page_count": 65, + "file_size": "3.1 ميجابايت", + "upload_date": "2025-01-25" + } + ] + + +# تطبيق وحدة الترجمة الصوتية متعددة اللغات +class VoiceNarrationApp: + """وحدة تطبيق الترجمة الصوتية متعددة اللغات""" + + def __init__(self): + """تهيئة وحدة تطبيق الترجمة الصوتية متعددة اللغات""" + self.voice_over_system = VoiceOverSystem() + + def render(self): + """عرض واجهة وحدة تطبيق الترجمة الصوتية متعددة اللغات""" + st.markdown("

نظام الترجمة الصوتية متعددة اللغات

", unsafe_allow_html=True) + + st.markdown(""" +
+ يتيح لك نظام الترجمة الصوتية متعددة اللغات تحويل النصوص والمستندات إلى ملفات صوتية بلغات متعددة، + مما يساعد في توصيل المعلومات بشكل أفضل للأشخاص من خلفيات لغوية مختلفة. +
+ """, unsafe_allow_html=True) + + # عرض نظام الترجمة الصوتية + self.voice_over_system.render() + + +# تشغيل التطبيق بشكل مستقل عند استدعاء الملف مباشرة +if __name__ == "__main__": + st.set_page_config( + page_title="الترجمة الصوتية متعددة اللغات | WAHBi AI", + page_icon="🎙️", + layout="wide", + initial_sidebar_state="expanded" + ) + + app = VoiceNarrationApp() + app.render() \ No newline at end of file diff --git a/packages.txt b/packages.txt new file mode 100644 index 0000000000000000000000000000000000000000..0e30d39a0c1d186c9faa85df171fdd0cc5fdde1d --- /dev/null +++ b/packages.txt @@ -0,0 +1,7 @@ +libgl1-mesa-glx +poppler-utils +tesseract-ocr +libtesseract-dev +tesseract-ocr-ara +tesseract-ocr-eng +fonts-freefont-ttf \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..5d9ccee38544eeebf4bfdf01bcdf5872a46850e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,98 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "document-analysis-app" +version = "0.1.0" +description = "تطبيق تحليل المستندات باستخدام Docling و MLX VLM" +requires-python = ">=3.8" +dependencies = [ + # الاعتماديات الأساسية + "streamlit==1.32.0", + "pandas==2.2.0", + "numpy==1.26.3", + "matplotlib==3.8.2", + "seaborn==0.13.1", + "plotly==5.18.0", + + # معالجة البيانات + "openpyxl==3.1.2", + "xlrd==2.0.1", + "xlsxwriter==3.1.9", + "pyarrow==14.0.1", + + # تحليل المستندات + "PyPDF2==3.0.1", + "python-docx==1.1.0", + "pdf2image==1.17.0", + "pytesseract==0.3.10", + "pymupdf==1.23.7", + "pdfplumber==0.10.3", + "opencv-python-headless==4.8.1.78", + + # معالجة اللغة العربية + "arabic-reshaper==3.0.0", + "python-bidi==0.4.2", + "langdetect==1.0.9", + "farasapy==0.0.14", + + # الذكاء الاصطناعي والتعلم الآلي + "scikit-learn==1.4.0", + "transformers>=4.49.0", # تم تحديث الإصدار ليتوافق مع mlx-vlm + "torch==2.1.2", + "nltk==3.8.1", + "gensim==4.3.2", + + # قواعد البيانات + "SQLAlchemy==2.0.25", + "SQLAlchemy-Utils==0.41.1", + "alembic==1.13.1", + "sqlite-utils==3.35.1", + + # مكونات واجهة المستخدم + "streamlit-option-menu==0.3.2", + "streamlit-elements==0.1.0", + "streamlit-aggrid==0.3.4.post3", + "streamlit-authenticator==0.2.3", + "streamlit-extras==0.3.5", + "streamlit-image-coordinates==0.1.6", + + # أدوات وتبعيات إضافية + "pycountry==23.12.11", + "watchdog==3.0.0", + "python-dateutil==2.8.2", + "python-dotenv==1.0.0", + "requests==2.31.0", + "tqdm>=4.66.2", + "joblib==1.3.2", + "ipython==8.20.0", + + # مكتبات Docling و MLX VLM للتحليل المتقدم + "docling-core>=0.1.0", + "mlx-vlm>=0.1.0", + "mlx>=0.0.4", + "pillow>=10.3.0", # تم تحديث الإصدار ليتوافق مع mlx-vlm + "protobuf>=3.19.0,<4.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=6.0", + "black>=22.1.0", + "flake8>=4.0.0", +] + +[tool.setuptools] +packages = ["modules"] + +# متطلبات النموذج +[tool.script] +requires-python = ">=3.8" +dependencies = [ + "docling-core", + "mlx-vlm", + "pillow>=10.3.0", + "tqdm>=4.66.2", + "transformers>=4.49.0" +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9b64e97b16dfdbffdf02df1f741d6e31c488236a..f65b8f01ae5f543aae2ab2565e0254c5b8de5497 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,68 +1,17 @@ -# الاعتماديات الأساسية -streamlit==1.32.0 -pandas==2.2.0 -numpy==1.26.3 -matplotlib==3.8.2 -seaborn==0.13.1 -plotly==5.18.0 - -# معالجة البيانات +streamlit==1.25.0 +pandas==2.0.3 +numpy==1.25.2 +matplotlib==3.7.2 +plotly==5.15.0 openpyxl==3.1.2 -xlrd==2.0.1 -xlsxwriter==3.1.9 -pyarrow==14.0.1 - -# تحليل المستندات -PyPDF2==3.0.1 -python-docx==1.1.0 -pdf2image==1.17.0 -pytesseract==0.3.10 -pymupdf==1.23.7 -pdfplumber==0.10.3 -opencv-python-headless==4.8.1.78 -# poppler-utils ← يُثبت من apt - -# معالجة اللغة العربية -arabic-reshaper==3.0.0 -python-bidi==0.4.2 -langdetect==1.0.9 -farasapy==0.0.14 -# cameltools==1.1.0 - -# الذكاء الاصطناعي والتعلم الآلي -scikit-learn==1.4.0 -transformers==4.39.3 -torch==2.1.2 -nltk==3.8.1 -gensim==4.3.2 -openai==1.69.0 -anthropic==0.5.0 -pydantic==2.3.0 -joblib==1.3.2 - -# قواعد البيانات -SQLAlchemy==2.0.25 -SQLAlchemy-Utils==0.41.1 -alembic==1.13.1 -sqlite-utils==3.35.1 - -# مكونات واجهة المستخدم -streamlit-option-menu==0.3.2 -streamlit-elements==0.1.0 -streamlit-aggrid==0.3.4.post3 -streamlit-authenticator==0.2.3 -streamlit-extras==0.3.5 streamlit-echarts==0.4.0 -streamlit-image-coordinates==0.1.6 - -# أدوات وتبعيات إضافية -pycountry==23.12.11 -watchdog==3.0 -folium==0.16.0 -streamlit-folium==0.18.0 -python-dotenv==1.0.0 -jsonschema==4.19.0 +xlrd==2.0.1 +scikit-learn==1.3.0 +anthropic==0.3.4 +openai==0.27.8 pytest==7.4.0 pytest-cov==4.1.0 -reportlab==4.0.8 -rouge-score==0.1.2 +python-dotenv==1.0.0 +jsonschema==4.19.0 +nltk==3.8.1 +pydantic==2.3.0 \ No newline at end of file diff --git a/static/css/enhanced-styles.css b/static/css/enhanced-styles.css new file mode 100644 index 0000000000000000000000000000000000000000..d58d25a1d00c6190904e146091d98900265b1b08 --- /dev/null +++ b/static/css/enhanced-styles.css @@ -0,0 +1,913 @@ +/* أنماط CSS المحسنة للنظام - تم التحديث 2025 */ +@import url('https://fonts.googleapis.com/css2?family=Almarai:wght@300;400;700;800&family=Cairo:wght@200;300;400;500;600;700;800;900&family=Tajawal:wght@200;300;400;500;700;800;900&display=swap'); + +:root { + --primary-color: #0EA5A5; /* لون رئيسي جديد: فيروزي */ + --primary-light: rgba(14, 165, 165, 0.1); + --primary-dark: #088585; + --primary-gradient: linear-gradient(135deg, #0EA5A5, #088585); + --secondary-color: #FF9A3C; /* لون ثانوي: برتقالي */ + --secondary-light: rgba(255, 154, 60, 0.1); + --text-dark: #1d2b36; + --text-medium: #3a4f5f; + --text-light: #607d94; + --background-light: #f8f9fa; + --border-color: #e1e5ea; + --danger-color: #e3342f; + --success-color: #38c172; + --warning-color: #f7b731; + --info-color: #3490dc; + --card-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + --header-gradient: linear-gradient(120deg, #0EA5A5, #088585); + --sidebar-gradient: linear-gradient(180deg, #1d2b36, #2d3a45); + --border-radius: 10px; + --transition-speed: 0.3s; +} + +/* تعيين اتجاه النص من اليمين إلى اليسار للغة العربية */ +body { + direction: rtl; + text-align: right; + font-family: 'Almarai', 'Tajawal', 'Cairo', sans-serif; + color: var(--text-dark); + background-color: #fafafa; + /* التوافق مع جميع المتصفحات والأجهزة */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +/* أنماط العناوين */ +h1, h2, h3, h4, h5, h6 { + font-family: 'Almarai', 'Tajawal', 'Cairo', sans-serif; + color: var(--text-dark); + font-weight: 700; + line-height: 1.4; + margin-bottom: 0.75rem; +} + +/* أنماط العنوان الرئيسي بتدرج لوني */ +.main-title { + background: var(--primary-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + font-size: 2.25rem; + font-weight: 800; + text-align: center; + margin: 1.5rem 0; + padding: 0.5rem; + position: relative; +} + +.main-title::after { + content: ""; + position: absolute; + bottom: -5px; + left: 30%; + right: 30%; + height: 3px; + background: var(--primary-gradient); + border-radius: 3px; +} + +/* أنماط ترويسة الصفحة محسنة */ +.header-container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background: var(--header-gradient); + border-radius: var(--border-radius); + margin-bottom: 1.5rem; + box-shadow: var(--card-shadow); + color: white; +} + +.header-title { + margin-right: 1.25rem; +} + +.header-title h1 { + margin: 0; + font-size: 1.75rem; + color: white; + font-weight: 800; +} + +.header-title p { + margin: 0.25rem 0 0 0; + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.8); + font-weight: 400; +} + +.header-info { + display: flex; + align-items: center; +} + +.date-box { + display: flex; + background-color: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-left: 1rem; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + backdrop-filter: blur(5px); +} + +.date-day { + font-size: 1.75rem; + font-weight: bold; + margin-left: 0.5rem; + line-height: 1; +} + +.date-info { + display: flex; + flex-direction: column; + font-size: 0.8rem; +} + +.date-month { + font-weight: bold; + line-height: 1.2; +} + +.date-year { + line-height: 1; +} + +/* أنماط قائمة التنقل الجديدة */ +.nav-menu { + background-color: white; + border-radius: var(--border-radius); + padding: 0.5rem; + box-shadow: var(--card-shadow); + margin: 1rem 0; +} + +.nav-menu ul { + display: flex; + list-style: none; + padding: 0; + margin: 0; + justify-content: flex-end; + flex-wrap: wrap; +} + +.nav-menu li { + margin: 0.25rem; +} + +.nav-menu a { + display: flex; + align-items: center; + color: var(--text-medium); + text-decoration: none; + padding: 0.5rem 0.75rem; + border-radius: 6px; + transition: all var(--transition-speed); + font-weight: 500; + border: 1px solid transparent; +} + +.nav-menu a:hover { + background-color: var(--primary-light); + color: var(--primary-color); + border-color: var(--primary-color); + transform: translateY(-2px); +} + +.nav-icon { + margin-left: 0.5rem; + font-size: 1.25rem; +} + +/* أنماط عنوان الوحدة */ +.module-title { + color: var(--text-dark); + font-size: 1.75rem; + margin-bottom: 1.25rem; + border-right: 4px solid var(--primary-color); + padding-right: 0.75rem; + position: relative; +} + +/* أنماط بطاقات المعلومات المحسنة */ +.info-card { + background-color: white; + border-radius: var(--border-radius); + padding: 1.5rem; + margin-bottom: 1.25rem; + box-shadow: var(--card-shadow); + border-top: 4px solid var(--primary-color); + transition: transform var(--transition-speed); +} + +.info-card:hover { + transform: translateY(-5px); +} + +.info-card h3 { + color: var(--text-dark); + margin-top: 0; + margin-bottom: 0.75rem; + font-weight: 700; +} + +.info-card p { + color: var(--text-medium); + margin: 0; + line-height: 1.6; +} + +/* أنماط الجداول */ +.dataframe { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin-bottom: 1.5rem; + border-radius: var(--border-radius); + overflow: hidden; + box-shadow: var(--card-shadow); +} + +.dataframe th { + background-color: var(--primary-color); + color: white; + text-align: right; + padding: 0.75rem 1rem; + font-weight: 600; + border: none; +} + +.dataframe td { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color); + text-align: right; + background-color: white; +} + +.dataframe tr:last-child td { + border-bottom: none; +} + +.dataframe tr:nth-child(even) td { + background-color: rgba(248, 249, 250, 0.7); +} + +.dataframe tr:hover td { + background-color: var(--primary-light); +} + +/* أنماط الأزرار الجديدة */ +button, .stButton>button { + background: var(--primary-gradient); + color: white; + border: none; + border-radius: 6px; + padding: 0.6rem 1.25rem; + cursor: pointer; + transition: all var(--transition-speed); + font-weight: 600; + font-size: 0.95rem; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + display: inline-flex; + align-items: center; + justify-content: center; +} + +button:hover, .stButton>button:hover { + background: var(--primary-dark); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); +} + +button:active, .stButton>button:active { + transform: translateY(0); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +/* أزرار ثانوية */ +.btn-secondary, .stButton.secondary>button { + background: white; + color: var(--primary-color); + border: 1px solid var(--primary-color); +} + +.btn-secondary:hover, .stButton.secondary>button:hover { + background: var(--primary-light); +} + +/* أنماط المخططات */ +.plot-container { + background-color: white; + border-radius: var(--border-radius); + padding: 1rem; + margin: 1.25rem 0; + box-shadow: var(--card-shadow); +} + +/* أنماط بطاقات الابتكارات */ +.innovation-card { + background-color: white; + border-radius: var(--border-radius); + padding: 1.5rem; + margin-bottom: 1.25rem; + border-right: 4px solid var(--primary-color); + box-shadow: var(--card-shadow); + transition: transform var(--transition-speed); +} + +.innovation-card:hover { + transform: translateY(-5px); +} + +.innovation-icon { + font-size: 2rem; + margin-bottom: 0.75rem; + background: var(--primary-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.innovation-card h3 { + color: var(--text-dark); + margin-bottom: 0.75rem; + font-weight: 700; +} + +.innovation-card p { + color: var(--text-medium); + font-size: 0.95rem; + line-height: 1.6; +} + +/* أنماط فريق التطوير */ +.team-section { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.5rem; + margin: 2rem 0; +} + +.team-member { + text-align: center; + margin-bottom: 1.25rem; + background-color: white; + border-radius: var(--border-radius); + padding: 1.5rem; + box-shadow: var(--card-shadow); + transition: transform var(--transition-speed); + width: 230px; +} + +.team-member:hover { + transform: translateY(-5px); +} + +.team-member h3 { + color: var(--text-dark); + margin-bottom: 0.3rem; + font-size: 1.1rem; + font-weight: 700; +} + +.team-member h4 { + color: var(--primary-color); + margin-top: 0; + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +.team-member p { + color: var(--text-medium); + font-size: 0.85rem; + line-height: 1.5; +} + +.avatar { + background: var(--primary-gradient); + color: white; + width: 90px; + height: 90px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + margin: 0 auto 1rem auto; + font-size: 2rem; + font-weight: 700; + box-shadow: 0 5px 15px rgba(14, 165, 165, 0.3); +} + +/* أنماط تذييل الصفحة */ +.footer { + text-align: center; + color: var(--text-light); + font-size: 0.85rem; + margin-top: 2rem; + margin-bottom: 1rem; + padding: 1rem; + border-top: 1px solid var(--border-color); +} + +/* أنماط رسائل التنبيه المحسنة */ +.alert { + padding: 1rem 1.25rem; + border-radius: var(--border-radius); + margin-bottom: 1rem; + position: relative; + border-right: 4px solid; +} + +.alert-icon { + margin-left: 0.5rem; + font-size: 1.25rem; +} + +.alert-info { + background-color: rgba(52, 144, 220, 0.1); + color: var(--info-color); + border-right-color: var(--info-color); +} + +.alert-success { + background-color: rgba(56, 193, 114, 0.1); + color: var(--success-color); + border-right-color: var(--success-color); +} + +.alert-warning { + background-color: rgba(247, 183, 49, 0.1); + color: var(--warning-color); + border-right-color: var(--warning-color); +} + +.alert-danger { + background-color: rgba(227, 52, 47, 0.1); + color: var(--danger-color); + border-right-color: var(--danger-color); +} + +/* أنماط التبويبات المحسنة */ +.stTabs [data-baseweb="tab-list"] { + gap: 1px; + background-color: var(--background-light); + border-radius: var(--border-radius); + padding: 0.3rem; +} + +.stTabs [data-baseweb="tab"] { + height: 50px; + white-space: pre-wrap; + background-color: white; + border-radius: 6px; + gap: 1px; + padding: 0.6rem 1rem; + font-family: 'Almarai', 'Tajawal', sans-serif; + font-weight: 500; + transition: all var(--transition-speed); +} + +.stTabs [aria-selected="true"] { + background: var(--primary-gradient) !important; + color: white !important; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +/* أنماط مخصصة لدعم اللغة العربية في إدخالات النصوص والأرقام */ +input, textarea, .stTextInput>div>div>input, .stNumberInput>div>div>input { + direction: rtl; + text-align: right; + font-family: 'Almarai', 'Tajawal', sans-serif; + font-size: 0.95rem; + padding: 0.6rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-color); + transition: border-color var(--transition-speed); +} + +input:focus, textarea:focus, .stTextInput>div>div>input:focus, .stNumberInput>div>div>input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 1px var(--primary-light); +} + +/* أنماط قائمة الخيارات */ +.stSelectbox [data-baseweb="select"] { + direction: rtl; + text-align: right; + font-family: 'Almarai', 'Tajawal', sans-serif; +} + +/* أنماط للنسخة المحمولة والاستجابة للشاشات */ +@media (max-width: 992px) { + .main-title { + font-size: 1.75rem; + } + + .header-container { + padding: 1rem; + } + + .header-title h1 { + font-size: 1.5rem; + } +} + +@media (max-width: 768px) { + .header-container { + flex-direction: column; + align-items: flex-start; + } + + .header-info { + margin-top: 1rem; + width: 100%; + justify-content: flex-start; + } + + .nav-menu ul { + flex-wrap: wrap; + justify-content: flex-start; + } + + .nav-menu li { + margin-bottom: 0.5rem; + margin-left: 0.5rem; + width: calc(50% - 1rem); + } + + .module-title { + font-size: 1.5rem; + } + + .main-title { + font-size: 1.4rem; + } + + .innovation-card, .info-card { + margin-bottom: 1rem; + padding: 1rem; + } + + .dataframe { + display: block; + overflow-x: auto; + } + + .team-member { + width: 100%; + } +} + +@media (max-width: 480px) { + .main-title { + font-size: 1.25rem; + } + + .header-title h1 { + font-size: 1.25rem; + } + + .nav-menu li { + width: 100%; + margin-left: 0; + } +} + +/* تحسينات خاصة للأيفون والأجهزة ذات الشاشات الصغيرة */ +@media only screen and (max-width: 375px) { + .nav-menu { + margin: 0.5rem 0; + } + + button, .stButton>button { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + } + + .module-title, .info-card h3, .innovation-card h3 { + font-size: 1.1rem; + } +} + +/* أضف كلاس للأيقونات */ +.icon { + font-size: 1.1rem; + margin-left: 0.5rem; +} + +/* أنماط للصور والخلفيات */ +.bg-light { + background-color: var(--background-light); +} + +.card { + background: white; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +/* قسم معلومات النظام */ +.about-system { + margin: 2rem 0; + background: white; + border-radius: var(--border-radius); + box-shadow: var(--card-shadow); + padding: 1.5rem; +} + +.about-system h2 { + color: var(--primary-color); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--primary-light); +} + +.about-system p { + line-height: 1.6; + margin-bottom: 1rem; +} + +.about-system ul { + padding-right: 1.5rem; + margin-bottom: 1rem; +} + +.about-system li { + margin-bottom: 0.5rem; + line-height: 1.6; +} + +/* أنماط مؤشر التقدم */ +.progress { + height: 0.5rem; + overflow: hidden; + background-color: var(--background-light); + border-radius: 0.25rem; + margin: 0.5rem 0 1rem 0; +} + +.progress-bar { + height: 100%; + border-radius: 0.25rem; + background: var(--primary-gradient); +} + +/* المؤقت للمواعيد النهائية */ +.countdown-timer { + display: flex; + justify-content: center; + gap: 1rem; + margin: 1.5rem 0; +} + +.time-block { + background: white; + border-radius: var(--border-radius); + padding: 0.75rem 1rem; + text-align: center; + min-width: 80px; + box-shadow: var(--card-shadow); +} + +.time-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--primary-color); + line-height: 1; +} + +.time-label { + font-size: 0.8rem; + color: var(--text-medium); + margin-top: 0.25rem; +} + +/* إعدادات المستخدم */ +.settings-form { + background: white; + border-radius: var(--border-radius); + padding: 1.5rem; + box-shadow: var(--card-shadow); +} + +.settings-group { + margin-bottom: 1.5rem; +} + +.settings-group h3 { + font-size: 1.2rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.settings-item { + margin-bottom: 1rem; +} + +.settings-item label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +/* أنماط للتسعير المتقدم */ +.pricing-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; + margin: 1.5rem 0; +} + +.price-card { + background: white; + border-radius: var(--border-radius); + padding: 1.25rem; + box-shadow: var(--card-shadow); + display: flex; + flex-direction: column; +} + +.price-header { + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); +} + +.price-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary-color); + margin: 0.5rem 0; +} + +.price-details { + flex-grow: 1; +} + +.price-details ul { + padding-right: 1.25rem; + margin-bottom: 1rem; +} + +.price-details li { + margin-bottom: 0.5rem; + line-height: 1.5; +} + +.price-footer { + margin-top: auto; + padding-top: 1rem; +} + +/* أنماط إضافية للأيقونات والرموز */ +.colored-icon { + background: var(--primary-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* أنماط الشعار */ +.logo { + display: flex; + align-items: center; +} + +.logo-text { + font-weight: 800; + font-size: 1.25rem; + margin-right: 0.5rem; + background: var(--primary-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* كلاس للخط العريض */ +.bold { + font-weight: 700; +} + +/* تحسين مظهر مخطط مرمايد للنظام */ +.mermaid { + margin: 1.5rem 0; +} + +/* تعديلات على شكل العنصر الجانبي */ +.stSidebar { + background-color: white; + box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05); +} + +[data-testid="stSidebarContent"] { + background: var(--sidebar-gradient); +} + +/* تحسين شكل لوحة المعلومات */ +.dashboard-card { + background: white; + border-radius: var(--border-radius); + padding: 1rem; + box-shadow: var(--card-shadow); + height: 100%; + position: relative; + overflow: hidden; +} + +.dashboard-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--primary-gradient); +} + +.dashboard-value { + font-size: 2rem; + font-weight: 800; + color: var(--primary-color); + margin: 0.5rem 0; +} + +.dashboard-title { + color: var(--text-medium); + font-size: 0.9rem; + margin-bottom: 0.5rem; + font-weight: 600; +} + +.dashboard-change { + font-size: 0.8rem; + display: flex; + align-items: center; +} + +.change-up { + color: var(--success-color); +} + +.change-down { + color: var(--danger-color); +} + +/* بالنسبة للشركة */ +.company-info { + text-align: center; + background: white; + padding: 1rem; + border-radius: var(--border-radius); + margin: 1.5rem 0; + box-shadow: var(--card-shadow); +} + +.company-logo { + max-width: 150px; + margin: 0 auto 1rem auto; +} + +.company-name { + font-size: 1.2rem; + font-weight: 700; + color: var(--text-dark); + margin-bottom: 0.5rem; +} + +.company-slogan { + font-size: 0.9rem; + color: var(--text-medium); + margin-bottom: 1rem; +} + +.company-contact { + display: flex; + justify-content: center; + gap: 1rem; + margin-top: 1rem; +} + +.contact-item { + display: flex; + align-items: center; + font-size: 0.85rem; + color: var(--text-medium); +} + +.contact-icon { + margin-left: 0.3rem; +} \ No newline at end of file diff --git a/static/css/rtl-fixes.css b/static/css/rtl-fixes.css new file mode 100644 index 0000000000000000000000000000000000000000..39bf1a177b5f995a4c54e29314d0e2f3e145cb06 --- /dev/null +++ b/static/css/rtl-fixes.css @@ -0,0 +1,66 @@ +/* إصلاحات لدعم اللغة العربية والعرض من اليمين إلى اليسار */ + +/* نقل الشريط الجانبي من اليسار إلى اليمين */ +[data-testid="stSidebar"] { + right: 0; + left: unset !important; +} + +section[data-testid="stSidebarContent"] { + direction: rtl !important; + text-align: right !important; +} + +/* تعديل الاتجاه للمحتوى الرئيسي */ +.main .block-container { + direction: rtl; + text-align: right; + margin-left: 0; + margin-right: 21rem; + max-width: calc(100% - 21rem); +} + +@media (max-width: 768px) { + .main .block-container { + margin-right: 0; + max-width: 100%; + } +} + +/* تصحيح اتجاه النصوص والعناصر */ +.streamlit-expanderHeader, +.stRadio > div, +.stCheckbox > div, +.stSelectbox > div, +.stTextInput > div { + direction: rtl; + text-align: right; +} + +/* تعديل قائمة الخيارات في streamlit_option_menu */ +.nav-link { + text-align: right !important; +} + +/* تحسين ظهور الجداول */ +[data-testid="stTable"] { + direction: rtl; +} + +/* تصحيح أزرار الأرقام والتواريخ */ +.stNumberInput [data-baseweb="input"], +.stDateInput [data-baseweb="input"] { + direction: ltr; +} + +/* جعل عناصر الملاحظات والخانات النصية تدعم العربية */ +[data-testid="stMarkdown"], textarea { + direction: rtl; + text-align: right; +} + +/* تحسين ظهور قوائم الاختيار */ +.stMultiSelect div:first-child, +.stSelectbox div:first-child { + text-align: right; +} \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..9780de0e7cdc280e06149e0a746743865a7b6ee8 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,373 @@ +/* أنماط CSS للنظام */ + +/* تعيين اتجاه النص من اليمين إلى اليسار للغة العربية */ +body { + direction: rtl; + text-align: right; + font-family: 'Tajawal', 'Cairo', sans-serif; +} + +/* أنماط العناوين */ +h1, h2, h3, h4, h5, h6 { + font-family: 'Tajawal', 'Cairo', sans-serif; + color: #333; +} + +/* أنماط ترويسة الصفحة */ +.header-container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 10px 0; + background-color: #f8f9fa; + border-radius: 10px; + margin-bottom: 20px; +} + +.header-title { + margin-right: 20px; +} + +.header-title h1 { + margin: 0; + font-size: 24px; + color: #2c3e50; +} + +.header-title p { + margin: 0; + font-size: 14px; + color: #7f8c8d; +} + +.header-info { + display: flex; + align-items: center; +} + +.date-box { + display: flex; + background-color: #ff9a3c; + color: white; + border-radius: 8px; + padding: 5px 10px; + margin-left: 15px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.date-day { + font-size: 24px; + font-weight: bold; + margin-left: 5px; + line-height: 1; +} + +.date-info { + display: flex; + flex-direction: column; + font-size: 12px; +} + +.date-month { + font-weight: bold; + line-height: 1.2; +} + +.date-year { + line-height: 1; +} + +/* أنماط قائمة التنقل */ +.nav-menu { + margin: 10px 0; +} + +.nav-menu ul { + display: flex; + list-style: none; + padding: 0; + margin: 0; + justify-content: flex-end; +} + +.nav-menu li { + margin-left: 15px; +} + +.nav-menu a { + display: flex; + align-items: center; + color: #2c3e50; + text-decoration: none; + padding: 5px 10px; + border-radius: 5px; + transition: background-color 0.3s; +} + +.nav-menu a:hover { + background-color: #f0f0f0; +} + +.nav-icon { + margin-left: 5px; +} + +/* أنماط عنوان الوحدة */ +.module-title { + color: #2c3e50; + font-size: 28px; + margin-bottom: 20px; + border-right: 5px solid #ff9a3c; + padding-right: 10px; +} + +.main-title { + color: #2c3e50; + font-size: 32px; + text-align: center; + margin: 20px 0; +} + +/* أنماط بطاقات المعلومات */ +.info-card { + background-color: #f8f9fa; + border-radius: 10px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.info-card h3 { + color: #333; + margin-top: 0; + margin-bottom: 10px; +} + +.info-card p { + color: #666; + margin: 0; +} + +/* أنماط الجداول */ +.dataframe { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; +} + +.dataframe th { + background-color: #f0f0f0; + color: #333; + text-align: right; + padding: 8px; + border: 1px solid #ddd; +} + +.dataframe td { + padding: 8px; + border: 1px solid #ddd; + text-align: right; +} + +.dataframe tr:nth-child(even) { + background-color: #f9f9f9; +} + +.dataframe tr:hover { + background-color: #f0f0f0; +} + +/* أنماط الأزرار */ +button, .stButton>button { + background-color: #ff9a3c; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + transition: background-color 0.3s; +} + +button:hover, .stButton>button:hover { + background-color: #e67e22; +} + +/* أنماط المخططات */ +.plot-container { + margin: 20px 0; +} + +/* أنماط بطاقات الابتكارات */ +.innovation-card { + background-color: #f8f9fa; + border-radius: 10px; + padding: 15px; + margin-bottom: 20px; + border-right: 5px solid #ff9a3c; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.innovation-icon { + font-size: 24px; + margin-bottom: 10px; +} + +.innovation-card h3 { + color: #333; + margin-bottom: 10px; +} + +.innovation-card p { + color: #666; + font-size: 14px; +} + +/* أنماط فريق التطوير */ +.team-member { + text-align: center; + margin-bottom: 20px; +} + +.team-member h3 { + color: #333; + margin-bottom: 5px; + font-size: 18px; +} + +.team-member h4 { + color: #ff9a3c; + margin-top: 0; + margin-bottom: 10px; + font-size: 14px; +} + +.team-member p { + color: #666; + font-size: 12px; +} + +.avatar { + background-color: #ff9a3c; + color: white; + width: 100px; + height: 100px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + margin: 0 auto 15px auto; + font-size: 36px; +} + +/* أنماط تذييل الصفحة */ +.footer { + text-align: center; + color: #7f8c8d; + font-size: 12px; + margin-top: 30px; + margin-bottom: 10px; +} + +/* أنماط رسائل التنبيه */ +.alert { + padding: 10px 15px; + border-radius: 4px; + margin-bottom: 15px; +} + +.alert-info { + background-color: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.alert-success { + background-color: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.alert-warning { + background-color: #fff3cd; + color: #856404; + border: 1px solid #ffeeba; +} + +.alert-danger { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +/* أنماط الأيقونات */ +.icon { + font-size: 18px; + margin-left: 5px; +} + +/* أنماط التبويبات */ +.stTabs [data-baseweb="tab-list"] { + gap: 1px; +} + +.stTabs [data-baseweb="tab"] { + height: 50px; + white-space: pre-wrap; + background-color: white; + border-radius: 4px 4px 0 0; + gap: 1px; + padding-top: 10px; + padding-bottom: 10px; +} + +.stTabs [aria-selected="true"] { + background-color: #ff9a3c !important; + color: white !important; +} + +/* أنماط مخصصة لدعم اللغة العربية في إدخالات النصوص والأرقام */ +input, textarea, .stTextInput>div>div>input, .stNumberInput>div>div>input { + direction: rtl; + text-align: right; +} + +/* أنماط قائمة الخيارات */ +.stSelectbox [data-baseweb="select"] { + direction: rtl; + text-align: right; +} + +/* أنماط تحرير البيانات */ +.stDataEditor { + direction: rtl; +} + +/* أنماط للنسخة المحمولة */ +@media (max-width: 768px) { + .header-container { + flex-direction: column; + align-items: flex-start; + } + + .header-info { + margin-top: 10px; + } + + .nav-menu ul { + flex-wrap: wrap; + } + + .nav-menu li { + margin-bottom: 5px; + margin-left: 10px; + } + + .module-title { + font-size: 24px; + } + + .main-title { + font-size: 24px; + } +} \ No newline at end of file diff --git a/static/images/README.md b/static/images/README.md new file mode 100644 index 0000000000000000000000000000000000000000..28dec0bcb15aea0c448be7abdfba9b599ecc5d50 --- /dev/null +++ b/static/images/README.md @@ -0,0 +1,40 @@ +# مجلد الصور والأيقونات + +يحتوي هذا المجلد على جميع الصور والأيقونات المستخدمة في نظام تسعير المناقصات. + +## هيكل المجلد + +- `logo.png`: شعار النظام الرئيسي +- `logo-sm.png`: نسخة مصغرة من الشعار للاستخدام في الواجهات الصغيرة +- `favicon.ico`: أيقونة المفضلة للمتصفح +- `icons/`: مجلد الأيقونات المستخدمة في النظام +- `backgrounds/`: مجلد خلفيات النظام +- `users/`: مجلد لصور المستخدمين الافتراضية +- `charts/`: صور للمخططات الثابتة +- `report-templates/`: قوالب التقارير المطبوعة والمصدرة + +## إرشادات استخدام الصور + +- استخدم الصور بتنسيق PNG للشعارات والأيقونات +- استخدم تنسيق SVG للأيقونات كلما أمكن ذلك للحفاظ على جودة العرض +- الاحتفاظ بنسخة عالية الدقة من الشعار بتنسيق PSD أو AI + +## أيقونات النظام + +يستخدم النظام مجموعة أيقونات Font Awesome للعناصر المختلفة في الواجهة. يمكنك استخدامها عن طريق: + +```html + + + + + +``` + +لمزيد من الأيقونات، يمكنك زيارة [موقع Font Awesome](https://fontawesome.com/icons). + +## الصور الافتراضية + +- `default-project.png`: صورة افتراضية للمشاريع +- `default-user.png`: صورة افتراضية للمستخدمين +- `default-client.png`: صورة افتراضية للعملاء \ No newline at end of file diff --git a/static/images/logo.png b/static/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3a7428b5811220a31aa39d1e5026a6d6f4d810 Binary files /dev/null and b/static/images/logo.png differ diff --git a/static/js/charts.js b/static/js/charts.js new file mode 100644 index 0000000000000000000000000000000000000000..27368d347694cb5551cc2892142c1a2e31f61ef5 --- /dev/null +++ b/static/js/charts.js @@ -0,0 +1,348 @@ +/** + * ملف رسم وتهيئة المخططات البيانية + */ + +// دالة التهيئة عند تحميل الصفحة +document.addEventListener('DOMContentLoaded', function() { + // تهيئة المخططات + initializeCharts(); +}); + +/** + * تهيئة جميع المخططات في الصفحة + */ +function initializeCharts() { + // التحقق من توفر مكتبة Chart.js + if (typeof Chart === 'undefined') { + console.warn('مكتبة Chart.js غير متوفرة. لا يمكن تهيئة المخططات.'); + return; + } + + // تعيين الخيارات العامة للمخططات + Chart.defaults.font.family = "'Tajawal', sans-serif"; + Chart.defaults.font.size = 14; + Chart.defaults.color = '#333'; + Chart.defaults.plugins.tooltip.rtl = true; + Chart.defaults.plugins.tooltip.titleAlign = 'right'; + Chart.defaults.plugins.tooltip.bodyAlign = 'right'; + Chart.defaults.plugins.legend.rtl = true; + Chart.defaults.plugins.legend.labels.textAlign = 'right'; + + // إنشاء مخططات مختلفة بناءً على نوع المخطط + initializeBarCharts(); + initializeLineCharts(); + initializePieCharts(); + initializeRadarCharts(); + initializeGaugeCharts(); + initializeDashboardCharts(); +} + +/** + * تهيئة المخططات الشريطية + */ +function initializeBarCharts() { + const barChartElements = document.querySelectorAll('.bar-chart'); + + barChartElements.forEach(element => { + const ctx = element.getContext('2d'); + const dataUrl = element.getAttribute('data-url'); + + // استدعاء البيانات من الخادم إذا كان متوفرًا + if (dataUrl) { + fetch(dataUrl) + .then(response => response.json()) + .then(data => { + createBarChart(ctx, element, data); + }) + .catch(error => { + console.error('خطأ في تحميل بيانات المخطط:', error); + // استخدام بيانات افتراضية في حال حدوث خطأ + createBarChart(ctx, element, getDefaultBarChartData()); + }); + } else { + // استخدام البيانات المضمنة من سمة data-config + let chartData; + try { + chartData = JSON.parse(element.getAttribute('data-config') || '{}'); + } catch (e) { + console.error('تنسيق بيانات المخطط غير صالح:', e); + chartData = getDefaultBarChartData(); + } + + createBarChart(ctx, element, chartData); + } + }); +} + +/** + * إنشاء مخطط شريطي + */ +function createBarChart(ctx, element, data) { + const isVertical = element.getAttribute('data-orientation') !== 'horizontal'; + const isStacked = element.getAttribute('data-stacked') === 'true'; + + // تكوين الخيارات + const options = { + indexAxis: isVertical ? 'x' : 'y', + scales: { + x: { + beginAtZero: true, + grid: { + display: false + } + }, + y: { + beginAtZero: true, + grid: { + display: true, + color: '#f0f0f0' + } + } + }, + plugins: { + title: { + display: data.title ? true : false, + text: data.title || '', + align: 'right', + font: { + size: 16, + weight: 'bold' + } + }, + legend: { + display: (data.datasets && data.datasets.length > 1) ? true : false, + position: 'top', + align: 'end' + } + }, + responsive: true, + maintainAspectRatio: false + }; + + // إضافة خيارات للمخطط المكدس إذا لزم الأمر + if (isStacked) { + options.scales.x.stacked = true; + options.scales.y.stacked = true; + } + + // إنشاء المخطط + new Chart(ctx, { + type: 'bar', + data: { + labels: data.labels || [], + datasets: data.datasets || [] + }, + options: options + }); +} + +/** + * تهيئة المخططات الخطية + */ +function initializeLineCharts() { + const lineChartElements = document.querySelectorAll('.line-chart'); + + lineChartElements.forEach(element => { + const ctx = element.getContext('2d'); + const dataUrl = element.getAttribute('data-url'); + + // استدعاء البيانات من الخادم إذا كان متوفرًا + if (dataUrl) { + fetch(dataUrl) + .then(response => response.json()) + .then(data => { + createLineChart(ctx, element, data); + }) + .catch(error => { + console.error('خطأ في تحميل بيانات المخطط:', error); + createLineChart(ctx, element, getDefaultLineChartData()); + }); + } else { + // استخدام البيانات المضمنة + let chartData; + try { + chartData = JSON.parse(element.getAttribute('data-config') || '{}'); + } catch (e) { + console.error('تنسيق بيانات المخطط غير صالح:', e); + chartData = getDefaultLineChartData(); + } + + createLineChart(ctx, element, chartData); + } + }); +} + +/** + * إنشاء مخطط خطي + */ +function createLineChart(ctx, element, data) { + const isCurved = element.getAttribute('data-curved') === 'true'; + const showPoints = element.getAttribute('data-points') !== 'false'; + + // تكوين الخيارات + const options = { + scales: { + x: { + grid: { + display: false + } + }, + y: { + beginAtZero: element.getAttribute('data-start-at-zero') === 'true', + grid: { + color: '#f0f0f0' + } + } + }, + elements: { + line: { + tension: isCurved ? 0.4 : 0, + borderWidth: 2 + }, + point: { + radius: showPoints ? 4 : 0, + hoverRadius: showPoints ? 6 : 0 + } + }, + plugins: { + title: { + display: data.title ? true : false, + text: data.title || '', + align: 'right', + font: { + size: 16, + weight: 'bold' + } + }, + legend: { + position: 'top', + align: 'end' + } + }, + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + } + }; + + // إنشاء المخطط + new Chart(ctx, { + type: 'line', + data: { + labels: data.labels || [], + datasets: data.datasets || [] + }, + options: options + }); +} + +/** + * تهيئة المخططات الدائرية + */ +function initializePieCharts() { + const pieChartElements = document.querySelectorAll('.pie-chart, .doughnut-chart'); + + pieChartElements.forEach(element => { + const ctx = element.getContext('2d'); + const dataUrl = element.getAttribute('data-url'); + const isDoughnut = element.classList.contains('doughnut-chart'); + + // استدعاء البيانات من الخادم إذا كان متوفرًا + if (dataUrl) { + fetch(dataUrl) + .then(response => response.json()) + .then(data => { + createPieChart(ctx, element, data, isDoughnut); + }) + .catch(error => { + console.error('خطأ في تحميل بيانات المخطط:', error); + createPieChart(ctx, element, getDefaultPieChartData(), isDoughnut); + }); + } else { + // استخدام البيانات المضمنة + let chartData; + try { + chartData = JSON.parse(element.getAttribute('data-config') || '{}'); + } catch (e) { + console.error('تنسيق بيانات المخطط غير صالح:', e); + chartData = getDefaultPieChartData(); + } + + createPieChart(ctx, element, chartData, isDoughnut); + } + }); +} + +/** + * إنشاء مخطط دائري + */ +function createPieChart(ctx, element, data, isDoughnut) { + // تكوين الخيارات + const options = { + plugins: { + title: { + display: data.title ? true : false, + text: data.title || '', + align: 'right', + font: { + size: 16, + weight: 'bold' + } + }, + legend: { + position: 'bottom', + align: 'start', + rtl: true, + labels: { + boxWidth: 12, + padding: 15 + } + } + }, + responsive: true, + maintainAspectRatio: false + }; + + // إضافة خيارات لمخطط الدونات إذا لزم الأمر + if (isDoughnut) { + options.cutout = '60%'; + options.plugins.tooltip = { + callbacks: { + title: function(tooltipItems) { + return tooltipItems[0].label; + }, + label: function(context) { + const value = context.raw; + const total = context.chart.getDatasetMeta(0).total; + const percentage = Math.round((value / total) * 100); + return percentage + '% (' + value + ')'; + } + } + }; + } + + // إنشاء المخطط + new Chart(ctx, { + type: isDoughnut ? 'doughnut' : 'pie', + data: { + labels: data.labels || [], + datasets: [{ + data: data.values || [], + backgroundColor: data.colors || getDefaultColors(), + borderWidth: 1, + borderColor: '#fff' + }] + }, + options: options + }); +} + +/** + * تهيئة مخططات الرادار + */ +function initializeRadarCharts() { + const radarChartElements = document.querySelectorAll('.radar-chart'); + + radarChartElements.forEach(element => { \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000000000000000000000000000000000000..0c773145fe0bc5e5cf21a266ad75eb8d7dab8ccf --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,416 @@ +/** + * ملف السكربت الرئيسي لنظام تسعير المناقصات + */ + +// دالة التهيئة عند تحميل الصفحة +document.addEventListener('DOMContentLoaded', function() { + // تفعيل تلميحات الأدوات + initializeTooltips(); + + // تفعيل التحقق من الإدخال في النماذج + initializeFormValidation(); + + // تفعيل وظيفة البحث والتصفية + initializeSearchAndFilters(); + + // تفعيل وظائف التصدير + initializeExportFunctions(); + + // تفعيل تحديثات البيانات المباشرة + initializeLiveDataUpdates(); + + // تفعيل التأثيرات البصرية + initializeVisualEffects(); +}); + +/** + * تفعيل تلميحات الأدوات + */ +function initializeTooltips() { + // يمكن استخدام مكتبة Bootstrap للتلميحات إذا تم تحميلها + if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) { + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); + } else { + // تنفيذ بسيط للتلميحات إذا لم تكن مكتبة Bootstrap متاحة + const tooltips = document.querySelectorAll('[data-tooltip]'); + tooltips.forEach(element => { + element.addEventListener('mouseenter', function() { + const tooltipText = this.getAttribute('data-tooltip'); + const tooltip = document.createElement('div'); + tooltip.className = 'custom-tooltip'; + tooltip.textContent = tooltipText; + document.body.appendChild(tooltip); + + const rect = this.getBoundingClientRect(); + tooltip.style.left = rect.left + (rect.width / 2) - (tooltip.offsetWidth / 2) + 'px'; + tooltip.style.top = rect.bottom + 10 + 'px'; + + this.addEventListener('mouseleave', function() { + document.body.removeChild(tooltip); + }, { once: true }); + }); + }); + } +} + +/** + * تفعيل التحقق من الإدخال في النماذج + */ +function initializeFormValidation() { + const forms = document.querySelectorAll('.needs-validation'); + + forms.forEach(form => { + form.addEventListener('submit', function(event) { + if (!form.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } + + form.classList.add('was-validated'); + }); + + // التحقق المباشر من الإدخال + const inputs = form.querySelectorAll('input, select, textarea'); + inputs.forEach(input => { + input.addEventListener('input', function() { + validateInput(this); + }); + + input.addEventListener('blur', function() { + validateInput(this); + }); + }); + }); +} + +/** + * التحقق من إدخال حقل معين + */ +function validateInput(input) { + // التحقق من صحة الإدخال + if (input.checkValidity()) { + input.classList.remove('is-invalid'); + input.classList.add('is-valid'); + } else { + input.classList.remove('is-valid'); + input.classList.add('is-invalid'); + } + + // تحقق خاص بحقول الأرقام + if (input.type === 'number') { + const min = parseFloat(input.getAttribute('min')); + const max = parseFloat(input.getAttribute('max')); + const value = parseFloat(input.value); + + if (!isNaN(value)) { + if (!isNaN(min) && value < min) { + input.setCustomValidity(`القيمة يجب أن تكون أكبر من أو تساوي ${min}`); + } else if (!isNaN(max) && value > max) { + input.setCustomValidity(`القيمة يجب أن تكون أقل من أو تساوي ${max}`); + } else { + input.setCustomValidity(''); + } + } + } +} + +/** + * تفعيل وظيفة البحث والتصفية + */ +function initializeSearchAndFilters() { + // تنفيذ البحث في الجداول + const searchInputs = document.querySelectorAll('.table-search'); + searchInputs.forEach(input => { + input.addEventListener('input', function() { + const table = document.querySelector(this.getAttribute('data-table')); + const term = this.value.toLowerCase(); + + if (table) { + const rows = table.querySelectorAll('tbody tr'); + rows.forEach(row => { + const text = row.textContent.toLowerCase(); + row.style.display = text.includes(term) ? '' : 'none'; + }); + } + }); + }); + + // تنفيذ تصفية الجداول + const filterSelects = document.querySelectorAll('.table-filter'); + filterSelects.forEach(select => { + select.addEventListener('change', function() { + const table = document.querySelector(this.getAttribute('data-table')); + const column = parseInt(this.getAttribute('data-column')); + const value = this.value; + + if (table) { + const rows = table.querySelectorAll('tbody tr'); + rows.forEach(row => { + const cell = row.querySelectorAll('td')[column]; + if (cell) { + row.style.display = (value === 'all' || cell.textContent === value) ? '' : 'none'; + } + }); + } + }); + }); +} + +/** + * تفعيل وظائف التصدير + */ +function initializeExportFunctions() { + // تصدير إلى CSV + const csvButtons = document.querySelectorAll('.export-csv'); + csvButtons.forEach(button => { + button.addEventListener('click', function() { + const tableId = this.getAttribute('data-table'); + exportTableToCSV(tableId, this.getAttribute('data-filename') || 'export.csv'); + }); + }); + + // تصدير إلى PDF + const pdfButtons = document.querySelectorAll('.export-pdf'); + pdfButtons.forEach(button => { + button.addEventListener('click', function() { + const tableId = this.getAttribute('data-table'); + exportTableToPDF(tableId, this.getAttribute('data-filename') || 'export.pdf'); + }); + }); + + // تصدير إلى Excel + const excelButtons = document.querySelectorAll('.export-excel'); + excelButtons.forEach(button => { + button.addEventListener('click', function() { + const tableId = this.getAttribute('data-table'); + exportTableToExcel(tableId, this.getAttribute('data-filename') || 'export.xlsx'); + }); + }); +} + +/** + * تصدير جدول إلى CSV + */ +function exportTableToCSV(tableId, filename) { + const table = document.getElementById(tableId); + if (!table) return; + + let csv = []; + const rows = table.querySelectorAll('tr'); + + for (let i = 0; i < rows.length; i++) { + const row = [], cols = rows[i].querySelectorAll('td, th'); + + for (let j = 0; j < cols.length; j++) { + // تنظيف النص وإحاطته بعلامات اقتباس للتوافق مع تنسيق CSV + let text = cols[j].innerText; + text = text.replace(/"/g, '""'); + row.push('"' + text + '"'); + } + + csv.push(row.join(',')); + } + + // تحويل المصفوفة إلى نص + const csvText = csv.join('\n'); + + // إنشاء رابط تنزيل + const blob = new Blob([csvText], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + + // تحديد اسم الملف + link.setAttribute('download', filename); + + // إنشاء URL من Blob + link.href = URL.createObjectURL(blob); + link.style.visibility = 'hidden'; + + // إضافة الرابط وتنفيذ النقر عليه + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * تصدير جدول إلى PDF (يتطلب مكتبة خارجية مثل jsPDF) + */ +function exportTableToPDF(tableId, filename) { + // التحقق من وجود مكتبة jsPDF + if (typeof jsPDF === 'undefined') { + console.error('مكتبة jsPDF غير متوفرة. يرجى تضمينها لاستخدام هذه الوظيفة.'); + return; + } + + const table = document.getElementById(tableId); + if (!table) return; + + // إنشاء مستند PDF جديد + const doc = new jsPDF('l', 'pt', 'a4'); + + // تحويل الجدول إلى PDF + doc.autoTable({ + html: '#' + tableId, + startY: 20, + theme: 'grid', + headStyles: { fillColor: [41, 128, 185], textColor: 255 }, + bodyStyles: { textColor: 50 }, + alternateRowStyles: { fillColor: [245, 245, 245] } + }); + + // حفظ المستند + doc.save(filename); +} + +/** + * تصدير جدول إلى Excel (يتطلب مكتبة خارجية مثل SheetJS) + */ +function exportTableToExcel(tableId, filename) { + // التحقق من وجود مكتبة SheetJS + if (typeof XLSX === 'undefined') { + console.error('مكتبة SheetJS (XLSX) غير متوفرة. يرجى تضمينها لاستخدام هذه الوظيفة.'); + return; + } + + const table = document.getElementById(tableId); + if (!table) return; + + // تحويل جدول HTML إلى دفتر عمل + const wb = XLSX.utils.table_to_book(table); + + // حفظ الملف + XLSX.writeFile(wb, filename); +} + +/** + * تفعيل تحديثات البيانات المباشرة + */ +function initializeLiveDataUpdates() { + // تنفيذ إذا كانت الواجهة تستخدم تحديثات مباشرة + const liveDataElements = document.querySelectorAll('[data-live-update]'); + + if (liveDataElements.length > 0) { + // إعداد تحديثات دورية + setInterval(function() { + liveDataElements.forEach(element => { + const url = element.getAttribute('data-live-update'); + + // استدعاء البيانات من الخادم + fetch(url) + .then(response => response.json()) + .then(data => { + // تحديث محتوى العنصر + updateElementContent(element, data); + }) + .catch(error => { + console.error('خطأ في تحديث البيانات:', error); + }); + }); + }, 30000); // تحديث كل 30 ثانية + } +} + +/** + * تحديث محتوى عنصر بناءً على البيانات + */ +function updateElementContent(element, data) { + const updateType = element.getAttribute('data-update-type') || 'text'; + + switch (updateType) { + case 'text': + element.textContent = data.value; + break; + case 'html': + element.innerHTML = data.value; + break; + case 'attribute': + const attributeName = element.getAttribute('data-update-attribute'); + if (attributeName) { + element.setAttribute(attributeName, data.value); + } + break; + case 'progress': + element.style.width = data.value + '%'; + element.textContent = data.value + '%'; + break; + case 'chart': + // يفترض وجود مكتبة Chart.js + if (typeof Chart !== 'undefined' && element.chart) { + updateChart(element.chart, data); + } + break; + } + + // تطبيق تأثير التحديث + element.classList.add('updated'); + setTimeout(() => { element.classList.remove('updated'); }, 2000); +} + +/** + * تحديث مخطط باستخدام Chart.js + */ +function updateChart(chart, data) { + if (data.labels) { + chart.data.labels = data.labels; + } + + if (data.datasets) { + chart.data.datasets = data.datasets; + } else if (data.values) { + // تحديث قيم مجموعة البيانات الأولى فقط + chart.data.datasets[0].data = data.values; + } + + chart.update(); +} + +/** + * تفعيل التأثيرات البصرية + */ +function initializeVisualEffects() { + // تأثير التمرير السلس للروابط الداخلية + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function(e) { + e.preventDefault(); + const targetId = this.getAttribute('href'); + const targetElement = document.querySelector(targetId); + + if (targetElement) { + targetElement.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }); + }); + + // تأثير الظهور عند التمرير + const fadeElements = document.querySelectorAll('.fade-in-element'); + if (fadeElements.length > 0) { + const fadeObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + fadeObserver.unobserve(entry.target); + } + }); + }, { threshold: 0.1 }); + + fadeElements.forEach(element => { + fadeObserver.observe(element); + }); + } + + // تفعيل الرسوم المتحركة عند التفاعل + const animatedElements = document.querySelectorAll('.animated-element'); + animatedElements.forEach(element => { + element.addEventListener('mouseenter', function() { + const animation = this.getAttribute('data-animation') || 'pulse'; + this.classList.add(animation); + }); + + element.addEventListener('animationend', function() { + this.classList.remove(this.getAttribute('data-animation') || 'pulse'); + }); + }); +} \ No newline at end of file diff --git a/streamlit_output.log b/streamlit_output.log new file mode 100644 index 0000000000000000000000000000000000000000..e4f5e723101a4d38f9e3bb6c84fbaca191fec514 --- /dev/null +++ b/streamlit_output.log @@ -0,0 +1 @@ +-- Streamlit Log -- diff --git a/test.py b/test.py new file mode 100644 index 0000000000000000000000000000000000000000..d12ee15743277341fcd7c4d472f3d0cc88aec8e4 --- /dev/null +++ b/test.py @@ -0,0 +1,45 @@ +""" +اختبار التغييرات على مشروع البايثون في نظام تحليل المناقصات +""" + +import os +import sys + +print("=== اختبار التغييرات في نظام تحليل المناقصات ===\n") + +# إضافة المسار الحالي إلى مسار النظام +sys.path.append('.') + +# اختبار استيراد وحدة credits +print("1. اختبار استيراد وحدة credits:") +try: + from utils.components.credits import render_credits, display_credits + print("✅ تم استيراد وحدة credits بنجاح") +except Exception as e: + print(f"❌ فشل استيراد وحدة credits: {str(e)}") + +# اختبار استيراد وحدة التحليل +print("\n2. اختبار استيراد وحدة الصوت:") +try: + from modules.voice_narration.voice_over_system import VoiceOverSystem + print("✅ تم استيراد وحدة الصوت بنجاح") +except Exception as e: + print(f"❌ فشل استيراد وحدة الصوت: {str(e)}") + +# اختبار استيراد وحدة templates_catalog +print("\n3. اختبار استيراد وحدة كتالوج القوالب:") +try: + from modules.pricing.services.templates_catalog.templates_catalog import TemplatesCatalog + print("✅ تم استيراد وحدة كتالوج القوالب بنجاح") +except Exception as e: + print(f"❌ فشل استيراد وحدة كتالوج القوالب: {str(e)}") + +# اختبار استيراد وحدة pdf_handler +print("\n4. اختبار استيراد وحدة معالج PDF:") +try: + from utils.pdf_handler import export_pricing_to_pdf + print("✅ تم استيراد وحدة معالج PDF بنجاح") +except Exception as e: + print(f"❌ فشل استيراد وحدة معالج PDF: {str(e)}") + +print("\n=== النهاية ===") \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8f4760816b5ced57815849102acdbd9a607337ec --- /dev/null +++ b/tests/README.md @@ -0,0 +1,130 @@ +# اختبارات نظام تسعير المناقصات + +يحتوي هذا المجلد على اختبارات وحدة واختبارات تكامل لنظام تسعير المناقصات. + +## هيكل المجلد + +- `unit/`: اختبارات الوحدة للمكونات الفردية +- `integration/`: اختبارات التكامل بين مكونات النظام المختلفة + +## تشغيل الاختبارات + +### تشغيل جميع الاختبارات + +لتشغيل جميع الاختبارات، قم بتنفيذ الأمر التالي من المجلد الرئيسي للمشروع: + +```bash +python -m unittest discover -s tests +``` + +### تشغيل اختبارات الوحدة فقط + +```bash +python -m unittest discover -s tests/unit +``` + +### تشغيل اختبارات التكامل فقط + +```bash +python -m unittest discover -s tests/integration +``` + +### تشغيل ملف اختبار محدد + +```bash +python -m unittest tests/unit/test_reports.py +``` + +## كتابة اختبارات جديدة + +### اختبارات الوحدة + +اختبارات الوحدة تركز على اختبار مكون واحد معزول عن باقي النظام. يجب اتباع النمط التالي: + +```python +import unittest + +class TestComponentName(unittest.TestCase): + + def setUp(self): + # إعداد بيئة الاختبار + pass + + def tearDown(self): + # تنظيف بيئة الاختبار + pass + + def test_functionality_name(self): + # اختبار وظيفة معينة + result = function_to_test(params) + self.assertEqual(result, expected_value) +``` + +### اختبارات التكامل + +اختبارات التكامل تركز على التفاعل بين مكونين أو أكثر. يجب اتباع النمط التالي: + +```python +import unittest + +class TestIntegrationName(unittest.TestCase): + + def setUp(self): + # إعداد بيئة الاختبار + # إنشاء المكونات المختلفة المطلوبة + pass + + def tearDown(self): + # تنظيف بيئة الاختبار + pass + + def test_integration_scenario(self): + # اختبار سيناريو تكامل محدد + result = component1.action(params) + component2.process(result) + final_result = component2.get_result() + self.assertEqual(final_result, expected_value) +``` + +## المحاكاة والاستبدال + +في بعض الحالات، قد نحتاج إلى محاكاة بعض المكونات لعزل المكون الذي نختبره. يمكن استخدام مكتبة `unittest.mock` لهذا الغرض: + +```python +from unittest.mock import Mock, patch + +class TestWithMocking(unittest.TestCase): + + @patch('module.ClassName') + def test_with_mock(self, MockClass): + # إعداد المحاكاة + instance = MockClass.return_value + instance.method.return_value = 'mocked_result' + + # استدعاء الدالة التي تستخدم الكائن المحاكى + result = function_to_test() + + # التحقق من النتائج ومن استدعاء المحاكاة + self.assertEqual(result, expected_value) + instance.method.assert_called_once_with(expected_params) +``` + +## التغطية + +يمكن قياس تغطية الاختبارات باستخدام أداة `coverage`: + +```bash +# تثبيت أداة coverage +pip install coverage + +# تشغيل الاختبارات مع قياس التغطية +coverage run -m unittest discover -s tests + +# عرض تقرير التغطية +coverage report + +# إنشاء تقرير HTML للتغطية +coverage html +``` + +ملف تقرير HTML سيكون في مجلد `htmlcov/index.html`. \ No newline at end of file diff --git a/tests/integration/test_dashboard.py b/tests/integration/test_dashboard.py new file mode 100644 index 0000000000000000000000000000000000000000..49efb4e9926e1521a19fe1efe53163c38d01e320 --- /dev/null +++ b/tests/integration/test_dashboard.py @@ -0,0 +1,266 @@ +""" +اختبارات تكامل للوحة المعلومات +""" + +import unittest +import pandas as pd +import numpy as np +import os +import sys +import json +from datetime import datetime, timedelta + +# إضافة المسار الرئيسي للمشروع لاستيراد الوحدات +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from modules.reports.reports_app import ReportsApp +import streamlit as st + + +class TestDashboard(unittest.TestCase): + """اختبارات تكامل للوحة المعلومات""" + + def setUp(self): + """إعداد بيئة الاختبار""" + self.reports_app = ReportsApp() + + # إنشاء بيانات محاكاة للمشاريع + self.mock_projects = [ + { + 'id': 1, + 'name': 'مشروع 1', + 'number': 'T-2024001', + 'client': 'وزارة الصحة', + 'location': 'الرياض', + 'status': 'جديد', + 'submission_date': '2024-01-15', + 'tender_type': 'عامة', + 'created_at': '2024-01-10', + 'value': 1500000, + 'local_content': 75 + }, + { + 'id': 2, + 'name': 'مشروع 2', + 'number': 'T-2024002', + 'client': 'وزارة التعليم', + 'location': 'جدة', + 'status': 'قيد التسعير', + 'submission_date': '2024-01-28', + 'tender_type': 'خاصة', + 'created_at': '2024-01-20', + 'value': 4500000, + 'local_content': 80 + }, + { + 'id': 3, + 'name': 'مشروع 3', + 'number': 'T-2024003', + 'client': 'وزارة الصحة', + 'location': 'الدمام', + 'status': 'تم التقديم', + 'submission_date': '2024-02-10', + 'tender_type': 'عامة', + 'created_at': '2024-02-01', + 'value': 8000000, + 'local_content': 70 + }, + { + 'id': 4, + 'name': 'مشروع 4', + 'number': 'T-2024004', + 'client': 'شركة أرامكو', + 'location': 'الظهران', + 'status': 'تمت الترسية', + 'submission_date': '2024-02-15', + 'tender_type': 'عامة', + 'created_at': '2024-02-05', + 'value': 15000000, + 'local_content': 85 + }, + { + 'id': 5, + 'name': 'مشروع 5', + 'number': 'T-2024005', + 'client': 'شركة سابك', + 'location': 'الجبيل', + 'status': 'قيد التنفيذ', + 'submission_date': '2024-02-20', + 'tender_type': 'خاصة', + 'created_at': '2024-02-10', + 'value': 25000000, + 'local_content': 75 + }, + { + 'id': 6, + 'name': 'مشروع 6', + 'number': 'T-2024006', + 'client': 'وزارة النقل', + 'location': 'الرياض', + 'status': 'منتهي', + 'submission_date': '2024-01-05', + 'tender_type': 'عامة', + 'created_at': '2023-12-20', + 'value': 5000000, + 'local_content': 72 + }, + { + 'id': 7, + 'name': 'مشروع 7', + 'number': 'T-2024007', + 'client': 'وزارة الإسكان', + 'location': 'مكة', + 'status': 'ملغي', + 'submission_date': '2024-01-10', + 'tender_type': 'خاصة', + 'created_at': '2023-12-25', + 'value': 12000000, + 'local_content': 65 + } + ] + + # تعيين المشاريع في حالة الجلسة + st.session_state.projects = self.mock_projects + + def test_dashboard_metrics(self): + """اختبار مؤشرات لوحة المعلومات الرئيسية""" + # الحصول على المؤشرات + total_projects = self.reports_app._get_total_projects() + active_projects = self.reports_app._get_active_projects() + won_projects = self.reports_app._get_won_projects() + avg_local_content = self.reports_app._get_avg_local_content() + + # التحقق من المؤشرات + self.assertEqual(total_projects, 7) + self.assertEqual(active_projects, 4) # قيد التسعير، تم التقديم، تمت الترسية، قيد التنفيذ + self.assertEqual(won_projects, 3) # تمت الترسية، قيد التنفيذ، منتهي + + # التحقق من أن متوسط المحتوى المحلي قريب من القيمة المتوقعة + expected_avg_local_content = sum(p['local_content'] for p in self.mock_projects) / len(self.mock_projects) + self.assertAlmostEqual(avg_local_content, expected_avg_local_content, delta=1.0) + + def test_project_status_distribution(self): + """اختبار توزيع المشاريع حسب الحالة""" + # الحصول على بيانات التوزيع + status_data = self.reports_app._get_project_status_data() + + # التحقق من أن البيانات إطار بيانات + self.assertIsInstance(status_data, pd.DataFrame) + + # التحقق من الأعمدة + self.assertIn('status', status_data.columns) + self.assertIn('count', status_data.columns) + + # التحقق من أن عدد الحالات الفريدة يساوي 7 + self.assertEqual(len(status_data), 7) + + # التحقق من أن مجموع المشاريع صحيح + self.assertEqual(status_data['count'].sum(), 7) + + # التحقق من عدد المشاريع في كل حالة + status_counts = status_data.set_index('status')['count'].to_dict() + self.assertEqual(status_counts['جديد'], 1) + self.assertEqual(status_counts['قيد التسعير'], 1) + self.assertEqual(status_counts['تم التقديم'], 1) + self.assertEqual(status_counts['تمت الترسية'], 1) + self.assertEqual(status_counts['قيد التنفيذ'], 1) + self.assertEqual(status_counts['منتهي'], 1) + self.assertEqual(status_counts['ملغي'], 1) + + def test_monthly_trend_data(self): + """اختبار بيانات الاتجاه الشهري""" + # الحصول على بيانات الاتجاه + monthly_data = self.reports_app._get_monthly_project_data() + + # التحقق من أن البيانات إطار بيانات + self.assertIsInstance(monthly_data, pd.DataFrame) + + # التحقق من الأعمدة + self.assertIn('month', monthly_data.columns) + self.assertIn('new', monthly_data.columns) + self.assertIn('submitted', monthly_data.columns) + self.assertIn('won', monthly_data.columns) + + # التحقق من أن عدد الأشهر يساوي 6 + self.assertEqual(len(monthly_data), 6) + + def test_project_value_distribution(self): + """اختبار توزيع المشاريع حسب القيمة""" + # الحصول على بيانات التوزيع + value_data = self.reports_app._get_project_value_data() + + # التحقق من أن البيانات إطار بيانات + self.assertIsInstance(value_data, pd.DataFrame) + + # التحقق من الأعمدة + self.assertIn('range', value_data.columns) + self.assertIn('count', value_data.columns) + + # التحقق من أن عدد النطاقات يساوي 7 + self.assertEqual(len(value_data), 7) + + # التحقق من أن عدد المشاريع صحيح + self.assertEqual(value_data['count'].sum(), 7) + + # التحقق من نطاقات القيمة + value_ranges = value_data['range'].tolist() + self.assertIn('أقل من 1 مليون', value_ranges) + self.assertIn('1-5 مليون', value_ranges) + self.assertIn('5-10 مليون', value_ranges) + self.assertIn('10-20 مليون', value_ranges) + self.assertIn('20-50 مليون', value_ranges) + + def test_custom_report_generation(self): + """اختبار إنشاء التقارير المخصصة""" + # اختبار إنشاء تقرير جدول + table_data = self.reports_app._generate_sample_data( + data_source="المشاريع", + fields=["اسم المشروع", "العميل", "الحالة", "تاريخ التقديم"], + rows=10 + ) + + # التحقق من النتائج + self.assertIsInstance(table_data, pd.DataFrame) + self.assertEqual(len(table_data), 10) + self.assertEqual(len(table_data.columns), 4) + + # اختبار فلترة البيانات + filters = [ + { + 'field': 'العميل', + 'operator': 'يساوي', + 'value': 'وزارة الصحة' + } + ] + + filtered_data = table_data.copy() + for filter_info in filters: + field = filter_info['field'] + operator = filter_info['operator'] + value = filter_info['value'] + + if field in filtered_data.columns: + if operator == "يساوي": + filtered_data = filtered_data[filtered_data[field] == value] + + # التحقق من أن الفلترة تعمل بشكل صحيح + if not filtered_data.empty: + self.assertTrue(all(client == 'وزارة الصحة' for client in filtered_data['العميل'])) + + def test_report_export_functionality(self): + """اختبار وظيفة تصدير التقارير""" + # في هذا الاختبار نفترض وجود وظيفة تصدير تعمل بشكل صحيح + # ونتحقق فقط من أن البيانات جاهزة للتصدير + + # الحصول على بيانات المشاريع + project_data = pd.DataFrame(self.mock_projects) + + # التحقق من أن البيانات جاهزة للتصدير + self.assertTrue(len(project_data) > 0) + self.assertIn('name', project_data.columns) + self.assertIn('client', project_data.columns) + self.assertIn('status', project_data.columns) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/integration/test_report_export.py b/tests/integration/test_report_export.py new file mode 100644 index 0000000000000000000000000000000000000000..0561b237c913f57dd21488a9950fd0fee69e5177 --- /dev/null +++ b/tests/integration/test_report_export.py @@ -0,0 +1,246 @@ +""" +اختبارات تكامل لتصدير التقارير +""" + +import unittest +import pandas as pd +import os +import sys +import tempfile +import shutil +from datetime import datetime + +# إضافة المسار الرئيسي للمشروع لاستيراد الوحدات +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from modules.reports.reports_app import ReportsApp +from utils.excel_handler import export_to_excel + + +class TestReportExport(unittest.TestCase): + """اختبارات تكامل لتصدير التقارير""" + + def setUp(self): + """إعداد بيئة الاختبار""" + self.reports_app = ReportsApp() + + # إنشاء مجلد مؤقت للملفات المصدرة + self.test_dir = tempfile.mkdtemp() + + # إنشاء بيانات محاكاة للمشاريع + self.projects_data = pd.DataFrame({ + 'اسم المشروع': [f'مشروع {i}' for i in range(1, 6)], + 'رقم المناقصة': [f'T-2024{i:03d}' for i in range(1, 6)], + 'العميل': ['وزارة الصحة', 'وزارة التعليم', 'وزارة الصحة', 'شركة أرامكو', 'شركة سابك'], + 'الموقع': ['الرياض', 'جدة', 'الدمام', 'الظهران', 'الجبيل'], + 'الحالة': ['جديد', 'قيد التسعير', 'تم التقديم', 'تمت الترسية', 'قيد التنفيذ'], + 'تاريخ التقديم': ['2024-01-15', '2024-01-28', '2024-02-10', '2024-02-15', '2024-02-20'], + 'نوع المناقصة': ['عامة', 'خاصة', 'عامة', 'عامة', 'خاصة'] + }) + + # إنشاء بيانات محاكاة للتسعير + self.pricing_data = pd.DataFrame({ + 'اسم المشروع': ['مشروع 1', 'مشروع 1', 'مشروع 1', 'مشروع 2', 'مشروع 2'], + 'رقم البند': ['A001', 'A002', 'A003', 'B001', 'B002'], + 'وصف البند': [ + 'أعمال الحفر والردم', + 'أعمال الخرسانة المسلحة', + 'أعمال حديد التسليح', + 'أعمال البلوك', + 'أعمال اللياسة' + ], + 'الوحدة': ['م3', 'م3', 'طن', 'م2', 'م2'], + 'الكمية': [1000, 500, 50, 800, 1200], + 'سعر الوحدة': [50, 1200, 5000, 120, 80], + 'الإجمالي': [50000, 600000, 250000, 96000, 96000] + }) + + # إنشاء بيانات محاكاة للمخاطر + self.risks_data = pd.DataFrame({ + 'رمز المخاطرة': [f'R{i:03d}' for i in range(1, 6)], + 'اسم المشروع': ['مشروع 1', 'مشروع 1', 'مشروع 2', 'مشروع 3', 'مشروع 4'], + 'وصف المخاطرة': [ + 'غرامة تأخير مرتفعة', + 'مدة تنفيذ قصيرة', + 'متطلبات ضمان بنكي مرتفعة', + 'شروط دفع متأخرة', + 'متطلبات تأمين شاملة ومكلفة' + ], + 'الفئة': [ + 'مخاطر مالية', + 'مخاطر زمنية', + 'مخاطر مالية', + 'مخاطر مالية', + 'مخاطر مالية' + ], + 'التأثير': ['عالي', 'متوسط', 'منخفض', 'عالي', 'متوسط'], + 'الاحتمالية': ['محتمل', 'مؤكد', 'غير محتمل', 'محتمل', 'غير محتمل'], + 'درجة المخاطرة': [6, 6, 2, 8, 4] + }) + + def tearDown(self): + """تنظيف بيئة الاختبار""" + # حذف المجلد المؤقت + shutil.rmtree(self.test_dir) + + def test_export_projects_report_to_excel(self): + """اختبار تصدير تقرير المشاريع إلى Excel""" + # إنشاء مسار الملف المصدر + output_file = os.path.join(self.test_dir, 'projects_report.xlsx') + + # تصدير البيانات + result = export_to_excel(self.projects_data, output_file, sheet_name='المشاريع', title='تقرير المشاريع') + + # التحقق من نجاح التصدير + self.assertTrue(result) + self.assertTrue(os.path.exists(output_file)) + + # التحقق من محتوى الملف المصدر + imported_data = pd.read_excel(output_file, sheet_name='المشاريع', skiprows=2) # تخطي الصفوف الأولى (العنوان) + + # التحقق من عدد الصفوف والأعمدة + self.assertEqual(len(imported_data), len(self.projects_data)) + self.assertEqual(len(imported_data.columns), len(self.projects_data.columns)) + + def test_export_pricing_report_to_excel(self): + """اختبار تصدير تقرير التسعير إلى Excel""" + # إنشاء مسار الملف المصدر + output_file = os.path.join(self.test_dir, 'pricing_report.xlsx') + + # تصدير البيانات + result = export_to_excel(self.pricing_data, output_file, sheet_name='التسعير', title='تقرير التسعير') + + # التحقق من نجاح التصدير + self.assertTrue(result) + self.assertTrue(os.path.exists(output_file)) + + # التحقق من محتوى الملف المصدر + imported_data = pd.read_excel(output_file, sheet_name='التسعير', skiprows=2) # تخطي الصفوف الأولى (العنوان) + + # التحقق من عدد الصفوف والأعمدة + self.assertEqual(len(imported_data), len(self.pricing_data)) + self.assertEqual(len(imported_data.columns), len(self.pricing_data.columns)) + + def test_export_multiple_sheets(self): + """اختبار تصدير تقرير متعدد الصفحات""" + # إنشاء مسار الملف المصدر + output_file = os.path.join(self.test_dir, 'combined_report.xlsx') + + # تصدير البيانات متعددة الصفحات + result1 = export_to_excel(self.projects_data, output_file, sheet_name='المشاريع', title='تقرير المشاريع') + result2 = export_to_excel(self.pricing_data, output_file, sheet_name='التسعير', title='تقرير التسعير', append=True) + result3 = export_to_excel(self.risks_data, output_file, sheet_name='المخاطر', title='تقرير المخاطر', append=True) + + # التحقق من نجاح التصدير + self.assertTrue(result1) + self.assertTrue(result2) + self.assertTrue(result3) + self.assertTrue(os.path.exists(output_file)) + + # التحقق من وجود جميع الصفحات + excel_file = pd.ExcelFile(output_file) + self.assertIn('المشاريع', excel_file.sheet_names) + self.assertIn('التسعير', excel_file.sheet_names) + self.assertIn('المخاطر', excel_file.sheet_names) + + # التحقق من محتوى كل صفحة + projects_data = pd.read_excel(output_file, sheet_name='المشاريع', skiprows=2) + pricing_data = pd.read_excel(output_file, sheet_name='التسعير', skiprows=2) + risks_data = pd.read_excel(output_file, sheet_name='المخاطر', skiprows=2) + + self.assertEqual(len(projects_data), len(self.projects_data)) + self.assertEqual(len(pricing_data), len(self.pricing_data)) + self.assertEqual(len(risks_data), len(self.risks_data)) + + def test_export_with_formatting(self): + """اختبار تصدير تقرير مع التنسيق""" + # إنشاء مسار الملف المصدر + output_file = os.path.join(self.test_dir, 'formatted_report.xlsx') + + # تصدير البيانات مع تنسيق + formatting = { + 'header_format': { + 'bold': True, + 'bg_color': '#4F81BD', + 'font_color': 'white', + 'border': 1, + 'align': 'center' + }, + 'row_formats': [ + { + 'columns': ['الإجمالي'], + 'num_format': '#,##0.00 ريال' + }, + { + 'columns': ['الكمية'], + 'num_format': '#,##0.00' + } + ], + 'conditional_formats': [ + { + 'column': 'درجة المخاطرة', + 'criteria': '>', + 'value': 6, + 'format': {'bg_color': '#FF9999'} + } + ] + } + + result = export_to_excel(self.risks_data, output_file, sheet_name='المخاطر', + title='تقرير المخاطر', formatting=formatting) + + # التحقق من نجاح التصدير + self.assertTrue(result) + self.assertTrue(os.path.exists(output_file)) + + def test_export_with_filters(self): + """اختبار تصدير تقرير مع تصفية""" + # إنشاء مسار الملف المصدر + output_file = os.path.join(self.test_dir, 'filtered_report.xlsx') + + # تصدير البيانات المشاريع المفلترة (فقط مشاريع وزارة الصحة) + filtered_data = self.projects_data[self.projects_data['العميل'] == 'وزارة الصحة'] + result = export_to_excel(filtered_data, output_file, sheet_name='مشاريع الصحة', + title='تقرير مشاريع وزارة الصحة') + + # التحقق من نجاح التصدير + self.assertTrue(result) + self.assertTrue(os.path.exists(output_file)) + + # التحقق من محتوى الملف المصدر + imported_data = pd.read_excel(output_file, sheet_name='مشاريع الصحة', skiprows=2) + + # التحقق من أن جميع المشاريع تنتمي لوزارة الصحة + self.assertEqual(len(imported_data), 2) + self.assertTrue(all(client == 'وزارة الصحة' for client in imported_data['العميل'])) + + def test_export_with_summary(self): + """اختبار تصدير تقرير مع ملخص""" + # إنشاء مسار الملف المصدر + output_file = os.path.join(self.test_dir, 'summary_report.xlsx') + + # إنشاء بيانات الملخص + summary_data = pd.DataFrame({ + 'المؤشر': ['إجمالي المشاريع', 'المشاريع النشطة', 'المشاريع المرساة', 'نسبة النجاح'], + 'القيمة': [5, 4, 2, '40%'] + }) + + # تصدير البيانات مع ملخص + result1 = export_to_excel(summary_data, output_file, sheet_name='الملخص', + title='ملخص تقرير المشاريع') + result2 = export_to_excel(self.projects_data, output_file, sheet_name='البيانات', + title='تفاصيل المشاريع', append=True) + + # التحقق من نجاح التصدير + self.assertTrue(result1) + self.assertTrue(result2) + self.assertTrue(os.path.exists(output_file)) + + # التحقق من وجود صفحات الملخص والبيانات + excel_file = pd.ExcelFile(output_file) + self.assertIn('الملخص', excel_file.sheet_names) + self.assertIn('البيانات', excel_file.sheet_names) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/unit/test_reports.py b/tests/unit/test_reports.py new file mode 100644 index 0000000000000000000000000000000000000000..911c98227636e90af1fd9b387f6683a6dc6c3770 --- /dev/null +++ b/tests/unit/test_reports.py @@ -0,0 +1,200 @@ +""" +اختبارات وحدة تقارير المشاريع +""" + +import unittest +import pandas as pd +import numpy as np +import sys +import os +from datetime import datetime, timedelta + +# إضافة المسار الرئيسي للمشروع لاستيراد الوحدات +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + +from modules.reports.reports_app import ReportsApp + + +class TestReportsApp(unittest.TestCase): + """اختبارات وحدة لتطبيق التقارير""" + + def setUp(self): + """إعداد بيئة الاختبار""" + self.reports_app = ReportsApp() + + # إنشاء بيانات محاكاة للمشاريع + self.mock_projects = [ + { + 'id': 1, + 'name': 'مشروع 1', + 'number': 'T-2024001', + 'client': 'وزارة الصحة', + 'location': 'الرياض', + 'status': 'جديد', + 'submission_date': '2024-01-15', + 'tender_type': 'عامة', + 'created_at': '2024-01-10' + }, + { + 'id': 2, + 'name': 'مشروع 2', + 'number': 'T-2024002', + 'client': 'وزارة التعليم', + 'location': 'جدة', + 'status': 'قيد التسعير', + 'submission_date': '2024-01-28', + 'tender_type': 'خاصة', + 'created_at': '2024-01-20' + }, + { + 'id': 3, + 'name': 'مشروع 3', + 'number': 'T-2024003', + 'client': 'وزارة الصحة', + 'location': 'الدمام', + 'status': 'تم التقديم', + 'submission_date': '2024-02-10', + 'tender_type': 'عامة', + 'created_at': '2024-02-01' + }, + { + 'id': 4, + 'name': 'مشروع 4', + 'number': 'T-2024004', + 'client': 'شركة أرامكو', + 'location': 'الظهران', + 'status': 'تمت الترسية', + 'submission_date': '2024-02-15', + 'tender_type': 'عامة', + 'created_at': '2024-02-05' + }, + { + 'id': 5, + 'name': 'مشروع 5', + 'number': 'T-2024005', + 'client': 'شركة سابك', + 'location': 'الجبيل', + 'status': 'قيد التنفيذ', + 'submission_date': '2024-02-20', + 'tender_type': 'خاصة', + 'created_at': '2024-02-10' + } + ] + + def test_get_total_projects(self): + """اختبار حساب إجمالي عدد المشاريع""" + # تعيين المشاريع في حالة الجلسة + import streamlit as st + st.session_state.projects = self.mock_projects + + # اختبار الدالة + total = self.reports_app._get_total_projects() + self.assertEqual(total, 5) + + def test_get_active_projects(self): + """اختبار حساب عدد المشاريع النشطة""" + # تعيين المشاريع في حالة الجلسة + import streamlit as st + st.session_state.projects = self.mock_projects + + # اختبار الدالة + active = self.reports_app._get_active_projects() + self.assertEqual(active, 4) # جميع المشاريع باستثناء الجديدة أو المنتهية أو الملغية + + def test_get_won_projects(self): + """اختبار حساب عدد المشاريع المرساة""" + # تعيين المشاريع في حالة الجلسة + import streamlit as st + st.session_state.projects = self.mock_projects + + # اختبار الدالة + won = self.reports_app._get_won_projects() + self.assertEqual(won, 2) # المشاريع المرساة وقيد التنفيذ والمنتهية + + def test_get_project_status_data(self): + """اختبار الحصول على بيانات توزيع المشاريع حسب الحالة""" + # تعيين المشاريع في حالة الجلسة + import streamlit as st + st.session_state.projects = self.mock_projects + + # اختبار الدالة + status_data = self.reports_app._get_project_status_data() + + self.assertIsInstance(status_data, pd.DataFrame) + self.assertEqual(len(status_data), 5) # 5 حالات مختلفة + + # التحقق من أن عدد المشاريع لكل حالة صحيح + status_counts = status_data.set_index('status')['count'].to_dict() + self.assertEqual(status_counts['جديد'], 1) + self.assertEqual(status_counts['قيد التسعير'], 1) + self.assertEqual(status_counts['تم التقديم'], 1) + self.assertEqual(status_counts['تمت الترسية'], 1) + self.assertEqual(status_counts['قيد التنفيذ'], 1) + + def test_get_monthly_project_data(self): + """اختبار الحصول على بيانات اتجاه المشاريع الشهري""" + # اختبار الدالة + monthly_data = self.reports_app._get_monthly_project_data() + + self.assertIsInstance(monthly_data, pd.DataFrame) + self.assertEqual(len(monthly_data), 6) # 6 أشهر + + # التحقق من وجود الأعمدة المطلوبة + self.assertIn('month', monthly_data.columns) + self.assertIn('new', monthly_data.columns) + self.assertIn('submitted', monthly_data.columns) + self.assertIn('won', monthly_data.columns) + + def test_get_project_value_data(self): + """اختبار الحصول على بيانات توزيع قيم المشاريع""" + # اختبار الدالة + value_data = self.reports_app._get_project_value_data() + + self.assertIsInstance(value_data, pd.DataFrame) + self.assertEqual(len(value_data), 7) # 7 نطاقات قيمة + + # التحقق من وجود الأعمدة المطلوبة + self.assertIn('range', value_data.columns) + self.assertIn('count', value_data.columns) + + def test_generate_sample_data(self): + """اختبار توليد البيانات العشوائية للمحاكاة""" + # مشاريع + project_fields = ["اسم المشروع", "رقم المناقصة", "العميل", "الموقع", "الحالة"] + project_data = self.reports_app._generate_sample_data("المشاريع", project_fields, 10) + + self.assertIsInstance(project_data, pd.DataFrame) + self.assertEqual(len(project_data), 10) + for field in project_fields: + self.assertIn(field, project_data.columns) + + # تسعير + pricing_fields = ["اسم المشروع", "رقم البند", "وصف البند", "الكمية", "سعر الوحدة"] + pricing_data = self.reports_app._generate_sample_data("التسعير", pricing_fields, 10) + + self.assertIsInstance(pricing_data, pd.DataFrame) + self.assertEqual(len(pricing_data), 10) + for field in pricing_fields: + self.assertIn(field, pricing_data.columns) + + # مخاطر + risk_fields = ["اسم المشروع", "رمز المخاطرة", "وصف المخاطرة", "الفئة"] + risk_data = self.reports_app._generate_sample_data("المخاطر", risk_fields, 10) + + self.assertIsInstance(risk_data, pd.DataFrame) + self.assertEqual(len(risk_data), 10) + for field in risk_fields: + self.assertIn(field, risk_data.columns) + + # محتوى محلي + local_content_fields = ["اسم المشروع", "الفئة", "البند", "المورد", "التكلفة"] + local_content_data = self.reports_app._generate_sample_data("المحتوى المحلي", local_content_fields, 10) + + self.assertIsInstance(local_content_data, pd.DataFrame) + self.assertEqual(len(local_content_data), 10) + for field in local_content_fields: + self.assertIn(field, local_content_data.columns) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/utils/assets/logo.svg b/utils/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..ec20e0466cc00b9ff9cc5850ccbd43f614044d68 --- /dev/null +++ b/utils/assets/logo.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + واهبي + + + للتحليل الذكي + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/utils/components/about_system.py b/utils/components/about_system.py new file mode 100644 index 0000000000000000000000000000000000000000..bc1df4d3017e4c00cdb48179b65c5fa9481b21d0 --- /dev/null +++ b/utils/components/about_system.py @@ -0,0 +1,262 @@ +""" +مكون عرض معلومات حول النظام +""" + +import streamlit as st + + +def display_about_system(): + """ + عرض معلومات حول النظام وميزاته + """ + # مربع معلومات النظام + st.markdown(""" +
+

نبذة عن النظام

+

+ نظام WAHBi AI هو نظام متكامل لتحليل العقود والمناقصات باستخدام تقنيات الذكاء الاصطناعي المتقدمة. + تم تطوير النظام خصيصًا لشركة شبه الجزيرة للمقاولات لتمكينها من تحليل وثائق المناقصات، وتسعير المشاريع، وتقييم المخاطر، + وإدارة الموارد بكفاءة عالية. +

+ +

المميزات الرئيسية

+
    +
  • تحليل المستندات ذكيًا - استخراج البنود والكميات والمخاطر من المستندات تلقائيًا
  • +
  • حاسبة تكاليف البناء المتكاملة - حساب التكاليف التفصيلية للمواد الخام والمعدات والعمالة والمصاريف الإدارية وهوامش الربح
  • +
  • تحليل الأسعار غير المتزنة - تحليل ومقارنة الأسعار مع متوسطات السوق لتحقيق أقصى استفادة
  • +
  • تتبع المعدات والموارد - متابعة وتنظيم موارد الشركة وتخصيصها للمشاريع المختلفة
  • +
  • تحليل المخاطر - تحديد المخاطر المحتملة في المشاريع وتقييم تأثيرها واقتراح إجراءات التخفيف
  • +
  • مؤشرات أداء المشاريع - متابعة الأداء المالي والفني للمشاريع وقياس الإنجاز
  • +
  • حاسبة المحتوى المحلي - حساب وتحسين نسبة المحتوى المحلي في المشاريع لتلبية متطلبات المملكة
  • +
  • التنبؤ بأسعار المواد - استخدام نماذج التعلم الآلي للتنبؤ بتغيرات الأسعار المستقبلية
  • +
  • مؤقت مواعيد التسليم - متابعة مواعيد بدء الدراسة ومواعيد تسليم العروض للمشاريع
  • +
  • دعم اللغة العربية - واجهة مستخدم باللغة العربية مع إمكانية التبديل للغة الإنجليزية
  • +
  • توافق متعدد الأجهزة - يعمل على جميع الأجهزة (أيفون، أندرويد، أجهزة لوحية، حواسيب مكتبية)
  • +
+ +

معلومات الاتصال

+
+

العنوان: المملكة العربية السعودية - الرياض - حي الملز - شارع السبالة

+

البريد الإلكتروني: info@peninsula-contracting.com

+

هاتف: +966 12 345 6789

+

الموقع الإلكتروني: www.peninsula-contracting.com

+
+ +

فريق التطوير

+

+ تم تطوير هذا النظام بواسطة فريق متخصص من المهندسين والمطورين بقيادة م. بدر وهبي، + بالتعاون مع فريق التسعير والمشاريع في شركة شبه الجزيرة للمقاولات. +

+ +

الإصدار والترخيص

+

+ رقم الإصدار: 2.5.0 (مارس 2025)
+ الترخيص: جميع الحقوق محفوظة © 2025 شركة شبه الجزيرة للمقاولات +

+
+ """, unsafe_allow_html=True) + + # أضف مساحة أسفل المحتوى + st.markdown("

", unsafe_allow_html=True) + + # إضافة أزرار تفاعلية + col1, col2, col3 = st.columns(3) + + with col1: + st.button("تحميل دليل المستخدم") + + with col2: + st.button("الدعم الفني") + + with col3: + st.button("التحديثات والمزايا القادمة") + + +def display_settings(): + """ + عرض صفحة الإعدادات + """ + st.markdown("

إعدادات النظام

", unsafe_allow_html=True) + + # تقسيم الصفحة إلى قسمين + col1, col2 = st.columns(2) + + with col1: + st.markdown(""" +
+
+

خيارات اللغة

+
+ +
+
+
+ """, unsafe_allow_html=True) + + language = st.selectbox( + "اختر لغة الواجهة", + options=["العربية", "English"], + index=0, + label_visibility="collapsed" + ) + + st.markdown(""" +
+
+

الإشعارات

+
+ +
+
+
+ """, unsafe_allow_html=True) + + notifications_email = st.checkbox("إشعارات البريد الإلكتروني", value=True) + notifications_sms = st.checkbox("إشعارات الرسائل النصية SMS", value=False) + notifications_system = st.checkbox("إشعارات النظام", value=True) + + if st.button("حفظ الإعدادات"): + st.success("تم حفظ الإعدادات بنجاح") + + with col2: + st.markdown(""" +
+
+

معلومات الحساب

+
+
+ """, unsafe_allow_html=True) + + username = st.text_input("اسم المستخدم", value="admin") + email = st.text_input("البريد الإلكتروني", value="admin@peninsula-contracting.com") + + st.markdown(""" +
+
+

تغيير كلمة المرور

+
+
+ """, unsafe_allow_html=True) + + current_password = st.text_input("كلمة المرور الحالية", type="password") + new_password = st.text_input("كلمة المرور الجديدة", type="password") + confirm_password = st.text_input("تأكيد كلمة المرور الجديدة", type="password") + + if st.button("تغيير كلمة المرور"): + if not current_password or not new_password or not confirm_password: + st.error("يرجى ملء جميع الحقول") + elif new_password != confirm_password: + st.error("كلمات المرور غير متطابقة") + else: + st.success("تم تغيير كلمة المرور بنجاح") + + # إضافة مزيد من الإعدادات + st.markdown("
", unsafe_allow_html=True) + st.markdown("

إعدادات النظام المتقدمة

", unsafe_allow_html=True) + + col3, col4 = st.columns(2) + + with col3: + theme = st.selectbox( + "سمة النظام", + options=["الافتراضية", "الوضع الفاتح", "الوضع الداكن"] + ) + + date_format = st.selectbox( + "تنسيق التاريخ", + options=["DD/MM/YYYY", "MM/DD/YYYY", "YYYY-MM-DD"] + ) + + with col4: + currency = st.selectbox( + "العملة الافتراضية", + options=["ريال سعودي (SAR)", "دولار أمريكي (USD)", "يورو (EUR)"] + ) + + notifications_frequency = st.selectbox( + "تكرار الإشعارات", + options=["فوري", "يومي", "أسبوعي"] + ) + + +def display_countdown_timer(): + """ + عرض مؤقت العد التنازلي للمواعيد النهائية + """ + st.markdown("

مواعيد المناقصات

", unsafe_allow_html=True) + + # بيانات المواعيد + deadlines = [ + { + "name": "مناقصة توسعة مستشفى الملك فهد", + "submission_date": "15 أبريل 2025", + "days_left": 15, + "start_date": "1 مارس 2025" + }, + { + "name": "مناقصة إنشاء مبنى كلية الطب", + "submission_date": "30 مارس 2025", + "days_left": 0, + "start_date": "15 فبراير 2025" + }, + { + "name": "مناقصة طريق الدائري الشمالي", + "submission_date": "10 مايو 2025", + "days_left": 40, + "start_date": "5 مارس 2025" + } + ] + + for i, deadline in enumerate(deadlines): + # تحديد لون المؤقت بناءً على عدد الأيام المتبقية + color_class = "danger" if deadline["days_left"] <= 5 else "warning" if deadline["days_left"] <= 15 else "success" + + # عرض معلومات الموعد والمؤقت + st.markdown(f""" +
+

{deadline["name"]}

+
+
+ بدء الدراسة: {deadline["start_date"]} +
+
+ تاريخ التسليم: {deadline["submission_date"]} +
+
+
+
+
+
+
+ متبقي: {deadline["days_left"]} أيام +
+
+ +
+
+
+ """, unsafe_allow_html=True) + + # عرض مؤقت تفصيلي للمناقصة الأولى + st.markdown("

العد التنازلي للتسليم

", unsafe_allow_html=True) + + st.markdown(""" +
+
+
15
+
يوم
+
+
+
08
+
ساعة
+
+
+
45
+
دقيقة
+
+
+
20
+
ثانية
+
+
+ """, unsafe_allow_html=True) \ No newline at end of file diff --git a/utils/components/credits.py b/utils/components/credits.py new file mode 100644 index 0000000000000000000000000000000000000000..64ce1c3673de201c75c09c19319e81e84d7a7055 --- /dev/null +++ b/utils/components/credits.py @@ -0,0 +1,115 @@ +""" +مكون عرض معلومات فريق التطوير +""" + +import streamlit as st + +# تصدير واضح للدالة لجعلها متاحة للاستيراد +__all__ = ['render_credits', 'display_credits'] + + +def render_credits(): + """ + عرض معلومات فريق التطوير (واجهة للاستخدام مع وحدات النظام المختلفة) + """ + display_credits() + + +def display_credits(): + """ + عرض معلومات فريق التطوير + """ + # تعريف بيانات الفريق + team_members = [ + { + "name": "م بدر وهبي", + "role": "مدير المشروع", + "image": "badr.jpg", + "bio": "مهندس برمجيات ذو خبرة 15 عامًا في تطوير أنظمة المقاولات والتسعير" + }, + { + "name": "م تامر الجوهري", + "role": "مهندسة الذكاء الاصطناعي", + "image": "tamer.jpg", + "bio": "متخصصة في معالجة اللغة العربية الطبيعية وتحليل البيانات" + }, + { + "name": "م اسلام عيسي", + "role": "أخصائي تطوير التسعير", + "image": "Islam.jpg", + "bio": "خبير في أنظمة التسعير المتقدمة والتحليل المالي للمشاريع" + } + ] + + # عرض معلومات الفريق في صفوف + # كل صف يحتوي على 3 أعضاء + cols_per_row = 3 + + for i in range(0, len(team_members), cols_per_row): + # إنشاء الأعمدة + cols = st.columns(cols_per_row) + + # عرض الأعضاء في هذا الصف + for j in range(cols_per_row): + idx = i + j + if idx < len(team_members): + member = team_members[idx] + + with cols[j]: + # عرض صورة العضو (استخدام صورة افتراضية إذا لم تكن متوفرة) + try: + st.image(f"static/images/team/{member['image']}", width=150) + except: + # استخدام الأحرف الأولى من الاسم كصورة افتراضية + initials = ''.join([name[0] for name in member['name'].split() if name.startswith('م.') == False]) + st.markdown(f""" +
+ {initials} +
+ """, unsafe_allow_html=True) + + # عرض معلومات العضو + st.markdown(f""" +
+

{member['name']}

+

{member['role']}

+

{member['bio']}

+
+ """, unsafe_allow_html=True) + + # إضافة أسلوب CSS للعرض + st.markdown(""" + + """, unsafe_allow_html=True) \ No newline at end of file diff --git a/utils/components/header.py b/utils/components/header.py new file mode 100644 index 0000000000000000000000000000000000000000..57ff09d01258bbe590009a922232525962ddd647 --- /dev/null +++ b/utils/components/header.py @@ -0,0 +1,135 @@ +""" +مكون الهيدر لنظام واهبي لتحليل العقود والمناقصات +Header component for WAHBI Tender Analysis System +""" + +import streamlit as st +import os +import base64 + +def render_header(subtitle=None): + """ + عرض شريط العنوان في التطبيق مع الشعار + """ + # الحصول على مسار ملف الشعار + logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "logo.svg") + + # إذا كان ملف الشعار موجود، قم بعرضه + if os.path.exists(logo_path): + with open(logo_path, "r", encoding="utf-8") as f: + svg_content = f.read() + + # استخدام HTML مباشرة لعرض الشعار SVG بشكل صحيح + st.markdown(f""" +
+
+ {svg_content} +
+
+

نظام واهبي للذكاء الاصطناعي

+

{subtitle if subtitle else "لتحليل العقود والمناقصات"}

+
+
+ """, unsafe_allow_html=True) + else: + # إذا لم يكن ملف الشعار موجود، عرض نسخة نصية بدون الشعار + st.markdown(f""" +
+

نظام واهبي للذكاء الاصطناعي

+

{subtitle if subtitle else "لتحليل العقود والمناقصات"}

+
+ """, unsafe_allow_html=True) + print(f"تحذير: لم يتم العثور على ملف الشعار في المسار: {logo_path}") + +def render_app_header(): + """ + عرض هيدر التطبيق الرئيسي مع الشعار والعنوان + """ + render_header("النظام المتكامل لتحليل العقود والمناقصات باستخدام الذكاء الاصطناعي") + +def render_section_header(title, description=None, icon=None): + """ + عرض عنوان قسم مع وصف اختياري وأيقونة + """ + icon_html = f'' if icon else '' + + st.markdown(f""" +
+

+ {icon_html}{title} +

+ {f'

{description}

' if description else ''} +
+ """, unsafe_allow_html=True) + +def render_page_title(title, description=None, icon=None): + """ + عرض عنوان صفحة مع وصف اختياري وأيقونة + """ + st.markdown(f""" +

+ {f'' if icon else ''}{title} +

+ {f'

{description}

' if description else ''} + """, unsafe_allow_html=True) + +def render_breadcrumbs(items): + """ + عرض مسار التنقل في الصفحة + + المعلمات: + items (list): قائمة بالعناصر، كل عنصر هو قاموس يحتوي على مفتاحين: "label" و"url" (اختياري) + """ + breadcrumbs_html = "" + for i, item in enumerate(items): + if i > 0: + breadcrumbs_html += f'' + + if "url" in item and item["url"]: + breadcrumbs_html += f'{item["label"]}' + else: + breadcrumbs_html += f'{item["label"]}' + + st.markdown(f""" +
+ {breadcrumbs_html} +
+ """, unsafe_allow_html=True) + +def render_notice(message, type="info"): + """ + عرض إشعار للمستخدم + + المعلمات: + message (str): نص الإشعار + type (str): نوع الإشعار "info", "success", "warning", "error" + """ + icon_map = { + "info": "info-circle", + "success": "check-circle", + "warning": "exclamation-triangle", + "error": "times-circle" + } + + color_map = { + "info": "#1E88E5", + "success": "#4CAF50", + "warning": "#FFC107", + "error": "#F44336" + } + + bg_color_map = { + "info": "#E3F2FD", + "success": "#E8F5E9", + "warning": "#FFF8E1", + "error": "#FFEBEE" + } + + st.markdown(f""" +
+ +
+

{message}

+
+
+ """, unsafe_allow_html=True) \ No newline at end of file diff --git a/utils/components/sidebar.py b/utils/components/sidebar.py new file mode 100644 index 0000000000000000000000000000000000000000..409e9ffc177ae6153f6b72a0c408ea910e2bcdc1 --- /dev/null +++ b/utils/components/sidebar.py @@ -0,0 +1,176 @@ +""" +مكون الشريط الجانبي لنظام واهبي لتحليل العقود والمناقصات +Sidebar component for WAHBI Tender Analysis System +""" + +import streamlit as st +import os +from pathlib import Path +import streamlit_option_menu as option_menu +import json + +def get_user_info(): + """ + استرجاع معلومات المستخدم الحالي (يُستخدم كمثال بسيط) + """ + # في بيئة الإنتاج، هذه المعلومات يجب أن تأتي من نظام المصادقة + return { + "name": "محمد أحمد", + "role": "محلل عقود", + "image": None + } + +def render_sidebar(): + """ + عرض الشريط الجانبي الرئيسي للتطبيق + """ + with st.sidebar: + # عرض معلومات المستخدم + user = get_user_info() + + # مربع معلومات المستخدم + st.markdown(f""" +
+
+ {user["name"][0] if user["name"] else "م"} +
+

{user["name"]}

+

{user["role"]}

+
+ """, unsafe_allow_html=True) + + # القائمة الرئيسية باستخدام كومبوننت الشريط الجانبي + selected = option_menu.option_menu( + menu_title="الوصول السريع", + options=[ + "الرئيسية", + "تحليل العقود", + "حاسبة التكاليف", + "إدارة المشاريع", + "الخريطة التفاعلية", + "المساعد الذكي", + "التقارير", + "الإعدادات" + ], + icons=[ + "house-fill", + "file-earmark-text-fill", + "calculator-fill", + "clipboard2-data-fill", + "geo-alt-fill", + "robot", + "bar-chart-fill", + "gear-fill" + ], + menu_icon="list", + default_index=0, + styles={ + "container": {"padding": "0!important", "background-color": "transparent", "direction": "rtl"}, + "icon": {"color": "#1E88E5", "font-size": "1rem", "float": "right", "margin-left": "10px"}, + "nav-link": { + "font-size": "0.9rem", + "text-align": "right", + "direction": "rtl", + "--hover-color": "#E3F2FD", + "margin-bottom": "0.2rem", + "padding-right": "15px", + }, + "nav-link-selected": {"background-color": "#1E88E5", "color": "white", "text-align": "right"}, + } + ) + + # تخزين القيمة المحددة في session_state + st.session_state["sidebar_selected"] = selected + + # إظهار حالة الاتصال بقاعدة البيانات وحالة النظام + st.markdown(""" +
+

حالة النظام

+
+
+ قاعدة البيانات متصلة +
+
+
+ واجهة برمجة التطبيقات +
+
+
+ الذكاء الاصطناعي +
+
+ """, unsafe_allow_html=True) + + # معلومات النظام وعنوان المشروع + with st.expander("حول النظام", expanded=False): + st.markdown(""" +
+

نظام واهبي للذكاء الاصطناعي - إصدار 2.0

+

تحليل العقود والمناقصات

+

© 2025 جميع الحقوق محفوظة

+
+ """, unsafe_allow_html=True) + +def get_sidebar_selection(): + """ + الحصول على العنصر المحدد في القائمة الجانبية + """ + return st.session_state.get("sidebar_selected", "الرئيسية") + +def render_module_sidebar(module_name, options=[]): + """ + عرض شريط جانبي مخصص للوحدة + + المعلمات: + module_name (str): اسم الوحدة + options (list): قائمة بالخيارات المتاحة في الوحدة + """ + with st.sidebar: + # عنوان الوحدة + st.markdown(f""" +

+ {module_name} +

+ """, unsafe_allow_html=True) + + # إذا تم توفير خيارات للوحدة + if options: + selected = option_menu.option_menu( + menu_title=None, + options=options, + menu_icon=None, + default_index=0, + styles={ + "container": {"padding": "0!important", "background-color": "transparent", "direction": "rtl"}, + "icon": {"color": "#1E88E5", "font-size": "1rem", "float": "right", "margin-left": "10px"}, + "nav-link": { + "font-size": "0.9rem", + "text-align": "right", + "direction": "rtl", + "--hover-color": "#E3F2FD", + "margin-bottom": "0.2rem", + "padding-right": "15px", + "padding": "0.5rem" + }, + "nav-link-selected": {"background-color": "#1E88E5", "color": "white", "text-align": "right"}, + } + ) + + # تخزين الخيار المحدد + st.session_state[f"{module_name}_selected"] = selected + + return selected + + # زر للعودة إلى القائمة الرئيسية + if st.button("العودة للقائمة الرئيسية", key=f"back_btn_{module_name}"): + st.session_state["sidebar_selected"] = "الرئيسية" + st.rerun() + +def get_module_selection(module_name): + """ + الحصول على العنصر المحدد في قائمة الوحدة + + المعلمات: + module_name (str): اسم الوحدة + """ + return st.session_state.get(f"{module_name}_selected", None) \ No newline at end of file diff --git a/utils/components/system_innovation.py b/utils/components/system_innovation.py new file mode 100644 index 0000000000000000000000000000000000000000..072bcdce4ede7d07f11ba97edbad49a662d5765b --- /dev/null +++ b/utils/components/system_innovation.py @@ -0,0 +1,81 @@ +""" +مكون عرض ابتكارات النظام +""" + +import streamlit as st + + +def display_innovations(): + """ + عرض ابتكارات النظام + """ + # تعريف الابتكارات الرئيسية + innovations = [ + { + "title": "تحليل المستندات بالذكاء الاصطناعي", + "description": "استخراج البنود والكميات والمخاطر من المستندات تلقائيًا باستخدام تقنيات الذكاء الاصطناعي ومعالجة اللغة الطبيعية، مما يوفر الوقت والجهد ويقلل من الأخطاء البشرية.", + "icon": "📄" + }, + { + "title": "تقنية التسعير غير المتزن", + "description": "آلية متطورة لتحليل وتطبيق استراتيجيات التسعير غير المتزن، مع ضمان الحفاظ على القيمة الإجمالية للعرض، وزيادة فرص الربحية وتحسين التدفق النقدي.", + "icon": "💰" + }, + { + "title": "حاسبة المحتوى المحلي الذكية", + "description": "حساب وتحسين نسبة المحتوى المحلي في المشاريع بطريقة آلية، مع اقتراح بدائل محلية للمنتجات والخدمات المستوردة لتحقيق متطلبات المحتوى المحلي.", + "icon": "🏭" + }, + { + "title": "نظام التنبؤ بالأسعار", + "description": "التنبؤ بأسعار المواد والخدمات باستخدام خوارزميات التعلم الآلي والبيانات التاريخية، مما يساعد في اتخاذ قرارات التسعير بدقة أكبر.", + "icon": "📊" + }, + { + "title": "تحليل المخاطر الاستباقي", + "description": "تحديد وتحليل المخاطر المحتملة في المشاريع بشكل استباقي، مع توفير استراتيجيات المعالجة المناسبة لكل مخاطرة وتقدير تأثيرها على التكلفة.", + "icon": "⚠️" + } + ] + + # عرض الابتكارات في صفوف + col1, col2 = st.columns(2) + + for i, innovation in enumerate(innovations): + # توزيع الابتكارات على عمودين + current_col = col1 if i % 2 == 0 else col2 + + with current_col: + st.markdown(f""" +
+
{innovation["icon"]}
+

{innovation["title"]}

+

{innovation["description"]}

+
+ """, unsafe_allow_html=True) + + # إضافة أسلوب CSS للبطاقات + st.markdown(""" + + """, unsafe_allow_html=True) \ No newline at end of file diff --git a/utils/css/enhanced.css b/utils/css/enhanced.css new file mode 100644 index 0000000000000000000000000000000000000000..58563ad5d799bb82ebd89f4ffe1a3bbfc07ac0b7 --- /dev/null +++ b/utils/css/enhanced.css @@ -0,0 +1,832 @@ +/* + * ملف CSS للتأثيرات المتقدمة والتحسينات البصرية + * Enhanced effects and visual improvements CSS file + */ + +/* تأثيرات الخطوط المتدرجة والظلال */ +.gradient-text { + background: linear-gradient(135deg, #1E88E5, #64B5F6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +.shadow-text { + text-shadow: 1px 2px 2px rgba(0, 0, 0, 0.1); +} + +/* تحسين العناوين الرئيسية */ +h1.hero-title { + font-size: 2.5rem; + font-weight: 800; + color: #1E88E5; + line-height: 1.2; + margin-bottom: 1.5rem; + position: relative; + padding-right: 1rem; + text-shadow: 1px 2px 3px rgba(0, 0, 0, 0.1); + background: linear-gradient(135deg, #1E88E5, #64B5F6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +h1.hero-title::before { + content: ''; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 5px; + background: linear-gradient(180deg, #1E88E5, #64B5F6); + border-radius: 5px; +} + +/* تحسين البطاقات مع تأثيرات متقدمة */ +.card-enhanced { + background: white; + border-radius: 15px; + padding: 1.5rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + transition: transform 0.3s ease, box-shadow 0.3s ease; + overflow: hidden; + position: relative; + z-index: 1; +} + +.card-enhanced::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 5px; + background: linear-gradient(90deg, #1E88E5, #64B5F6); + z-index: 2; +} + +.card-enhanced:hover { + transform: translateY(-10px); + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); +} + +/* تحسين الأزرار مع تأثيرات متقدمة */ +.button-enhanced { + padding: 0.8rem 1.5rem; + border-radius: 50px; + font-weight: bold; + transition: all 0.3s ease; + cursor: pointer; + border: none; + position: relative; + overflow: hidden; + z-index: 1; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); +} + +.button-enhanced::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 0; + height: 100%; + background: rgba(255, 255, 255, 0.2); + transition: width 0.3s ease; + z-index: -1; +} + +.button-enhanced:hover::before { + width: 100%; + right: auto; + left: 0; +} + +.button-primary-enhanced { + background: linear-gradient(135deg, #1E88E5, #1565C0); + color: white; +} + +.button-secondary-enhanced { + background: linear-gradient(135deg, #F5F9FF, #E3F2FD); + color: #1565C0; + border: 1px solid rgba(30, 136, 229, 0.3); +} + +.button-success-enhanced { + background: linear-gradient(135deg, #4CAF50, #2E7D32); + color: white; +} + +.button-warning-enhanced { + background: linear-gradient(135deg, #FFC107, #FFA000); + color: white; +} + +.button-danger-enhanced { + background: linear-gradient(135deg, #F44336, #C62828); + color: white; +} + +/* تحسين المؤشرات والإحصائيات */ +.metric-card-enhanced { + background: white; + border-radius: 15px; + padding: 1.5rem; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.05); + text-align: center; + position: relative; + overflow: hidden; + z-index: 1; +} + +.metric-card-enhanced::before { + content: ''; + position: absolute; + right: 0; + top: 0; + width: 100%; + height: 5px; + background: linear-gradient(90deg, var(--metric-color, #1E88E5), var(--metric-color-light, #64B5F6)); + z-index: 2; +} + +.metric-value-enhanced { + font-size: 3rem; + font-weight: 800; + line-height: 1; + margin-bottom: 0.5rem; + background: linear-gradient(135deg, var(--metric-color, #1E88E5), var(--metric-color-light, #64B5F6)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +.metric-label-enhanced { + font-size: 1rem; + color: #666; + font-weight: 500; +} + +/* تحسين عناصر التنقل والتبويب */ +.tabs-enhanced { + display: flex; + margin-bottom: 1.5rem; + position: relative; +} + +.tabs-enhanced::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + width: 100%; + height: 1px; + background-color: #ddd; + z-index: 1; +} + +.tab-item-enhanced { + padding: 1rem 1.5rem; + cursor: pointer; + position: relative; + font-weight: 500; + z-index: 2; + transition: all 0.3s ease; +} + +.tab-item-enhanced::after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + width: 0; + height: 3px; + background: linear-gradient(90deg, #1E88E5, #64B5F6); + transition: width 0.3s ease; + z-index: 3; +} + +.tab-item-enhanced.active { + color: #1E88E5; +} + +.tab-item-enhanced.active::after { + width: 100%; +} + +.tab-item-enhanced:hover::after { + width: 100%; +} + +/* تحسين التنبيهات والإشعارات */ +.alert-enhanced { + padding: 1.2rem; + border-radius: 10px; + margin-bottom: 1.5rem; + position: relative; + display: flex; + align-items: flex-start; + overflow: hidden; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); +} + +.alert-enhanced::before { + content: ''; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 5px; + background: linear-gradient(180deg, var(--alert-color, #1E88E5), var(--alert-color-light, #64B5F6)); + border-radius: 0 5px 5px 0; +} + +.alert-icon-enhanced { + margin-left: 1rem; + font-size: 1.8rem; + color: var(--alert-color, #1E88E5); +} + +.alert-content-enhanced { + flex: 1; +} + +.alert-title-enhanced { + font-weight: bold; + margin-bottom: 0.3rem; + color: var(--alert-color, #1E88E5); +} + +.alert-message-enhanced { + margin: 0; + color: #333; +} + +/* تحسين جداول البيانات */ +.table-enhanced { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin-bottom: 1.5rem; + background-color: white; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); +} + +.table-enhanced th { + background: linear-gradient(135deg, #1E88E5, #1565C0); + color: white; + font-weight: bold; + padding: 1.2rem 1rem; + text-align: right; +} + +.table-enhanced td { + padding: 1rem; + text-align: right; + border-bottom: 1px solid #f0f0f0; +} + +.table-enhanced tr:last-child td { + border-bottom: none; +} + +.table-enhanced tr:nth-child(even) { + background-color: #f9fbfe; +} + +.table-enhanced tr:hover { + background-color: #f0f7ff; +} + +/* تحسين شريط البحث */ +.search-bar-enhanced { + position: relative; + margin-bottom: 1.5rem; +} + +.search-input-enhanced { + width: 100%; + padding: 1rem 3rem; + border: none; + border-radius: 50px; + font-size: 1rem; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); +} + +.search-input-enhanced:focus { + box-shadow: 0 4px 20px rgba(30, 136, 229, 0.2); + outline: none; +} + +.search-icon-enhanced { + position: absolute; + right: 1.2rem; + top: 50%; + transform: translateY(-50%); + color: #1E88E5; + font-size: 1.2rem; +} + +/* تحسين شريط التقدم */ +.progress-container-enhanced { + width: 100%; + height: 15px; + background-color: #f0f0f0; + border-radius: 10px; + overflow: hidden; + margin-bottom: 1rem; + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.05); +} + +.progress-bar-enhanced { + height: 100%; + background: linear-gradient(90deg, var(--progress-color, #1E88E5), var(--progress-color-light, #64B5F6)); + border-radius: 10px; + transition: width 0.5s ease; + position: relative; + overflow: hidden; +} + +.progress-bar-enhanced::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.2) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0.2) 75%, + transparent 75%, + transparent + ); + background-size: 30px 30px; + animation: progressAnimation 2s linear infinite; + border-radius: 10px; +} + +@keyframes progressAnimation { + 0% { + background-position: 0 0; + } + 100% { + background-position: 30px 0; + } +} + +/* تحسين بطاقات المشاريع */ +.project-card-enhanced { + position: relative; + overflow: hidden; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + transition: transform 0.3s ease, box-shadow 0.3s ease; + margin-bottom: 1.5rem; +} + +.project-card-enhanced::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 5px; + height: 100%; + background: linear-gradient(180deg, var(--project-color, #1E88E5), var(--project-color-light, #64B5F6)); +} + +.project-card-enhanced:hover { + transform: translateY(-10px); + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.1); +} + +.project-header-enhanced { + padding: 1.5rem; + border-bottom: 1px solid #f0f0f0; + position: relative; +} + +.project-title-enhanced { + font-weight: bold; + font-size: 1.4rem; + margin-bottom: 0.5rem; + color: #333; +} + +.project-subtitle-enhanced { + font-size: 0.9rem; + color: #666; +} + +.project-content-enhanced { + padding: 1.5rem; +} + +.project-footer-enhanced { + padding: 1rem 1.5rem; + background-color: #f9fbfe; + border-top: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; +} + +/* تحسين قائمة المهام */ +.task-list-enhanced { + margin-bottom: 1.5rem; +} + +.task-item-enhanced { + padding: 1.2rem; + background-color: white; + border-radius: 10px; + margin-bottom: 1rem; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.task-item-enhanced:hover { + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); +} + +.task-checkbox-enhanced { + margin-left: 1rem; + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.task-content-enhanced { + flex: 1; +} + +.task-title-enhanced { + font-weight: 500; + margin-bottom: 0.3rem; + color: #333; + font-size: 1.1rem; +} + +.task-description-enhanced { + font-size: 0.9rem; + color: #666; +} + +.task-meta-enhanced { + display: flex; + align-items: center; + font-size: 0.8rem; + color: #888; + margin-top: 0.8rem; +} + +.task-meta-item-enhanced { + display: flex; + align-items: center; + margin-left: 1.2rem; +} + +.task-meta-icon-enhanced { + margin-left: 0.3rem; + color: var(--task-color, #1E88E5); +} + +/* تحسين الأيقونات والرموز */ +.icon-enhanced { + width: 40px; + height: 40px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background: linear-gradient(135deg, var(--icon-color, #1E88E5), var(--icon-color-light, #64B5F6)); + color: white; + box-shadow: 0 4px 10px rgba(var(--icon-color-rgb, 30, 136, 229), 0.3); + margin-left: 1rem; + flex-shrink: 0; +} + +/* تحسين بطاقات الملف الشخصي */ +.profile-card-enhanced { + background-color: white; + border-radius: 15px; + padding: 1.5rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + margin-bottom: 1.5rem; +} + +.profile-avatar-enhanced { + width: 80px; + height: 80px; + border-radius: 50%; + background: linear-gradient(135deg, #1E88E5, #64B5F6); + color: white; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; + font-weight: bold; + margin-left: 1.5rem; + box-shadow: 0 5px 15px rgba(30, 136, 229, 0.3); + flex-shrink: 0; +} + +.profile-info-enhanced { + flex: 1; +} + +.profile-name-enhanced { + font-size: 1.4rem; + font-weight: bold; + margin-bottom: 0.3rem; + color: #333; +} + +.profile-role-enhanced { + font-size: 0.9rem; + color: #1E88E5; + margin-bottom: 0.8rem; +} + +.profile-stats-enhanced { + display: flex; + margin-top: 1rem; +} + +.profile-stat-enhanced { + margin-left: 1.5rem; + text-align: center; +} + +.profile-stat-value-enhanced { + font-size: 1.2rem; + font-weight: bold; + color: #1E88E5; +} + +.profile-stat-label-enhanced { + font-size: 0.8rem; + color: #666; +} + +/* تحسين القوائم والتعداد */ +.list-enhanced { + list-style-type: none; + padding: 0; + margin: 0 0 1.5rem 0; +} + +.list-item-enhanced { + position: relative; + padding-right: 2rem; + margin-bottom: 1rem; +} + +.list-item-enhanced::before { + content: ''; + position: absolute; + top: 0.5rem; + right: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: linear-gradient(135deg, #1E88E5, #64B5F6); +} + +/* تحسين الشاشة الترحيبية */ +.hero-section { + background: linear-gradient(135deg, #f8f9fa, #f0f7ff); + padding: 3rem 2rem; + border-radius: 15px; + margin-bottom: 2rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + text-align: center; + position: relative; + overflow: hidden; +} + +.hero-section::before { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 5px; + background: linear-gradient(90deg, #1E88E5, #64B5F6); +} + +.hero-title { + font-size: 2.5rem; + font-weight: 800; + margin-bottom: 1rem; + background: linear-gradient(135deg, #1E88E5, #64B5F6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; +} + +.hero-subtitle { + font-size: 1.2rem; + color: #666; + margin-bottom: 2rem; + max-width: 700px; + margin-right: auto; + margin-left: auto; +} + +.hero-actions { + display: flex; + justify-content: center; + gap: 1rem; +} + +/* تحسين أدوات التحليل والرسوم البيانية */ +.chart-container-enhanced { + background-color: white; + border-radius: 15px; + padding: 1.5rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05); + margin-bottom: 2rem; + position: relative; +} + +.chart-header-enhanced { + margin-bottom: 1.5rem; + text-align: center; +} + +.chart-title-enhanced { + font-size: 1.4rem; + font-weight: bold; + color: #333; + margin-bottom: 0.3rem; +} + +.chart-subtitle-enhanced { + font-size: 0.9rem; + color: #666; +} + +/* تحسين الأحداث الزمنية */ +.timeline-enhanced { + margin-bottom: 2rem; + position: relative; +} + +.timeline-enhanced::before { + content: ''; + position: absolute; + top: 0; + right: 24px; + bottom: 0; + width: 2px; + background: linear-gradient(180deg, #1E88E5, #64B5F6); +} + +.timeline-item-enhanced { + position: relative; + padding-right: 50px; + padding-bottom: 1.5rem; +} + +.timeline-point-enhanced { + position: absolute; + top: 0; + right: 20px; + width: 12px; + height: 12px; + border-radius: 50%; + background: linear-gradient(135deg, #1E88E5, #64B5F6); + transform: translateX(50%); + box-shadow: 0 0 0 4px rgba(30, 136, 229, 0.2); + z-index: 2; +} + +.timeline-content-enhanced { + background-color: white; + border-radius: 10px; + padding: 1.2rem; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); +} + +.timeline-date-enhanced { + font-size: 0.8rem; + color: #1E88E5; + margin-bottom: 0.5rem; +} + +.timeline-title-enhanced { + font-weight: 500; + margin-bottom: 0.5rem; + color: #333; +} + +.timeline-description-enhanced { + font-size: 0.9rem; + color: #666; +} + +/* القائمة الشبكية للخدمات أو المزايا */ +.service-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.service-item { + background-color: white; + border-radius: 10px; + padding: 1.5rem; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); + transition: transform 0.3s ease, box-shadow 0.3s ease; + text-align: center; +} + +.service-item:hover { + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} + +.service-icon { + width: 60px; + height: 60px; + background: linear-gradient(135deg, #1E88E5, #64B5F6); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + margin: 0 auto 1rem auto; + color: white; + font-size: 1.5rem; + box-shadow: 0 5px 15px rgba(30, 136, 229, 0.3); +} + +.service-title { + font-weight: bold; + margin-bottom: 0.5rem; + color: #333; +} + +.service-description { + font-size: 0.9rem; + color: #666; +} + +/* استجابة للأجهزة المختلفة */ +@media (max-width: 768px) { + .service-grid { + grid-template-columns: repeat(2, 1fr); + } + + .hero-title { + font-size: 2rem; + } + + .chart-container-enhanced { + padding: 1rem; + } + + .project-card-enhanced { + margin-bottom: 1rem; + } +} + +@media (max-width: 576px) { + .service-grid { + grid-template-columns: 1fr; + } + + .hero-actions { + flex-direction: column; + } + + .profile-card-enhanced { + flex-direction: column; + text-align: center; + } + + .profile-avatar-enhanced { + margin: 0 0 1rem 0; + } + + .profile-stats-enhanced { + justify-content: center; + } +} \ No newline at end of file diff --git a/utils/css/main.css b/utils/css/main.css new file mode 100644 index 0000000000000000000000000000000000000000..63d7631dbd0da8bb1e3b943de07328121c0f3108 --- /dev/null +++ b/utils/css/main.css @@ -0,0 +1,734 @@ +/* + * ملف CSS الرئيسي لنظام واهبي لتحليل العقود والمناقصات + * Main CSS file for WAHBI Tender Analysis System + */ + +/* ================= المتغيرات العامة ================= */ +:root { + /* الألوان الرئيسية */ + --primary-color: #1E88E5; + --primary-light: #64B5F6; + --primary-dark: #1565C0; + --secondary-color: #E3F2FD; + + /* ألوان مخصصة */ + --success-color: #4CAF50; + --success-light: #81C784; + --warning-color: #FFC107; + --warning-light: #FFD54F; + --danger-color: #F44336; + --danger-light: #E57373; + --info-color: #2196F3; + --info-light: #64B5F6; + + /* ألوان محايدة */ + --gray-100: #f8f9fa; + --gray-200: #f0f2f5; + --gray-300: #dee2e6; + --gray-400: #ced4da; + --gray-500: #adb5bd; + --gray-600: #6c757d; + --gray-700: #495057; + --gray-800: #343a40; + --gray-900: #212529; + + /* خصائص الخط */ + --font-family: 'Tajawal', 'Cairo', sans-serif; + --font-size-base: 16px; + --font-size-sm: 0.875rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.5rem; + --font-size-2xl: 2rem; + + /* المسافات */ + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; + --spacing-2xl: 3rem; + + /* الظلال */ + --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05); + --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-md: 0 6px 10px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 15px 20px rgba(0, 0, 0, 0.1); + + /* الحدود المستديرة */ + --border-radius-sm: 0.25rem; + --border-radius: 0.5rem; + --border-radius-lg: 0.75rem; + --border-radius-xl: 1rem; + --border-radius-circle: 50%; +} + +/* ================= أساسيات النمط ================= */ +html, body { + font-family: var(--font-family); + font-size: var(--font-size-base); + color: var(--gray-800); + line-height: 1.5; + background-color: #f8f9fa; + margin: 0; + padding: 0; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + margin-top: 0; + margin-bottom: var(--spacing-md); + color: var(--gray-900); +} + +h1 { + font-size: var(--font-size-2xl); +} + +h2 { + font-size: var(--font-size-xl); +} + +h3 { + font-size: var(--font-size-lg); +} + +p { + margin-top: 0; + margin-bottom: var(--spacing-md); +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color 0.2s; +} + +a:hover { + color: var(--primary-dark); + text-decoration: underline; +} + +img { + max-width: 100%; + height: auto; +} + +/* ================= تخصيص Streamlit ================= */ +/* إخفاء شريط Streamlit الافتراضي */ +.reportview-container .main .block-container { + padding-top: 1rem; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-bottom: 1rem; +} + +.reportview-container { + background-color: #f8f9fa; +} + +/* ضبط تباعد الصفحة */ +.block-container { + padding: var(--spacing-lg) !important; +} + +/* تخصيص الهامش العلوي لتجنب التداخل مع شريط الهيدر */ +.main .block-container { + margin-top: 2rem; +} + +/* تحسين الهوامش ومظهر الأزرار */ +.stButton>button { + background-color: var(--primary-color); + color: white !important; /* ضمان ظهور النص بالأبيض */ + border: none; + border-radius: var(--border-radius); + padding: 0.5rem 1rem; + font-weight: 500; + box-shadow: var(--shadow-sm); + transition: all 0.3s ease; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); /* إضافة ظل للنص لتحسين القراءة */ +} + +.stButton>button:hover { + background-color: var(--primary-dark); + box-shadow: var(--shadow); + transform: translateY(-2px); +} + +/* شريط الشاشة الجانبي */ +.css-1d391kg, .css-12oz5g7 { + background-color: white; + border-right: 1px solid var(--gray-200); +} + +/* مربعات النصوص والإدخال */ +.stTextInput>div>div>input { + border: 1px solid var(--gray-300); + border-radius: var(--border-radius); + padding: 0.5rem; + transition: border-color 0.3s; +} + +.stTextInput>div>div>input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(30, 136, 229, 0.2); +} + +/* القوائم المنسدلة */ +.stSelectbox>div>div>div { + border: 1px solid var(--gray-300); + border-radius: var(--border-radius); +} + +/* مربعات الاختيار */ +.stCheckbox>div>div>label { + display: flex; + align-items: center; +} + +.stCheckbox>div>div>label>div { + margin-right: 0.5rem; +} + +/* ================= مكونات واجهة المستخدم ================= */ +/* البطاقات */ +.card { + background-color: white; + border-radius: var(--border-radius); + padding: var(--spacing-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); + transition: all 0.3s ease; +} + +.card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +/* بطاقات المؤشرات والإحصائيات */ +.metric-card { + background-color: white; + border-radius: var(--border-radius); + padding: var(--spacing-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.metric-value { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--primary-color); + margin-bottom: var(--spacing-xs); +} + +.metric-label { + font-size: var(--font-size-sm); + color: var(--gray-600); +} + +/* أزرار بالألوان المختلفة */ +.button-primary { + background-color: var(--primary-color); + color: white; +} + +.button-secondary { + background-color: white; + color: var(--primary-color); + border: 1px solid var(--primary-color); +} + +.button-success { + background-color: var(--success-color); + color: white; +} + +.button-warning { + background-color: var(--warning-color); + color: white; +} + +.button-danger { + background-color: var(--danger-color); + color: white; +} + +/* الشريط العلوي */ +.header { + background-color: white; + box-shadow: var(--shadow); + padding: var(--spacing-md) var(--spacing-lg); + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-lg); +} + +.header-logo { + display: flex; + align-items: center; +} + +.header-logo img { + height: 40px; + margin-left: var(--spacing-md); +} + +.header-title { + font-size: var(--font-size-lg); + font-weight: 700; + color: var(--primary-color); + margin: 0; +} + +.header-nav { + display: flex; + align-items: center; +} + +.header-nav-item { + margin-left: var(--spacing-md); + font-size: var(--font-size-sm); + color: var(--gray-600); + text-decoration: none; + transition: color 0.2s; +} + +.header-nav-item:hover { + color: var(--primary-color); +} + +/* التنبيهات والإشعارات */ +.alert { + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + display: flex; + align-items: flex-start; +} + +.alert-info { + background-color: var(--secondary-color); + border-right: 4px solid var(--info-color); +} + +.alert-success { + background-color: #E8F5E9; + border-right: 4px solid var(--success-color); +} + +.alert-warning { + background-color: #FFF8E1; + border-right: 4px solid var(--warning-color); +} + +.alert-danger { + background-color: #FFEBEE; + border-right: 4px solid var(--danger-color); +} + +.alert-icon { + margin-left: var(--spacing-md); +} + +.alert-content { + flex: 1; +} + +.alert-title { + font-weight: 700; + margin-bottom: var(--spacing-xs); +} + +.alert-message { + margin: 0; +} + +/* الجداول */ +.table { + width: 100%; + border-collapse: collapse; + margin-bottom: var(--spacing-lg); +} + +.table th { + background-color: var(--primary-color); + color: white; + padding: var(--spacing-md); + text-align: right; +} + +.table td { + padding: var(--spacing-md); + border-bottom: 1px solid var(--gray-200); +} + +.table tr:nth-child(even) { + background-color: var(--gray-100); +} + +.table tr:hover { + background-color: var(--secondary-color); +} + +/* مربعات البحث */ +.search-container { + position: relative; + margin-bottom: var(--spacing-md); +} + +.search-input { + width: 100%; + padding: var(--spacing-md); + padding-right: 2.5rem; + border: 1px solid var(--gray-300); + border-radius: var(--border-radius); + transition: all 0.3s ease; +} + +.search-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(30, 136, 229, 0.2); + outline: none; +} + +.search-icon { + position: absolute; + top: 50%; + right: var(--spacing-md); + transform: translateY(-50%); + color: var(--gray-500); +} + +/* أشرطة التقدم */ +.progress-container { + width: 100%; + height: 10px; + background-color: var(--gray-200); + border-radius: 10px; + overflow: hidden; + margin-bottom: var(--spacing-md); +} + +.progress-bar { + height: 100%; + border-radius: 10px; + transition: width 0.3s; +} + +.progress-primary { + background-color: var(--primary-color); +} + +.progress-success { + background-color: var(--success-color); +} + +.progress-warning { + background-color: var(--warning-color); +} + +.progress-danger { + background-color: var(--danger-color); +} + +/* بطاقات المشروع */ +.project-card { + background-color: white; + border-radius: var(--border-radius); + padding: var(--spacing-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); + position: relative; + transition: all 0.3s ease; +} + +.project-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.project-card::before { + content: ''; + position: absolute; + top: 0; + right: 0; + height: 100%; + width: 4px; + background-color: var(--primary-color); + border-radius: 0 var(--border-radius) var(--border-radius) 0; +} + +.project-title { + font-size: var(--font-size-lg); + font-weight: 700; + margin-bottom: var(--spacing-sm); +} + +.project-description { + color: var(--gray-600); + margin-bottom: var(--spacing-md); +} + +.project-meta { + display: flex; + align-items: center; + margin-bottom: var(--spacing-sm); +} + +.project-meta-label { + font-weight: 500; + margin-left: var(--spacing-sm); + color: var(--gray-700); +} + +.project-meta-value { + color: var(--gray-600); +} + +.project-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: var(--spacing-md); + padding-top: var(--spacing-md); + border-top: 1px solid var(--gray-200); +} + +/* ثيمات خاصة بأنواع المشاريع */ +.project-card.high-priority::before { + background-color: var(--danger-color); +} + +.project-card.medium-priority::before { + background-color: var(--warning-color); +} + +.project-card.low-priority::before { + background-color: var(--success-color); +} + +/* قسم الإجراءات */ +.actions-container { + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +/* علامات التبويب */ +.tabs { + display: flex; + border-bottom: 1px solid var(--gray-300); + margin-bottom: var(--spacing-lg); +} + +.tab-item { + padding: var(--spacing-md); + cursor: pointer; + position: relative; + font-weight: 500; + color: var(--gray-600); + transition: color 0.3s; +} + +.tab-item.active { + color: var(--primary-color); +} + +.tab-item.active::after { + content: ''; + position: absolute; + bottom: -1px; + right: 0; + width: 100%; + height: 2px; + background-color: var(--primary-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* وحدات نظام واهبي */ +.module-card { + background-color: white; + border-radius: var(--border-radius); + padding: var(--spacing-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); + transition: all 0.3s ease; + display: flex; + flex-direction: column; + height: 100%; +} + +.module-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-3px); +} + +.module-icon { + width: 50px; + height: 50px; + background-color: var(--secondary-color); + border-radius: var(--border-radius-circle); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--spacing-md); + color: var(--primary-color); + font-size: 1.5rem; +} + +.module-title { + font-size: var(--font-size-lg); + font-weight: 700; + margin-bottom: var(--spacing-sm); +} + +.module-description { + color: var(--gray-600); + margin-bottom: var(--spacing-md); + flex: 1; +} + +/* عناصر التحليل والخرائط البيانية */ +.chart-container { + background-color: white; + border-radius: var(--border-radius); + padding: var(--spacing-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); +} + +.chart-title { + font-size: var(--font-size-lg); + font-weight: 700; + margin-bottom: var(--spacing-sm); + text-align: center; +} + +.chart-description { + color: var(--gray-600); + margin-bottom: var(--spacing-md); + text-align: center; +} + +/* عناصر تصميم خريطة المشروع */ +.map-container { + background-color: white; + border-radius: var(--border-radius); + padding: var(--spacing-lg); + box-shadow: var(--shadow); + margin-bottom: var(--spacing-lg); + height: 400px; + position: relative; +} + +/* تصميم صفحة التفاصيل */ +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-lg); +} + +.detail-title { + font-size: var(--font-size-xl); + font-weight: 700; + margin: 0; +} + +.detail-actions { + display: flex; + gap: var(--spacing-sm); +} + +.detail-section { + margin-bottom: var(--spacing-xl); +} + +.detail-section-title { + font-size: var(--font-size-lg); + font-weight: 700; + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--gray-200); +} + +/* تنسيق صغحة 404 والأخطاء */ +.error-container { + text-align: center; + padding: var(--spacing-2xl); +} + +.error-code { + font-size: 5rem; + font-weight: 700; + color: var(--primary-color); + margin-bottom: var(--spacing-md); +} + +.error-title { + font-size: var(--font-size-xl); + margin-bottom: var(--spacing-md); +} + +.error-description { + color: var(--gray-600); + max-width: 500px; + margin: 0 auto var(--spacing-lg); +} + +/* ================= تطبيق الاستجابة لمختلف الأجهزة ================= */ +@media screen and (max-width: 992px) { + :root { + --font-size-base: 14px; + } + + .header { + flex-direction: column; + align-items: flex-start; + } + + .header-nav { + margin-top: var(--spacing-md); + } +} + +@media screen and (max-width: 768px) { + .actions-container { + flex-direction: column; + } + + .project-meta { + flex-direction: column; + align-items: flex-start; + } + + .project-footer { + flex-direction: column; + align-items: flex-start; + } +} + +@media screen and (max-width: 576px) { + .detail-header { + flex-direction: column; + align-items: flex-start; + } + + .detail-actions { + margin-top: var(--spacing-md); + } +} \ No newline at end of file diff --git a/utils/css/rtl.css b/utils/css/rtl.css new file mode 100644 index 0000000000000000000000000000000000000000..f56e300f8b1b8c249036cd6ae7ecabe91cd8a667 --- /dev/null +++ b/utils/css/rtl.css @@ -0,0 +1,368 @@ +/* + * ملف CSS للدعم العربي وتنسيق RTL + * RTL support and Arabic language CSS file + */ + +/* تعديل الاتجاه للعناصر */ +body, .main-container, .stApp, .stMarkdown, h1, h2, h3, h4, h5, h6, p, div, +input, textarea, select, button, th, td, li, span, a, label { + direction: rtl; + text-align: right; +} + +/* تنسيقات Streamlit */ +.stTextInput div, .stTextArea div, .stSelectbox div, .stMultiSelect div, +.stNumberInput div, .stDateInput div, .stSlider div { + direction: rtl; + text-align: right; +} + +.stSidebar div { + direction: rtl; + text-align: right; +} + +/* تعديل الشريط الجانبي لدعم RTL */ +.stSidebar .sidebar-content, +div[data-testid="stSidebar"] { + direction: rtl !important; + text-align: right !important; + left: auto !important; + right: 0 !important; + border-left: 1px solid #e6e6e6 !important; + border-right: none !important; +} + +/* تغيير موضع الشريط الجانبي إلى اليمين */ +.stApp .main-container { + flex-direction: row-reverse !important; +} + +/* تنسيقات إضافية لمكتبة streamlit_option_menu */ +.nav-link-horizontal, .nav-link { + text-align: right !important; + direction: rtl !important; +} + +.stOptionMenu div:has(> ul) { + direction: rtl !important; +} + +.stOptionMenu ul { + direction: rtl !important; + padding-right: 0 !important; +} + +/* تحسين وضوح النص في القائمة */ +.stOptionMenu span, .stOptionMenu a, .stOptionMenu div { + color: #2C3E50 !important; + font-weight: normal !important; +} + +.stOptionMenu [data-baseweb="tab"], .stOptionMenu [data-baseweb="tab-list"] { + direction: rtl !important; +} + +/* إصلاح مشكلة النص الأبيض على خلفية بيضاء */ +.stOptionMenu { + color: var(--gray-800) !important; +} + +.sidebar-menu { + color: var(--gray-800) !important; +} + +.stOptionMenu span, .stOptionMenu label, .stOptionMenu div { + color: var(--gray-800) !important; + text-shadow: none !important; +} + +/* إضافة لون خلفية للقوائم المنسدلة لتحسين التباين */ +.sidebar-menu-item { + background-color: rgba(240, 242, 245, 0.5) !important; + margin-bottom: 4px !important; + border-radius: 4px !important; + transition: all 0.2s ease-in-out !important; +} + +.sidebar-menu-item:hover { + background-color: rgba(222, 226, 230, 0.8) !important; +} + +.sidebar-menu-item.active { + background-color: var(--primary-color) !important; + color: white !important; +} + +.sidebar-menu-item.active span, .sidebar-menu-item.active label, .sidebar-menu-item.active div { + color: white !important; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important; +} + +/* تعديل المربعات والبطاقات */ +.card, .metric-card, .alert, .callout, .project-card, .list-container, .tab-content { + direction: rtl; + text-align: right; +} + +/* تعديل أيقونات الكتل */ +.block-icon { + direction: rtl; +} + +/* تعديل الهوامش للدعم العربي */ +h1, h2, h3, h4, h5, h6 { + padding-right: 10px; + padding-left: 0; + margin-right: 0; + margin-left: 0; + border-right: 4px solid #1E88E5; + border-left: none; +} + +.button-icon, .fa, .fas, .far, .fab { + margin-left: 0.5rem; + margin-right: 0; +} + +/* تعديل عناصر القوائم */ +ul, ol { + padding-right: 20px; + padding-left: 0; +} + +li { + direction: rtl; + text-align: right; +} + +/* تعديل المسافات البادئة */ +.indented { + margin-right: 1.5rem; + margin-left: 0; +} + +/* تعديل مؤشرات الأرقام والنقاط للقوائم */ +ul { + list-style-position: inside; +} + +/* تعديل ترتيب الأعمدة في الصفوف المرنة */ +.row-flex { + flex-direction: row-reverse; +} + +/* تنسيق الاتجاهات للنص في المخططات البيانية */ +.plotly-chart text { + direction: rtl; +} + +/* تحسين الجداول لدعم RTL */ +table { + direction: rtl; + text-align: right; +} + +/* تعديل علامات التبويب */ +.tab-item { + margin-left: 0; + margin-right: 0; +} + +/* تعديل عناصر التنبيهات والإشعارات */ +.alert-icon, .callout-icon { + margin-left: 0.8rem; + margin-right: 0; +} + +/* تعديل شريط البحث */ +.search-icon { + left: auto; + right: 1rem; +} + +.search-input { + padding-left: 1rem; + padding-right: 3rem; +} + +/* تنسيق الحوارات والرسائل المنبثقة */ +.dialog { + direction: rtl; + text-align: right; +} + +.dialog-header { + flex-direction: row-reverse; +} + +/* تعديل شكل المحادثات ورسائل الدردشة */ +.chat-message-incoming { + align-self: flex-start; + border-bottom-left-radius: 0; + border-bottom-right-radius: 10px; +} + +.chat-message-outgoing { + align-self: flex-end; + border-bottom-right-radius: 0; + border-bottom-left-radius: 10px; +} + +.chat-avatar { + margin-right: 0; + margin-left: 0.8rem; +} + +/* تعديل أشكال نماذج الإدخال */ +form { + direction: rtl; + text-align: right; +} + +/* تعديل أزرار الإرسال والإلغاء */ +.form-buttons { + flex-direction: row-reverse; +} + +/* تعديل مؤشرات الحالة */ +.status-indicator { + margin-right: 0; + margin-left: 0.5rem; +} + +/* تعديل الشريط العلوي */ +.topbar { + flex-direction: row-reverse; +} + +/* تعديل شريط المعلومات */ +.info-bar { + flex-direction: row-reverse; +} + +/* تنسيق الأرقام والتواريخ */ +.number, .date { + direction: ltr; + display: inline-block; +} + +/* تعديل شريط التنقل */ +.breadcrumbs { + direction: rtl; + text-align: right; +} + +.breadcrumbs-separator { + transform: rotate(180deg); +} + +/* تعديل أشرطة التقدم والعدادات */ +.progress-container { + direction: rtl; +} + +/* دعم التمرير في الاتجاه العربي */ +.overflow-auto { + direction: rtl; +} + +/* تعديل الهوامش للعناصر المتتالية */ +.consecutive-items > * + * { + margin-right: 1rem; + margin-left: 0; +} + +/* تعديل المذكرات والتعليقات الجانبية */ +.note, .aside { + border-right: 4px solid #ccc; + border-left: none; + padding-right: 1rem; + padding-left: 0; +} + +/* تعديل شكل المشاريع المرتبة */ +.project-list-item { + padding-right: 0; + padding-left: 1rem; +} + +/* تخصيص عناصر Streamlit */ +.stButton, .stCheckbox, .stRadio { + direction: rtl; + text-align: right; +} + +.stExpander { + direction: rtl; + text-align: right; +} + +.stTabs { + direction: rtl; +} + +.stDataFrame { + direction: rtl; +} + +.stFileUploader { + direction: rtl; +} + +/* تعديل خطوط سير العمل والمخططات الزمنية */ +.timeline { + direction: rtl; +} + +.timeline-item { + padding-right: 2rem; + padding-left: 0; + border-right: 2px solid #ccc; + border-left: none; +} + +.timeline-item::before { + right: -8px; + left: auto; +} + +/* تعديل مربعات الاختيار ومفاتيح التبديل */ +.checkbox-label, .switch-label { + padding-right: 2rem; + padding-left: 0; +} + +.checkbox-custom, .switch-custom { + right: 0; + left: auto; +} + +/* تعديل القوائم المنسدلة */ +.dropdown-content { + text-align: right; +} + +/* تعديل الرسائل التنبيهية */ +.toast { + right: 1rem; + left: auto; +} + +/* تعديل أشرطة التقدم */ +.stProgress { + direction: rtl; +} + +.stProgress > div > div { + direction: rtl; +} + +/* تعديل الاستيراد */ +@media (max-width: 768px) { + /* تعديلات للأجهزة الصغيرة */ + .sidebar-menu-item { + padding-right: 0.5rem; + padding-left: 0.5rem; + } +} \ No newline at end of file diff --git a/utils/dwg_handler.py b/utils/dwg_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..14bfb9beb005ce0e703f5315294421dc1db79157 --- /dev/null +++ b/utils/dwg_handler.py @@ -0,0 +1,223 @@ +""" +معالج ملفات DWG +""" + +import os +import re +import subprocess +import shutil +import tempfile +import traceback +import pandas as pd +from pathlib import Path +import config +from utils.helpers import create_directory_if_not_exists + + +class DWGHandler: + """معالج ملفات DWG (AutoCAD)""" + + def __init__(self, converter_path=None): + """ + تهيئة معالج ملفات DWG + + المعلمات: + converter_path: مسار برنامج تحويل DWG (اختياري) + """ + # محاولة تحديد مسار برنامج تحويل DWG + self.converter_path = converter_path + + if not self.converter_path: + # البحث عن المسار في إعدادات النظام + if hasattr(config, 'DWG_CONVERTER_PATH') and config.DWG_CONVERTER_PATH: + self.converter_path = config.DWG_CONVERTER_PATH + else: + # محاولة البحث عن البرامج المعروفة + possible_paths = [ + r"C:\Program Files\Autodesk\AutoCAD 2022\accoreconsole.exe", + r"C:\Program Files\Autodesk\AutoCAD 2021\accoreconsole.exe", + r"C:\Program Files\Autodesk\AutoCAD 2020\accoreconsole.exe", + r"C:\Program Files\ODA\ODAFileConverter\ODAFileConverter.exe" + ] + + for path in possible_paths: + if os.path.exists(path): + self.converter_path = path + break + + def convert_to_pdf(self, dwg_path, output_path=None): + """ + تحويل ملف DWG إلى PDF + + المعلمات: + dwg_path: مسار ملف DWG + output_path: مسار ملف الإخراج (اختياري) + + الإرجاع: + مسار ملف PDF الناتج + """ + try: + # التحقق من وجود الملف + if not os.path.exists(dwg_path): + raise FileNotFoundError(f"ملف DWG غير موجود: {dwg_path}") + + # التحقق من وجود برنامج التحويل + if not self.converter_path or not os.path.exists(self.converter_path): + raise FileNotFoundError("لم يتم العثور على برنامج تحويل DWG") + + # تحديد مسار الإخراج + if not output_path: + output_path = os.path.splitext(dwg_path)[0] + ".pdf" + + # إنشاء مجلد الإخراج إذا لم يكن موجودًا + output_dir = os.path.dirname(output_path) + create_directory_if_not_exists(output_dir) + + # تنفيذ عملية التحويل باستخدام برنامج التحويل المناسب + if "accoreconsole.exe" in self.converter_path.lower(): + # استخدام AutoCAD Core Console لتحويل الملف + script_content = f""" + /i "{dwg_path}" + /e + /o "{output_path}" + """ + script_path = tempfile.mktemp(suffix=".scr") + with open(script_path, "w") as f: + f.write(script_content) + + cmd = [self.converter_path, "/s", script_path] + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + os.unlink(script_path) # حذف ملف النص البرمجي المؤقت + + if result.returncode != 0: + raise RuntimeError(f"فشل في تحويل ملف DWG: {result.stderr}") + + elif "odafileconverter.exe" in self.converter_path.lower(): + # استخدام ODA File Converter لتحويل الملف + input_dir = os.path.dirname(dwg_path) + output_dir = os.path.dirname(output_path) + input_filename = os.path.basename(dwg_path) + output_format = "PDF" + + cmd = [ + self.converter_path, + input_dir, + output_dir, + "DWG", + output_format, + "1", + "1", + input_filename + ] + + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + if result.returncode != 0: + raise RuntimeError(f"فشل في تحويل ملف DWG: {result.stderr}") + + else: + raise NotImplementedError(f"برنامج التحويل غير مدعوم: {self.converter_path}") + + # التحقق من وجود ملف الإخراج + if not os.path.exists(output_path): + raise FileNotFoundError(f"لم يتم إنشاء ملف PDF: {output_path}") + + return output_path + + except Exception as e: + error_msg = f"خطأ في تحويل ملف DWG إلى PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + def extract_quantities(self, dwg_path): + """ + استخراج الكميات من ملف DWG + + المعلمات: + dwg_path: مسار ملف DWG + + الإرجاع: + DataFrame يحتوي على الكميات المستخرجة + """ + try: + # تحويل ملف DWG إلى PDF أولاً + pdf_path = self.convert_to_pdf(dwg_path) + + # استخدام معالج ملفات PDF لاستخراج الكميات + from utils.pdf_handler import extract_quantities_from_pdf + return extract_quantities_from_pdf(pdf_path) + + except Exception as e: + error_msg = f"خطأ في استخراج الكميات من ملف DWG: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + def extract_text(self, dwg_path): + """ + استخراج النص من ملف DWG + + المعلمات: + dwg_path: مسار ملف DWG + + الإرجاع: + النص المستخرج من ملف DWG + """ + try: + # تحويل ملف DWG إلى PDF أولاً + pdf_path = self.convert_to_pdf(dwg_path) + + # استخدام معالج ملفات PDF لاستخراج النص + from utils.pdf_handler import extract_text_from_pdf + return extract_text_from_pdf(pdf_path) + + except Exception as e: + error_msg = f"خطأ في استخراج النص من ملف DWG: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + def get_dwg_info(self, dwg_path): + """ + الحصول على معلومات ملف DWG + + المعلمات: + dwg_path: مسار ملف DWG + + الإرجاع: + قاموس يحتوي على معلومات الملف + """ + try: + # التحقق من وجود الملف + if not os.path.exists(dwg_path): + raise FileNotFoundError(f"ملف DWG غير موجود: {dwg_path}") + + # الحصول على معلومات الملف الأساسية + file_info = { + 'filename': os.path.basename(dwg_path), + 'path': dwg_path, + 'size': os.path.getsize(dwg_path), + 'modified_date': os.path.getmtime(dwg_path) + } + + # محاولة استخراج معلومات إضافية من الملف + # ملاحظة: هذا يتطلب مكتبات إضافية أو استخدام برامج خارجية + + return file_info + + except Exception as e: + error_msg = f"خطأ في الحصول على معلومات ملف DWG: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + def is_converter_available(self): + """ + التحقق من توفر برنامج تحويل DWG + + الإرجاع: + True إذا كان برنامج التحويل متوفرًا، False خلاف ذلك + """ + return self.converter_path is not None and os.path.exists(self.converter_path) \ No newline at end of file diff --git a/utils/excel_handler.py b/utils/excel_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..df039ce29adad8e5dccefac09dc39e99858e0e50 --- /dev/null +++ b/utils/excel_handler.py @@ -0,0 +1,486 @@ +""" +معالج ملفات Excel +""" + +import pandas as pd +import os +import numpy as np +import xlsxwriter +from datetime import datetime +import traceback +import config +from utils.helpers import create_directory_if_not_exists, get_file_extension, format_number + + +def read_excel_file(file_path, sheet_name=0, header=0, skip_rows=None): + """ + قراءة ملف Excel + + المعلمات: + file_path: مسار ملف Excel + sheet_name: اسم أو رقم الصفحة (افتراضي: 0) + header: رقم الصف الذي يحتوي على العناوين (افتراضي: 0) + skip_rows: قائمة بأرقام الصفوف للتخطي (افتراضي: None) + + الإرجاع: + DataFrame من البيانات المقروءة + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # التحقق من امتداد الملف + ext = get_file_extension(file_path) + if ext not in ['.xlsx', '.xls', '.xlsm']: + raise ValueError(f"نوع الملف غير مدعوم: {ext}. يجب أن يكون الملف بامتداد .xlsx أو .xls أو .xlsm") + + # قراءة الملف + df = pd.read_excel( + file_path, + sheet_name=sheet_name, + header=header, + skiprows=skip_rows + ) + + return df + + except Exception as e: + error_msg = f"خطأ في قراءة ملف Excel: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def write_excel_file(df, file_path, sheet_name="Sheet1", index=False, freeze_panes=None, column_widths=None, formats=None): + """ + كتابة DataFrame إلى ملف Excel + + المعلمات: + df: DataFrame المراد كتابته + file_path: مسار ملف Excel + sheet_name: اسم الصفحة (افتراضي: Sheet1) + index: ما إذا كان سيتم تضمين الفهرس (افتراضي: False) + freeze_panes: صف وعمود لتجميد الألواح (افتراضي: None) + column_widths: قاموس لعرض الأعمدة {column_name: width} + formats: قاموس لتنسيقات الأعمدة {column_name: format_function} + + الإرجاع: + True في حالة النجاح + """ + try: + # التأكد من وجود المجلد + create_directory_if_not_exists(os.path.dirname(file_path)) + + # تحديد المكتب والورقة + writer = pd.ExcelWriter(file_path, engine='xlsxwriter') + df.to_excel(writer, sheet_name=sheet_name, index=index) + + # الحصول على مرجع لورقة العمل + workbook = writer.book + worksheet = writer.sheets[sheet_name] + + # إنشاء تنسيقات مخصصة + header_format = workbook.add_format({ + 'bold': True, + 'bg_color': '#CCCCCC', + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'text_wrap': True + }) + + number_format = workbook.add_format({ + 'num_format': '#,##0.00', + 'align': 'right' + }) + + currency_format = workbook.add_format({ + 'num_format': '_-* #,##0.00 [$ريال]_-;-* #,##0.00 [$ريال]_-;_-* "-" [$ريال]_-;_-@_-', + 'align': 'right' + }) + + date_format = workbook.add_format({ + 'num_format': 'yyyy-mm-dd', + 'align': 'center' + }) + + text_format = workbook.add_format({ + 'align': 'right', + 'text_wrap': True + }) + + # تطبيق التنسيقات على العناوين + for col_num, value in enumerate(df.columns.values): + worksheet.write(0, col_num + (1 if index else 0), value, header_format) + + # تعيين حجم الأعمدة + if column_widths: + for col_name, width in column_widths.items(): + if col_name in df.columns: + col_idx = df.columns.get_loc(col_name) + (1 if index else 0) + worksheet.set_column(col_idx, col_idx, width) + else: + # ضبط عرض الأعمدة تلقائيًا + for col_num, col_name in enumerate(df.columns): + max_len = df[col_name].astype(str).map(len).max() + col_len = max(max_len, len(str(col_name))) + 2 + worksheet.set_column(col_num + (1 if index else 0), col_num + (1 if index else 0), col_len) + + # تطبيق التنسيقات حسب نوع البيانات + for row_num in range(len(df)): + for col_num, col_name in enumerate(df.columns): + cell_value = df.iloc[row_num, col_num] + cell_format = text_format + + # تحديد التنسيق المناسب بناءً على نوع البيانات + if pd.api.types.is_numeric_dtype(df[col_name].dtype): + if any(curr in col_name.lower() for curr in ['سعر', 'تكلفة', 'قيمة', 'مبلغ', 'ريال']): + cell_format = currency_format + else: + cell_format = number_format + elif pd.api.types.is_datetime64_dtype(df[col_name].dtype): + cell_format = date_format + + # استخدام تنسيق مخصص إذا تم توفيره + if formats and col_name in formats: + custom_format = formats[col_name] + if callable(custom_format): + # إذا كان دالة، استدعاها لتطبيق التنسيق + cell_value = custom_format(cell_value) + else: + # إذا كان تنسيق، استخدمه + cell_format = custom_format + + worksheet.write(row_num + 1, col_num + (1 if index else 0), cell_value, cell_format) + + # تجميد الألواح إذا تم تحديده + if freeze_panes: + worksheet.freeze_panes(*freeze_panes) + + # حفظ الملف + writer.close() + + return True + + except Exception as e: + error_msg = f"خطأ في كتابة ملف Excel: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def export_to_excel(data, file_path, sheet_name="Sheet1", customize_func=None): + """ + تصدير البيانات إلى ملف Excel مع خيارات تخصيص + + المعلمات: + data: DataFrame أو قاموس من DataFrames للتصدير + file_path: مسار ملف Excel + sheet_name: اسم الصفحة (افتراضي: Sheet1) + customize_func: دالة لتخصيص المصنف قبل الحفظ (افتراضي: None) + + الإرجاع: + True في حالة النجاح + """ + try: + # التأكد من وجود المجلد + create_directory_if_not_exists(os.path.dirname(file_path)) + + # إنشاء كائن الكاتب + writer = pd.ExcelWriter(file_path, engine='xlsxwriter') + + # تصدير البيانات + if isinstance(data, pd.DataFrame): + # إذا كان DataFrame واحد + data.to_excel(writer, sheet_name=sheet_name, index=False) + elif isinstance(data, dict): + # إذا كان قاموس من DataFrames + for sheet, df in data.items(): + if isinstance(df, pd.DataFrame): + df.to_excel(writer, sheet_name=sheet, index=False) + else: + raise ValueError("البيانات يجب أن تكون DataFrame أو قاموس من DataFrames") + + # تطبيق التخصيص إذا تم توفيره + if customize_func and callable(customize_func): + customize_func(writer) + + # حفظ الملف + writer.close() + + return True + + except Exception as e: + error_msg = f"خطأ في تصدير البيانات إلى Excel: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def read_sheets_from_excel(file_path): + """ + قراءة جميع صفحات ملف Excel + + المعلمات: + file_path: مسار ملف Excel + + الإرجاع: + قاموس من DataFrames بأسماء الصفحات كمفاتيح + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # التحقق من امتداد الملف + ext = get_file_extension(file_path) + if ext not in ['.xlsx', '.xls', '.xlsm']: + raise ValueError(f"نوع الملف غير مدعوم: {ext}. يجب أن يكون الملف بامتداد .xlsx أو .xls أو .xlsm") + + # قراءة جميع الصفحات من الملف + excel_file = pd.ExcelFile(file_path) + sheets = {} + + for sheet_name in excel_file.sheet_names: + sheets[sheet_name] = pd.read_excel(excel_file, sheet_name=sheet_name) + + return sheets + + except Exception as e: + error_msg = f"خطأ في قراءة صفحات ملف Excel: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def create_excel_report(data_dict, file_path, formats=None, column_widths=None, title=None, subtitle=None): + """ + إنشاء تقرير Excel متقدم + + المعلمات: + data_dict: قاموس من DataFrames للتصدير {sheet_name: DataFrame} + file_path: مسار ملف Excel + formats: قاموس للتنسيقات {sheet_name: {column_name: format}} + column_widths: قاموس لعرض الأعمدة {sheet_name: {column_name: width}} + title: عنوان التقرير + subtitle: العنوان الفرعي للتقرير + + الإرجاع: + True في حالة النجاح + """ + try: + # التأكد من وجود المجلد + create_directory_if_not_exists(os.path.dirname(file_path)) + + # إنشاء كائن الكاتب + writer = pd.ExcelWriter(file_path, engine='xlsxwriter') + workbook = writer.book + + # إنشاء التنسيقات العامة + header_format = workbook.add_format({ + 'bold': True, + 'bg_color': '#CCCCCC', + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'text_wrap': True + }) + + title_format = workbook.add_format({ + 'bold': True, + 'font_size': 16, + 'align': 'center', + 'valign': 'vcenter', + 'bg_color': '#E0E0E0', + 'border': 2 + }) + + subtitle_format = workbook.add_format({ + 'font_size': 12, + 'align': 'center', + 'valign': 'vcenter', + 'bg_color': '#E0E0E0', + 'border': 1 + }) + + date_format = workbook.add_format({ + 'num_format': 'yyyy-mm-dd', + 'align': 'center' + }) + + number_format = workbook.add_format({ + 'num_format': '#,##0.00', + 'align': 'right' + }) + + currency_format = workbook.add_format({ + 'num_format': '_-* #,##0.00 [$ريال]_-;-* #,##0.00 [$ريال]_-;_-* "-" [$ريال]_-;_-@_-', + 'align': 'right' + }) + + percent_format = workbook.add_format({ + 'num_format': '0.00%', + 'align': 'right' + }) + + text_format = workbook.add_format({ + 'align': 'right', + 'text_wrap': True + }) + + # تصدير البيانات + current_row = 0 + + # إضافة العنوان والعنوان الفرعي إذا تم توفيرهما + if title or subtitle: + for sheet_name in data_dict.keys(): + worksheet = workbook.add_worksheet(sheet_name) + current_row = 0 + + if title: + worksheet.merge_range('A1:J1', title, title_format) + current_row += 1 + + if subtitle: + worksheet.merge_range(f'A{current_row + 1}:J{current_row + 1}', subtitle, subtitle_format) + current_row += 1 + + # إضافة فاصل + current_row += 1 + + # كتابة البيانات + df = data_dict[sheet_name] + df.to_excel(writer, sheet_name=sheet_name, startrow=current_row, index=False) + + # تنسيق العناوين + for col_num, value in enumerate(df.columns.values): + worksheet.write(current_row, col_num, value, header_format) + + # تطبيق التنسيقات المخصصة + if formats and sheet_name in formats: + sheet_formats = formats[sheet_name] + for col_name, fmt in sheet_formats.items(): + if col_name in df.columns: + col_idx = df.columns.get_loc(col_name) + for row_num in range(len(df)): + cell_value = df.iloc[row_num, col_idx] + worksheet.write(row_num + current_row + 1, col_idx, cell_value, fmt) + + # تعيين عرض الأعمدة + if column_widths and sheet_name in column_widths: + sheet_widths = column_widths[sheet_name] + for col_name, width in sheet_widths.items(): + if col_name in df.columns: + col_idx = df.columns.get_loc(col_name) + worksheet.set_column(col_idx, col_idx, width) + else: + # ضبط عرض الأعمدة تلقائيًا + for col_num, col_name in enumerate(df.columns): + max_len = df[col_name].astype(str).map(len).max() + col_len = max(max_len, len(str(col_name))) + 2 + worksheet.set_column(col_num, col_num, col_len) + else: + # إذا لم يتم توفير عنوان أو عنوان فرعي، استخدم الطريقة العادية + for sheet_name, df in data_dict.items(): + df.to_excel(writer, sheet_name=sheet_name, index=False) + worksheet = writer.sheets[sheet_name] + + # تنسيق العناوين + for col_num, value in enumerate(df.columns.values): + worksheet.write(0, col_num, value, header_format) + + # تطبيق التنسيقات المخصصة + if formats and sheet_name in formats: + sheet_formats = formats[sheet_name] + for col_name, fmt in sheet_formats.items(): + if col_name in df.columns: + col_idx = df.columns.get_loc(col_name) + for row_num in range(len(df)): + cell_value = df.iloc[row_num, col_idx] + worksheet.write(row_num + 1, col_idx, cell_value, fmt) + + # تعيين عرض الأعمدة + if column_widths and sheet_name in column_widths: + sheet_widths = column_widths[sheet_name] + for col_name, width in sheet_widths.items(): + if col_name in df.columns: + col_idx = df.columns.get_loc(col_name) + worksheet.set_column(col_idx, col_idx, width) + else: + # ضبط عرض الأعمدة تلقائيًا + for col_num, col_name in enumerate(df.columns): + max_len = df[col_name].astype(str).map(len).max() + col_len = max(max_len, len(str(col_name))) + 2 + worksheet.set_column(col_num, col_num, col_len) + + # حفظ الملف + writer.close() + + return True + + except Exception as e: + error_msg = f"خطأ في إنشاء تقرير Excel: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_data_from_excel(file_path, columns_mapping=None, sheet_name=0, header_row=0, data_start_row=1): + """ + استخراج بيانات منظمة من ملف Excel + + المعلمات: + file_path: مسار ملف Excel + columns_mapping: قاموس لتخطيط الأعمدة {اسم_العمود_الجديد: اسم_العمود_الأصلي} + sheet_name: اسم أو رقم الصفحة (افتراضي: 0) + header_row: رقم صف العناوين (افتراضي: 0) + data_start_row: رقم صف بداية البيانات (افتراضي: 1) + + الإرجاع: + DataFrame من البيانات المستخرجة + """ + try: + # قراءة الملف + df = pd.read_excel( + file_path, + sheet_name=sheet_name, + header=header_row, + skiprows=range(1, data_start_row) if data_start_row > 1 else None + ) + + # تنظيف العناوين (إزالة المسافات الزائدة) + df.columns = df.columns.str.strip() + + # إعادة تسمية الأعمدة إذا تم توفير تخطيط + if columns_mapping: + # التحقق من وجود جميع الأعمدة المطلوبة + missing_columns = [col for col in columns_mapping.values() if col not in df.columns] + if missing_columns: + raise ValueError(f"الأعمدة التالية غير موجودة في الملف: {', '.join(missing_columns)}") + + # إعادة تسمية الأعمدة + df = df.rename(columns={v: k for k, v in columns_mapping.items()}) + + # اختيار الأعمدة المطلوبة فقط + df = df[list(columns_mapping.keys())] + + # تنظيف البيانات + for col in df.columns: + # تحويل الأعمدة النصية + if df[col].dtype == 'object': + df[col] = df[col].astype(str).str.strip() + + # محاولة تحويل الأعمدة الرقمية + try: + df[col] = pd.to_numeric(df[col], errors='ignore') + except: + pass + + return df + + except Exception as e: + error_msg = f"خطأ في استخراج البيانات من ملف Excel: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) \ No newline at end of file diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..9a428a39d73ee82dcade5bab50f2e1319e7b27a4 --- /dev/null +++ b/utils/helpers.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة المساعدة المركزية للنظام +تحتوي على دوال مساعدة مشتركة تستخدم في جميع أنحاء التطبيق +""" + +import os +import sys +import streamlit as st +import pandas as pd +import numpy as np +import json +import re +import time +import tempfile +from datetime import datetime, timedelta +import random +import secrets +import shutil +import base64 +import logging +from pathlib import Path + +# تكوين التسجيلات +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("wahbi-ai") + + +def create_directory_if_not_exists(directory_path): + """إنشاء مسار إذا لم يكن موجوداً""" + try: + if not os.path.exists(directory_path): + os.makedirs(directory_path) + logger.info(f"تم إنشاء المجلد: {directory_path}") + return True + except Exception as e: + logger.error(f"خطأ في إنشاء المجلد {directory_path}: {e}") + return False + + +def get_data_folder(): + """الحصول على مسار مجلد البيانات الرئيسي""" + # مسار بيانات النظام الرئيسي + data_folder = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data") + create_directory_if_not_exists(data_folder) + return data_folder + + +def load_config(): + """تحميل إعدادات التكوين من ملف config.json""" + config_path = os.path.join(get_data_folder(), "config.json") + + # إذا لم يكن ملف التكوين موجوداً، قم بإنشاء ملف افتراضي + if not os.path.exists(config_path): + default_config = { + "system": { + "version": "1.0.0", + "release_date": "2025-03-30", + "company_name": "شركة شبه الجزيرة للمقاولات", + "company_logo": "", + "language": "ar", + "theme": "light", + "debug_mode": False + }, + "ai_models": { + "default_model": "claude-3-7-sonnet-20250219", + "openai_model": "gpt-4o", + "huggingface_model": "mistralai/Mistral-7B-Instruct-v0.2" + }, + "notifications": { + "enable_email": False, + "enable_browser": True, + "check_interval": 60 + } + } + + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(default_config, f, ensure_ascii=False, indent=2) + + return default_config + + # تحميل ملف التكوين الموجود + try: + with open(config_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"خطأ في تحميل ملف التكوين: {e}") + return {} + + +def save_config(config): + """حفظ إعدادات التكوين إلى ملف config.json""" + config_path = os.path.join(get_data_folder(), "config.json") + + try: + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + return True + except Exception as e: + logger.error(f"خطأ في حفظ ملف التكوين: {e}") + return False + + +def format_time(timestamp=None, format_str="%Y-%m-%d %H:%M:%S"): + """تنسيق الوقت إلى صيغة معينة""" + if timestamp is None: + timestamp = datetime.now() + elif isinstance(timestamp, (int, float)): + timestamp = datetime.fromtimestamp(timestamp) + + return timestamp.strftime(format_str) + + +def get_user_info(): + """الحصول على معلومات المستخدم الحالي""" + # في التطبيق الفعلي، يمكن استرداد معلومات المستخدم من قاعدة البيانات أو من حالة الجلسة + if "user_info" in st.session_state: + return st.session_state.user_info + + # معلومات افتراضية للتطوير + return { + "id": 1, + "username": "admin", + "full_name": "مدير النظام", + "email": "admin@example.com", + "role": "مدير", + "department": "الإدارة", + "last_login": format_time() + } + + +def get_current_project(): + """الحصول على معلومات المشروع الحالي""" + if "current_project" in st.session_state: + return st.session_state.current_project + + # في حالة عدم وجود مشروع محدد + return None + + +def load_icons(): + """تحميل الأيقونات المستخدمة في النظام""" + icons_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "icons") + icons = {} + + # التأكد من وجود مجلد الأيقونات + if not os.path.exists(icons_path): + create_directory_if_not_exists(icons_path) + return icons + + # تحميل جميع الأيقونات + for icon_file in os.listdir(icons_path): + if icon_file.endswith(('.png', '.svg', '.jpg')): + icon_name = os.path.splitext(icon_file)[0] + icon_path = os.path.join(icons_path, icon_file) + + try: + with open(icon_path, "rb") as f: + icons[icon_name] = base64.b64encode(f.read()).decode() + except Exception as e: + logger.error(f"خطأ في تحميل الأيقونة {icon_name}: {e}") + + return icons + + +def get_random_id(length=8): + """إنشاء معرف عشوائي بطول محدد""" + return secrets.token_hex(length // 2) + + +def compress_text(text, max_length=10000): + """اختصار النص إلى حد أقصى محدد مع الحفاظ على المعنى""" + if not text or len(text) <= max_length: + return text + + # تقسيم النص إلى جمل + sentences = re.split(r'(?<=[.!?])\s+', text) + + # حساب متوسط طول الجملة + avg_sentence_length = len(text) / len(sentences) + + # حساب عدد الجمل التي يمكن تضمينها + num_sentences_to_keep = int(max_length / avg_sentence_length) + + # الاحتفاظ بالجمل الأولى والأخيرة للحفاظ على السياق + keep_first = num_sentences_to_keep // 2 + keep_last = num_sentences_to_keep - keep_first + + # دمج الجمل المختارة + compressed_text = ' '.join(sentences[:keep_first] + sentences[-keep_last:]) + + # إضافة إشارة إلى أن النص تم اختصاره + if len(compressed_text) < len(text): + compressed_text += " [...المزيد من النص تم اختصاره...]" + + return compressed_text + + +def str_to_bool(text): + """تحويل النص إلى قيمة منطقية""" + return text.lower() in ('yes', 'true', 'y', 't', '1', 'نعم', 'صحيح') + + +def handle_arabic_text(text): + """معالجة النص العربي للعرض بشكل صحيح""" + if not text: + return "" + + # إضافة علامة RTL لضمان عرض النص العربي بشكل صحيح + return f"
{text}
" + + +def render_credits(): + """عرض معلومات النظام وحقوق الملكية""" + st.markdown("---") + + config = load_config() + system_info = config.get("system", {}) + + col1, col2, col3 = st.columns([1, 2, 1]) + + with col2: + st.markdown( + f""" +
+

{system_info.get('company_name', 'شركة شبه الجزيرة للمقاولات')}

+

الإصدار: {system_info.get('version', '1.0.0')}

+

© جميع الحقوق محفوظة 2025

+
+ """, + unsafe_allow_html=True + ) + + +# دالة للحصول على اتصال قاعدة البيانات +def get_connection(): + """ + دالة للحصول على اتصال بقاعدة البيانات + + ملاحظة: ينبغي أن تكون مستبدلة بالدالة من db_connector.py في البيئة الإنتاجية + """ + try: + # استيراد المستوى الفعلي للاتصال بقاعدة البيانات + from database.db_connector import get_connection as get_db_connection + return get_db_connection() + except ImportError: + # إذا كان الاتصال بقاعدة البيانات غير متاح + logger.warning("لم يتم العثور على وحدة اتصال قاعدة البيانات. استخدام مخزن بيانات مؤقت.") + # إرجاع None للإشارة إلى عدم وجود اتصال + return None + + +def load_css(file_name=None): + """تحميل ملف CSS مخصص""" + try: + if file_name: + css_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "css", file_name) + + if os.path.exists(css_file): + with open(css_file, "r", encoding="utf-8") as f: + css_content = f.read() + else: + logger.warning(f"ملف CSS غير موجود: {css_file}") + return + else: + # CSS افتراضي + css_content = """ + .sidebar .sidebar-content { + direction: rtl; + text-align: right; + } + div[data-testid="stForm"] { + border: 1px solid #ddd; + padding: 10px; + border-radius: 10px; + } + .module-title { + color: #1E88E5; + text-align: center; + font-size: 1.8rem; + margin-bottom: 1rem; + } + .instructions { + background-color: #f8f9fa; + border-right: 3px solid #4CAF50; + padding: 10px; + margin-bottom: 15px; + } + .results-container { + background-color: #f5f5f5; + padding: 15px; + border-radius: 5px; + margin-top: 20px; + } + .risk-high { + color: #d32f2f; + font-weight: bold; + } + .risk-medium { + color: #f57c00; + font-weight: bold; + } + .risk-low { + color: #388e3c; + font-weight: bold; + } + .form-container { + background-color: #f9f9f9; + padding: 20px; + border-radius: 10px; + margin-bottom: 20px; + } + .section-header { + color: #2196F3; + font-size: 1.2rem; + font-weight: bold; + margin-top: 20px; + margin-bottom: 10px; + border-bottom: 1px solid #eee; + padding-bottom: 5px; + } + """ + + st.markdown(f"", unsafe_allow_html=True) + + except Exception as e: + logger.error(f"خطأ في تحميل ملف CSS: {e}") \ No newline at end of file diff --git a/utils/helpers/__init__.py b/utils/helpers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..55cb5af54b29bcb9435718982492624d2fccf94e --- /dev/null +++ b/utils/helpers/__init__.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""حزمة المساعدات العامة المستخدمة في النظام""" + +import streamlit as st +import pandas as pd +import numpy as np +import os +import sqlite3 + +from .utils import ( + create_directory_if_not_exists, + get_data_folder, + format_time, + get_user_info, + load_css, + render_credits, + load_icons, + format_number, + format_currency, + styled_button, + filter_dataframe, + get_file_extension, + extract_numbers_from_text +) + +def get_connection(): + """ + إنشاء اتصال وهمي لقاعدة البيانات للاستخدام عند عدم توفر اتصال PostgreSQL + + الإرجاع: + اتصال وهمي لقاعدة البيانات + """ + # إنشاء مجلد البيانات إذا لم يكن موجوداً + data_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'data') + os.makedirs(data_dir, exist_ok=True) + + # إنشاء اتصال قاعدة بيانات SQLite محلية + db_path = os.path.join(data_dir, 'local_db.sqlite') + conn = sqlite3.connect(db_path) + + # إعادة محاكاة سلوك اتصال PostgreSQL + conn.execute = conn.cursor().execute + + # إضافة وظيفة وهمية للاقتطاع (commit) والإغلاق + original_close = conn.close + def enhanced_close(): + conn.commit() + original_close() + conn.close = enhanced_close + + return conn + +__all__ = [ + 'create_directory_if_not_exists', + 'get_data_folder', + 'format_time', + 'get_user_info', + 'load_css', + 'render_credits', + 'load_icons', + 'format_number', + 'format_currency', + 'styled_button', + 'filter_dataframe', + 'get_file_extension', + 'extract_numbers_from_text', + 'get_connection' +] \ No newline at end of file diff --git a/utils/helpers/utils.py b/utils/helpers/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..07cb398f8f28c3f1281cae651e08d774bb859bf0 --- /dev/null +++ b/utils/helpers/utils.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +وحدة المساعدات العامة +توفر هذه الوحدة مجموعة من الدوال المساعدة المستخدمة في مختلف أجزاء النظام +""" + +import os +import datetime +import json +import re +import streamlit as st + +def create_directory_if_not_exists(directory_path): + """إنشاء مجلد إذا لم يكن موجوداً بالفعل""" + if not os.path.exists(directory_path): + os.makedirs(directory_path) + return True + return False + +def get_data_folder(): + """الحصول على مسار مجلد البيانات""" + data_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'data')) + create_directory_if_not_exists(data_folder) + return data_folder + +def format_time(timestamp=None): + """تنسيق الوقت بصيغة قابلة للقراءة""" + if timestamp is None: + timestamp = datetime.datetime.now() + return timestamp.strftime("%Y-%m-%d %H:%M:%S") + +def get_user_info(): + """الحصول على معلومات المستخدم الحالي""" + # في الوقت الحالي، نستخدم معلومات مستخدم افتراضية + # يمكن تعديل هذه الدالة لاحقاً للتكامل مع نظام المصادقة + return { + "id": 1, + "username": "admin", + "name": "مدير النظام", + "role": "admin" + } + +def load_css(): + """تحميل أنماط CSS المخصصة""" + st.markdown(""" + + + + """, unsafe_allow_html=True) + +def render_credits(): + """عرض المعلومات عن حقوق الملكية وإصدار النظام""" + st.markdown(""" + + """, unsafe_allow_html=True) + +def load_icons(): + """تحميل الأيقونات المستخدمة في النظام""" + icons = { + "project": "🏗️", + "document": "📄", + "analysis": "🔍", + "warning": "⚠️", + "success": "✅", + "error": "❌", + "info": "ℹ️", + "settings": "⚙️", + "user": "👤", + "money": "💰", + "time": "⏱️", + "location": "📍", + "notification": "🔔", + "edit": "✏️", + "delete": "🗑️", + "upload": "📤", + "download": "📥", + "save": "💾", + "cancel": "❌", + "add": "➕", + "calendar": "📅", + "chat": "💬", + "search": "🔎", + "star": "⭐", + "trophy": "🏆", + "medal": "🥇", + "chart": "📊", + "map": "🗺️", + "building": "🏢", + "road": "🛣️", + "bridge": "🌉", + } + return icons + +def format_number(number, decimal_places=2): + """تنسيق الأرقام بطريقة أنيقة""" + if isinstance(number, (int, float)): + if decimal_places == 0: + return "{:,.0f}".format(number) + else: + return "{:,.{dp}f}".format(number, dp=decimal_places) + return str(number) + +def format_currency(amount, currency="ريال", decimal_places=2): + """تنسيق المبالغ المالية""" + if amount is None: + return "غير محدد" + formatted = format_number(amount, decimal_places) + return f"{formatted} {currency}" + +def styled_button(label, key=None, type="primary", on_click=None, args=None, full_width=False, icon=None, is_link=False, help=None): + """ + إنشاء زر بتنسيق معين + :param label: نص الزر + :param key: مفتاح الزر الفريد + :param type: نوع التنسيق ('primary', 'secondary', 'success', 'warning', 'danger', 'info', 'glass', 'flat') + :param on_click: الدالة التي سيتم تنفيذها عند النقر + :param args: معاملات الدالة + :param full_width: هل يأخذ الزر العرض كاملاً + :param icon: أيقونة لعرضها قبل النص (emoji أو HTML) + :param is_link: إذا كان الزر رابطاً بدلاً من زر عادي + :param help: نص المساعدة للزر + :return: زر مُنسّق + """ + if is_link: + btn_class = f"{type}-btn" + if icon: + btn_class += " action-btn" + label_with_icon = f"{icon} {label}" + else: + label_with_icon = label + + button_html = f""" +
+ {label_with_icon} +
+ """ + return st.markdown(button_html, unsafe_allow_html=True) + else: + with st.container(): + btn_class = f"{type}-btn" + if icon: + btn_class += " action-btn" + label_with_icon = f"{icon} {label}" + else: + label_with_icon = label + + st.markdown(f'
', unsafe_allow_html=True) + clicked = st.button(label_with_icon, key=key, on_click=on_click, args=args, use_container_width=full_width, help=help) + st.markdown('
', unsafe_allow_html=True) + return clicked + +def filter_dataframe(df, column, value): + """ترشيح إطار البيانات""" + if value == "الكل": + return df + return df[df[column] == value] + +def get_file_extension(filename): + """استخراج امتداد الملف""" + if not filename: + return "" + return os.path.splitext(filename)[-1].lower() + +def extract_numbers_from_text(text): + """استخراج الأرقام من النص + + Args: + text (str): النص المراد استخراج الأرقام منه + + Returns: + list: قائمة بالأرقام المستخرجة + """ + if not text: + return [] + + # نمط للعثور على الأرقام (صحيحة أو عشرية) في النص + pattern = r'[-+]?\d*\.\d+|\d+' + + # استخراج جميع الأرقام من النص + numbers = re.findall(pattern, text) + + # تحويل النصوص المستخرجة إلى أرقام (صحيحة أو عشرية) + converted_numbers = [] + for num in numbers: + if '.' in num: + converted_numbers.append(float(num)) + else: + converted_numbers.append(int(num)) + + return converted_numbers \ No newline at end of file diff --git a/utils/pdf_handler.py b/utils/pdf_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..d2e141276d13808469c4bc601c48138445788824 --- /dev/null +++ b/utils/pdf_handler.py @@ -0,0 +1,847 @@ +""" +معالج ملفات PDF +""" + +import os +import io +import re +import PyPDF2 +import fitz # PyMuPDF +import pdfplumber +import numpy as np +from PIL import Image +import pytesseract +import pandas as pd +import traceback +from reportlab.lib.pagesizes import A4, landscape +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as ReportLabImage +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +import matplotlib.pyplot as plt + +from utils.helpers import create_directory_if_not_exists, extract_numbers_from_text + +# محاولة تسجيل الخطوط العربية إذا كانت متوفرة +try: + font_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "Amiri-Regular.ttf") + bold_font_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "Amiri-Bold.ttf") + + if os.path.exists(font_path) and os.path.exists(bold_font_path): + pdfmetrics.registerFont(TTFont('Arabic', font_path)) + pdfmetrics.registerFont(TTFont('ArabicBold', bold_font_path)) +except Exception as e: + print(f"تعذر تحميل الخطوط العربية: {str(e)}. سيتم استخدام الخطوط الافتراضية.") + + +def extract_text_from_pdf(file_path, method='pymupdf'): + """ + استخراج النص من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + method: طريقة الاستخراج ('pymupdf', 'pypdf2', 'pdfplumber') + + الإرجاع: + نص مستخرج من ملف PDF + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # استخراج النص حسب الطريقة المطلوبة + if method.lower() == 'pymupdf': + return _extract_text_with_pymupdf(file_path) + elif method.lower() == 'pypdf2': + return _extract_text_with_pypdf2(file_path) + elif method.lower() == 'pdfplumber': + return _extract_text_with_pdfplumber(file_path) + else: + # استخدام PyMuPDF كطريقة افتراضية + return _extract_text_with_pymupdf(file_path) + + except Exception as e: + error_msg = f"خطأ في استخراج النص من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def _extract_text_with_pymupdf(file_path): + """استخراج النص باستخدام PyMuPDF""" + document = fitz.open(file_path) + text = "" + + for page_number in range(len(document)): + page = document.load_page(page_number) + text += page.get_text("text") + "\n\n" + + document.close() + return text + + +def _extract_text_with_pypdf2(file_path): + """استخراج النص باستخدام PyPDF2""" + with open(file_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + text = "" + + for page_number in range(len(reader.pages)): + page = reader.pages[page_number] + text += page.extract_text() + "\n\n" + + return text + + +def _extract_text_with_pdfplumber(file_path): + """استخراج النص باستخدام pdfplumber""" + with pdfplumber.open(file_path) as pdf: + text = "" + + for page in pdf.pages: + text += page.extract_text() + "\n\n" + + return text + + +def extract_tables_from_pdf(file_path, page_numbers=None): + """ + استخراج الجداول من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + page_numbers: قائمة بأرقام الصفحات للاستخراج منها (افتراضي: None لجميع الصفحات) + + الإرجاع: + قائمة من DataFrames تمثل الجداول المستخرجة + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # استخراج الجداول باستخدام pdfplumber + tables = [] + + with pdfplumber.open(file_path) as pdf: + # تحديد الصفحات المراد استخراج الجداول منها + if page_numbers is None: + pages_to_extract = range(len(pdf.pages)) + else: + pages_to_extract = [p-1 for p in page_numbers if 1 <= p <= len(pdf.pages)] + + # استخراج الجداول من كل صفحة + for page_idx in pages_to_extract: + page = pdf.pages[page_idx] + page_tables = page.extract_tables() + + if page_tables: + for table in page_tables: + if table: # التحقق من أن الجدول ليس فارغًا + # تحويل الجدول إلى DataFrame + df = pd.DataFrame(table[1:], columns=table[0]) + + # تنظيف البيانات + df = df.applymap(lambda x: x.strip() if isinstance(x, str) else x) + + # إضافة إلى قائمة الجداول + tables.append(df) + + return tables + + except Exception as e: + error_msg = f"خطأ في استخراج الجداول من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_images_from_pdf(file_path, output_dir=None, prefix='image'): + """ + استخراج الصور من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + output_dir: دليل الإخراج (افتراضي: None للإرجاع كقائمة من الصور) + prefix: بادئة أسماء ملفات الصور + + الإرجاع: + قائمة من مسارات الصور المستخرجة إذا تم تحديد دليل الإخراج، وإلا قائمة من الصور + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # إنشاء دليل الإخراج إذا تم تحديده + if output_dir: + create_directory_if_not_exists(output_dir) + + # استخراج الصور باستخدام PyMuPDF + document = fitz.open(file_path) + images = [] + image_paths = [] + + for page_idx in range(len(document)): + page = document.load_page(page_idx) + + # استخراج الصور من الصفحة + image_list = page.get_images(full=True) + + for img_idx, img_info in enumerate(image_list): + xref = img_info[0] + base_image = document.extract_image(xref) + image_bytes = base_image["image"] + + # إنشاء كائن الصورة + image = Image.open(io.BytesIO(image_bytes)) + + if output_dir: + # حفظ الصورة في الدليل المحدد + image_filename = f"{prefix}_{page_idx+1}_{img_idx+1}.{base_image['ext']}" + image_path = os.path.join(output_dir, image_filename) + image.save(image_path) + image_paths.append(image_path) + else: + # إضافة الصورة إلى القائمة + images.append(image) + + document.close() + + return image_paths if output_dir else images + + except Exception as e: + error_msg = f"خطأ في استخراج الصور من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_text_from_image(image, lang='ara+eng'): + """ + استخراج النص من صورة باستخدام OCR + + المعلمات: + image: كائن الصورة أو مسار الصورة + lang: لغة النص (افتراضي: 'ara+eng' للعربية والإنجليزية) + + الإرجاع: + النص المستخرج من الصورة + """ + try: + # إذا كان مسار صورة، قم بفتحها + if isinstance(image, str): + image = Image.open(image) + + # استخراج النص باستخدام pytesseract + text = pytesseract.image_to_string(image, lang=lang) + + return text + + except Exception as e: + error_msg = f"خطأ في استخراج النص من الصورة: {str(e)}" + print(error_msg) + traceback.print_exc() + return "" + + +def ocr_pdf(file_path, lang='ara+eng'): + """ + تنفيذ OCR على ملف PDF + + المعلمات: + file_path: مسار ملف PDF + lang: لغة النص (افتراضي: 'ara+eng' للعربية والإنجليزية) + + الإرجاع: + النص المستخرج من ملف PDF + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # فتح ملف PDF + document = fitz.open(file_path) + text = "" + + for page_idx in range(len(document)): + page = document.load_page(page_idx) + + # تحويل الصفحة إلى صورة + pix = page.get_pixmap(matrix=fitz.Matrix(300/72, 300/72)) + img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + + # استخراج النص من الصورة + page_text = extract_text_from_image(img, lang=lang) + text += page_text + "\n\n" + + document.close() + + return text + + except Exception as e: + error_msg = f"خطأ في تنفيذ OCR على ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def search_in_pdf(file_path, search_text): + """ + البحث عن نص في ملف PDF + + المعلمات: + file_path: مسار ملف PDF + search_text: النص المراد البحث عنه + + الإرجاع: + قائمة من النتائج {page_number, text_snippet, matched_text} + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # استخراج النص من ملف PDF + document = fitz.open(file_path) + results = [] + + for page_idx in range(len(document)): + page = document.load_page(page_idx) + page_text = page.get_text("text") + + # البحث عن النص + if search_text.lower() in page_text.lower(): + # استخراج المقتطفات التي تحتوي على النص المطلوب + lines = page_text.split('\n') + for line in lines: + if search_text.lower() in line.lower(): + results.append({ + 'page_number': page_idx + 1, + 'text_snippet': line, + 'matched_text': search_text + }) + + document.close() + + return results + + except Exception as e: + error_msg = f"خطأ في البحث في ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_quantities_from_pdf(file_path): + """ + استخراج الكميات من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + + الإرجاع: + DataFrame يحتوي على البنود والكميات المستخرجة + """ + try: + # استخراج النص والجداول + text = extract_text_from_pdf(file_path) + tables = extract_tables_from_pdf(file_path) + + quantities = [] + + # استخراج الكميات من الجداول + for table in tables: + # البحث عن أعمدة تحتوي على "الكمية" أو "الوحدة" أو "البند" + quantity_cols = [col for col in table.columns if any(term in col.lower() for term in ['كمية', 'عدد', 'الكمية'])] + unit_cols = [col for col in table.columns if any(term in col.lower() for term in ['وحدة', 'الوحدة'])] + item_cols = [col for col in table.columns if any(term in col.lower() for term in ['بند', 'وصف', 'البند', 'العمل'])] + + if quantity_cols and (unit_cols or item_cols): + quantity_col = quantity_cols[0] + unit_col = unit_cols[0] if unit_cols else None + item_col = item_cols[0] if item_cols else None + + # استخراج الكميات + for _, row in table.iterrows(): + if pd.notna(row[quantity_col]) and (item_col is None or pd.notna(row[item_col])): + quantity_value = extract_numbers_from_text(row[quantity_col]) + quantity = quantity_value[0] if quantity_value else None + + quantities.append({ + 'البند': row[item_col] if item_col else "غير محدد", + 'الوحدة': row[unit_col] if unit_col else "غير محدد", + 'الكمية': quantity + }) + + # استخراج الكميات من النص + lines = text.split('\n') + for line in lines: + # البحث عن الخطوط التي تحتوي على أرقام ووحدات قياس + if re.search(r'\d+(?:,\d+)*(?:\.\d+)?', line) and any(unit in line for unit in ['م2', 'م3', 'متر', 'طن', 'كجم', 'عدد']): + numbers = extract_numbers_from_text(line) + if numbers: + # استخراج وحدة القياس + unit_match = re.search(r'\b(م2|م3|متر مربع|متر مكعب|م\.ط|طن|كجم|عدد|قطعة)\b', line) + unit = unit_match.group(1) if unit_match else "غير محدد" + + quantities.append({ + 'البند': line, + 'الوحدة': unit, + 'الكمية': numbers[0] + }) + + # إنشاء DataFrame + if quantities: + quantities_df = pd.DataFrame(quantities) + return quantities_df + else: + return pd.DataFrame(columns=['البند', 'الوحدة', 'الكمية']) + + except Exception as e: + error_msg = f"خطأ في استخراج الكميات من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def merge_pdfs(input_paths, output_path): + """ + دمج ملفات PDF متعددة في ملف واحد + + المعلمات: + input_paths: قائمة من مسارات ملفات PDF المراد دمجها + output_path: مسار ملف PDF الناتج + + الإرجاع: + True في حالة النجاح + """ + try: + # التحقق من وجود الملفات + for file_path in input_paths: + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # إنشاء مجلد الإخراج إذا لم يكن موجودًا + output_dir = os.path.dirname(output_path) + create_directory_if_not_exists(output_dir) + + # دمج ملفات PDF + merger = PyPDF2.PdfMerger() + + for file_path in input_paths: + merger.append(file_path) + + merger.write(output_path) + merger.close() + + return True + + except Exception as e: + error_msg = f"خطأ في دمج ملفات PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def split_pdf(input_path, output_dir, prefix='page'): + """ + تقسيم ملف PDF إلى ملفات منفصلة لكل صفحة + + المعلمات: + input_path: مسار ملف PDF المراد تقسيمه + output_dir: دليل الإخراج + prefix: بادئة أسماء ملفات الإخراج + + الإرجاع: + قائمة من مسارات ملفات PDF الناتجة + """ + try: + # التحقق من وجود الملف + if not os.path.exists(input_path): + raise FileNotFoundError(f"الملف غير موجود: {input_path}") + + # إنشاء دليل الإخراج + create_directory_if_not_exists(output_dir) + + # قراءة ملف PDF + with open(input_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + output_files = [] + + # تقسيم كل صفحة إلى ملف منفصل + for page_idx in range(len(reader.pages)): + writer = PyPDF2.PdfWriter() + writer.add_page(reader.pages[page_idx]) + + output_filename = f"{prefix}_{page_idx+1}.pdf" + output_path = os.path.join(output_dir, output_filename) + + with open(output_path, 'wb') as output_file: + writer.write(output_file) + + output_files.append(output_path) + + return output_files + + except Exception as e: + error_msg = f"خطأ في تقسيم ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def export_pricing_to_pdf(data, output_path, title="تحليل الأسعار", description=""): + """ + تصدير بيانات التسعير إلى ملف PDF + + المعلمات: + data: DataFrame يحتوي على بيانات التسعير + output_path: مسار ملف PDF الناتج + title: عنوان التقرير + description: وصف إضافي للتقرير + + الإرجاع: + مسار ملف PDF الناتج + """ + try: + # التحقق من أن البيانات ليست فارغة + if data is None or len(data) == 0: + raise ValueError("البيانات فارغة") + + # إنشاء دليل الإخراج إذا لم يكن موجودًا + output_dir = os.path.dirname(output_path) + create_directory_if_not_exists(output_dir) + + # إعداد أنماط النصوص + styles = getSampleStyleSheet() + + # محاولة استخدام الخطوط العربية إذا كانت متوفرة + try: + # تسجيل الخطوط العربية إذا لم تكن مسجلة + if 'Arabic' not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont('Arabic', 'utils/fonts/Amiri-Regular.ttf')) + + if 'ArabicBold' not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont('ArabicBold', 'utils/fonts/Amiri-Bold.ttf')) + + # إنشاء أنماط للنصوص العربية + title_style = ParagraphStyle( + name='ArabicTitleStyle', + fontName='ArabicBold', + fontSize=16, + alignment=1, # وسط + leading=18 + ) + + normal_style = ParagraphStyle( + name='ArabicStyle', + fontName='Arabic', + fontSize=12, + alignment=1, # وسط + leading=14 + ) + + except: + # استخدام الخطوط الافتراضية إذا تعذر استخدام الخطوط العربية + title_style = styles['Title'] + normal_style = styles['Normal'] + + # إنشاء وثيقة PDF + doc = SimpleDocTemplate( + output_path, + pagesize=landscape(A4), + rightMargin=30, + leftMargin=30, + topMargin=30, + bottomMargin=30 + ) + + # قائمة العناصر في الوثيقة + elements = [] + + # إضافة العنوان + elements.append(Paragraph(title, title_style)) + elements.append(Spacer(1, 20)) + + # إضافة الوصف إذا كان موجودًا + if description: + elements.append(Paragraph(description, normal_style)) + elements.append(Spacer(1, 20)) + + # تحويل DataFrame إلى قائمة لاستخدامها في الجدول + data_list = [data.columns.tolist()] + data.values.tolist() + + # إنشاء الجدول + table = Table(data_list, repeatRows=1) + + # إضافة تنسيق للجدول + table_style = TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.lightblue), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'ArabicBold' if 'ArabicBold' in pdfmetrics.getRegisteredFontNames() else 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 12), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTNAME', (0, 1), (-1, -1), 'Arabic' if 'Arabic' in pdfmetrics.getRegisteredFontNames() else 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 10), + ]) + + # إضافة تلوين للصفوف المتناوبة + for i in range(1, len(data_list)): + if i % 2 == 0: + table_style.add('BACKGROUND', (0, i), (-1, i), colors.lightgrey) + + table.setStyle(table_style) + elements.append(table) + + # إضافة ملخص إحصائي إذا كانت البيانات تحتوي على أعمدة رقمية + numeric_columns = data.select_dtypes(include=['number']).columns + if len(numeric_columns) > 0: + elements.append(Spacer(1, 30)) + elements.append(Paragraph("ملخص إحصائي", title_style)) + elements.append(Spacer(1, 10)) + + # إنشاء ملخص إحصائي + summary = data[numeric_columns].describe().reset_index() + summary_list = [['الإحصاءات'] + list(numeric_columns)] + summary.values.tolist() + + # إنشاء جدول الملخص + summary_table = Table(summary_list, repeatRows=1) + summary_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.lightblue), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'ArabicBold' if 'ArabicBold' in pdfmetrics.getRegisteredFontNames() else 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 12), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTNAME', (0, 1), (-1, -1), 'Arabic' if 'Arabic' in pdfmetrics.getRegisteredFontNames() else 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 10), + ])) + elements.append(summary_table) + + # إنشاء مخططات للأعمدة الرقمية الرئيسية + if len(numeric_columns) > 0 and len(data) > 1: + for col in numeric_columns[:2]: # أخذ أول عمودين رقميين فقط + plt.figure(figsize=(8, 4)) + plt.barh(range(len(data)), data[col]) + plt.yticks(range(len(data)), data.iloc[:, 0] if len(data.columns) > 0 else range(len(data))) + plt.xlabel(col) + plt.title(f"مخطط {col}") + plt.tight_layout() + + # حفظ المخطط كصورة في الذاكرة + img_data = io.BytesIO() + plt.savefig(img_data, format='png') + img_data.seek(0) + plt.close() + + # إضافة المخطط إلى الوثيقة + elements.append(Spacer(1, 20)) + img = ReportLabImage(img_data, width=500, height=250) + elements.append(img) + + # بناء الوثيقة + doc.build(elements) + + return output_path + + except Exception as e: + error_msg = f"خطأ في تصدير بيانات التسعير إلى PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def export_pricing_with_analysis_to_pdf(items_data, analysis_data, output_path, title="تحليل الأسعار مع تحليل المكونات", project_info=None): + """ + تصدير بيانات التسعير مع تحليل المكونات إلى ملف PDF + + المعلمات: + items_data: DataFrame يحتوي على بيانات البنود + analysis_data: قاموس يحتوي على تحليل أسعار البنود {item_id: DataFrame} + output_path: مسار ملف PDF الناتج + title: عنوان التقرير + project_info: معلومات المشروع (قاموس) + + الإرجاع: + مسار ملف PDF الناتج + """ + try: + # التحقق من أن البيانات ليست فارغة + if items_data is None or len(items_data) == 0: + raise ValueError("بيانات البنود فارغة") + + # إنشاء دليل الإخراج إذا لم يكن موجودًا + output_dir = os.path.dirname(output_path) + create_directory_if_not_exists(output_dir) + + # إعداد أنماط النصوص + styles = getSampleStyleSheet() + + # محاولة استخدام الخطوط العربية إذا كانت متوفرة + try: + # تسجيل الخطوط العربية إذا لم تكن مسجلة + if 'Arabic' not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont('Arabic', 'utils/fonts/Amiri-Regular.ttf')) + + if 'ArabicBold' not in pdfmetrics.getRegisteredFontNames(): + pdfmetrics.registerFont(TTFont('ArabicBold', 'utils/fonts/Amiri-Bold.ttf')) + + # إنشاء أنماط للنصوص العربية + title_style = ParagraphStyle( + name='ArabicTitleStyle', + fontName='ArabicBold', + fontSize=16, + alignment=1, # وسط + leading=18 + ) + + normal_style = ParagraphStyle( + name='ArabicStyle', + fontName='Arabic', + fontSize=12, + alignment=1, # وسط + leading=14 + ) + + subtitle_style = ParagraphStyle( + name='ArabicSubtitleStyle', + fontName='ArabicBold', + fontSize=14, + alignment=1, # وسط + leading=16 + ) + + except: + # استخدام الخطوط الافتراضية إذا تعذر استخدام الخطوط العربية + title_style = styles['Title'] + normal_style = styles['Normal'] + subtitle_style = styles['Heading2'] + + # إنشاء وثيقة PDF + doc = SimpleDocTemplate( + output_path, + pagesize=landscape(A4), + rightMargin=30, + leftMargin=30, + topMargin=30, + bottomMargin=30 + ) + + # قائمة العناصر في الوثيقة + elements = [] + + # إضافة العنوان + elements.append(Paragraph(title, title_style)) + elements.append(Spacer(1, 20)) + + # إضافة معلومات المشروع إذا كانت موجودة + if project_info: + project_info_text = f"اسم المشروع: {project_info.get('اسم_المشروع', '')} | " + project_info_text += f"وصف المشروع: {project_info.get('وصف_المشروع', '')}" + elements.append(Paragraph(project_info_text, normal_style)) + elements.append(Spacer(1, 20)) + + # إضافة جدول البنود + elements.append(Paragraph("جدول البنود", subtitle_style)) + elements.append(Spacer(1, 10)) + + # تحويل DataFrame إلى قائمة لاستخدامها في الجدول + items_list = [items_data.columns.tolist()] + items_data.values.tolist() + + # إنشاء الجدول + items_table = Table(items_list, repeatRows=1) + + # إضافة تنسيق للجدول + items_table_style = TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.lightblue), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'ArabicBold' if 'ArabicBold' in pdfmetrics.getRegisteredFontNames() else 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 12), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTNAME', (0, 1), (-1, -1), 'Arabic' if 'Arabic' in pdfmetrics.getRegisteredFontNames() else 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 10), + ]) + + # إضافة تلوين للصفوف المتناوبة + for i in range(1, len(items_list)): + if i % 2 == 0: + items_table_style.add('BACKGROUND', (0, i), (-1, i), colors.lightgrey) + + items_table.setStyle(items_table_style) + elements.append(items_table) + + # إضافة تحليل الأسعار لكل بند + if analysis_data: + elements.append(Spacer(1, 30)) + elements.append(Paragraph("تحليل مكونات الأسعار", subtitle_style)) + + for item_id, analysis_df in analysis_data.items(): + # الحصول على معلومات البند + item_info = items_data[items_data['رقم البند'] == item_id].iloc[0] if 'رقم البند' in items_data.columns and len(items_data[items_data['رقم البند'] == item_id]) > 0 else None + + if item_info is not None: + item_title = f"تحليل مكونات البند: {item_id} - {item_info.get('وصف البند', '')}" + else: + item_title = f"تحليل مكونات البند: {item_id}" + + elements.append(Spacer(1, 20)) + elements.append(Paragraph(item_title, subtitle_style)) + elements.append(Spacer(1, 10)) + + # تحويل DataFrame إلى قائمة لاستخدامها في الجدول + analysis_list = [analysis_df.columns.tolist()] + analysis_df.values.tolist() + + # إنشاء الجدول + analysis_table = Table(analysis_list, repeatRows=1) + + # إضافة تنسيق للجدول + analysis_table_style = TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.lightblue), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'ArabicBold' if 'ArabicBold' in pdfmetrics.getRegisteredFontNames() else 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 12), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.white), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('FONTNAME', (0, 1), (-1, -1), 'Arabic' if 'Arabic' in pdfmetrics.getRegisteredFontNames() else 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -1), 10), + ]) + + # إضافة تلوين للصفوف المتناوبة + for i in range(1, len(analysis_list)): + if i % 2 == 0: + analysis_table_style.add('BACKGROUND', (0, i), (-1, i), colors.lightgrey) + + analysis_table.setStyle(analysis_table_style) + elements.append(analysis_table) + + # حساب وعرض إجمالي تحليل السعر + if 'الإجمالي' in analysis_df.columns and len(analysis_df) > 0: + total_analysis_price = analysis_df['الإجمالي'].sum() + total_text = f"إجمالي تكلفة البند من التحليل: {total_analysis_price:,.2f} ريال" + elements.append(Spacer(1, 10)) + elements.append(Paragraph(total_text, normal_style)) + + # بناء الوثيقة + doc.build(elements) + + return output_path + + except Exception as e: + error_msg = f"خطأ في تصدير تحليل الأسعار إلى PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) diff --git a/utils/pdf_handler.py.bak b/utils/pdf_handler.py.bak new file mode 100644 index 0000000000000000000000000000000000000000..1da94f2ef06d7df164a62c9ce55e0ce3c14631e6 --- /dev/null +++ b/utils/pdf_handler.py.bak @@ -0,0 +1,476 @@ +""" +معالج ملفات PDF +""" + +import os +import io +import re +import PyPDF2 +import fitz # PyMuPDF +import pdfplumber +import numpy as np +from PIL import Image +import pytesseract +import pandas as pd +import traceback + +from utils.helpers import create_directory_if_not_exists, extract_numbers_from_text + + +def extract_text_from_pdf(file_path, method='pymupdf'): + """ + استخراج النص من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + method: طريقة الاستخراج ('pymupdf', 'pypdf2', 'pdfplumber') + + الإرجاع: + نص مستخرج من ملف PDF + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # استخراج النص حسب الطريقة المطلوبة + if method.lower() == 'pymupdf': + return _extract_text_with_pymupdf(file_path) + elif method.lower() == 'pypdf2': + return _extract_text_with_pypdf2(file_path) + elif method.lower() == 'pdfplumber': + return _extract_text_with_pdfplumber(file_path) + else: + # استخدام PyMuPDF كطريقة افتراضية + return _extract_text_with_pymupdf(file_path) + + except Exception as e: + error_msg = f"خطأ في استخراج النص من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def _extract_text_with_pymupdf(file_path): + """استخراج النص باستخدام PyMuPDF""" + document = fitz.open(file_path) + text = "" + + for page_number in range(len(document)): + page = document.load_page(page_number) + text += page.get_text("text") + "\n\n" + + document.close() + return text + + +def _extract_text_with_pypdf2(file_path): + """استخراج النص باستخدام PyPDF2""" + with open(file_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + text = "" + + for page_number in range(len(reader.pages)): + page = reader.pages[page_number] + text += page.extract_text() + "\n\n" + + return text + + +def _extract_text_with_pdfplumber(file_path): + """استخراج النص باستخدام pdfplumber""" + with pdfplumber.open(file_path) as pdf: + text = "" + + for page in pdf.pages: + text += page.extract_text() + "\n\n" + + return text + + +def extract_tables_from_pdf(file_path, page_numbers=None): + """ + استخراج الجداول من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + page_numbers: قائمة بأرقام الصفحات للاستخراج منها (افتراضي: None لجميع الصفحات) + + الإرجاع: + قائمة من DataFrames تمثل الجداول المستخرجة + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # استخراج الجداول باستخدام pdfplumber + tables = [] + + with pdfplumber.open(file_path) as pdf: + # تحديد الصفحات المراد استخراج الجداول منها + if page_numbers is None: + pages_to_extract = range(len(pdf.pages)) + else: + pages_to_extract = [p-1 for p in page_numbers if 1 <= p <= len(pdf.pages)] + + # استخراج الجداول من كل صفحة + for page_idx in pages_to_extract: + page = pdf.pages[page_idx] + page_tables = page.extract_tables() + + if page_tables: + for table in page_tables: + if table: # التحقق من أن الجدول ليس فارغًا + # تحويل الجدول إلى DataFrame + df = pd.DataFrame(table[1:], columns=table[0]) + + # تنظيف البيانات + df = df.applymap(lambda x: x.strip() if isinstance(x, str) else x) + + # إضافة إلى قائمة الجداول + tables.append(df) + + return tables + + except Exception as e: + error_msg = f"خطأ في استخراج الجداول من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_images_from_pdf(file_path, output_dir=None, prefix='image'): + """ + استخراج الصور من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + output_dir: دليل الإخراج (افتراضي: None للإرجاع كقائمة من الصور) + prefix: بادئة أسماء ملفات الصور + + الإرجاع: + قائمة من مسارات الصور المستخرجة إذا تم تحديد دليل الإخراج، وإلا قائمة من الصور + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # إنشاء دليل الإخراج إذا تم تحديده + if output_dir: + create_directory_if_not_exists(output_dir) + + # استخراج الصور باستخدام PyMuPDF + document = fitz.open(file_path) + images = [] + image_paths = [] + + for page_idx in range(len(document)): + page = document.load_page(page_idx) + + # استخراج الصور من الصفحة + image_list = page.get_images(full=True) + + for img_idx, img_info in enumerate(image_list): + xref = img_info[0] + base_image = document.extract_image(xref) + image_bytes = base_image["image"] + + # إنشاء كائن الصورة + image = Image.open(io.BytesIO(image_bytes)) + + if output_dir: + # حفظ الصورة في الدليل المحدد + image_filename = f"{prefix}_{page_idx+1}_{img_idx+1}.{base_image['ext']}" + image_path = os.path.join(output_dir, image_filename) + image.save(image_path) + image_paths.append(image_path) + else: + # إضافة الصورة إلى القائمة + images.append(image) + + document.close() + + return image_paths if output_dir else images + + except Exception as e: + error_msg = f"خطأ في استخراج الصور من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_text_from_image(image, lang='ara+eng'): + """ + استخراج النص من صورة باستخدام OCR + + المعلمات: + image: كائن الصورة أو مسار الصورة + lang: لغة النص (افتراضي: 'ara+eng' للعربية والإنجليزية) + + الإرجاع: + النص المستخرج من الصورة + """ + try: + # إذا كان مسار صورة، قم بفتحها + if isinstance(image, str): + image = Image.open(image) + + # استخراج النص باستخدام pytesseract + text = pytesseract.image_to_string(image, lang=lang) + + return text + + except Exception as e: + error_msg = f"خطأ في استخراج النص من الصورة: {str(e)}" + print(error_msg) + traceback.print_exc() + return "" + + +def ocr_pdf(file_path, lang='ara+eng'): + """ + تنفيذ OCR على ملف PDF + + المعلمات: + file_path: مسار ملف PDF + lang: لغة النص (افتراضي: 'ara+eng' للعربية والإنجليزية) + + الإرجاع: + النص المستخرج من ملف PDF + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # فتح ملف PDF + document = fitz.open(file_path) + text = "" + + for page_idx in range(len(document)): + page = document.load_page(page_idx) + + # تحويل الصفحة إلى صورة + pix = page.get_pixmap(matrix=fitz.Matrix(300/72, 300/72)) + img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + + # استخراج النص من الصورة + page_text = extract_text_from_image(img, lang=lang) + text += page_text + "\n\n" + + document.close() + + return text + + except Exception as e: + error_msg = f"خطأ في تنفيذ OCR على ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def search_in_pdf(file_path, search_text): + """ + البحث عن نص في ملف PDF + + المعلمات: + file_path: مسار ملف PDF + search_text: النص المراد البحث عنه + + الإرجاع: + قائمة من النتائج {page_number, text_snippet, matched_text} + """ + try: + # التحقق من وجود الملف + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # استخراج النص من ملف PDF + document = fitz.open(file_path) + results = [] + + for page_idx in range(len(document)): + page = document.load_page(page_idx) + page_text = page.get_text("text") + + # البحث عن النص + if search_text.lower() in page_text.lower(): + # استخراج المقتطفات التي تحتوي على النص المطلوب + lines = page_text.split('\n') + for line in lines: + if search_text.lower() in line.lower(): + results.append({ + 'page_number': page_idx + 1, + 'text_snippet': line, + 'matched_text': search_text + }) + + document.close() + + return results + + except Exception as e: + error_msg = f"خطأ في البحث في ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def extract_quantities_from_pdf(file_path): + """ + استخراج الكميات من ملف PDF + + المعلمات: + file_path: مسار ملف PDF + + الإرجاع: + DataFrame يحتوي على البنود والكميات المستخرجة + """ + try: + # استخراج النص والجداول + text = extract_text_from_pdf(file_path) + tables = extract_tables_from_pdf(file_path) + + quantities = [] + + # استخراج الكميات من الجداول + for table in tables: + # البحث عن أعمدة تحتوي على "الكمية" أو "الوحدة" أو "البند" + quantity_cols = [col for col in table.columns if any(term in col.lower() for term in ['كمية', 'عدد', 'الكمية'])] + unit_cols = [col for col in table.columns if any(term in col.lower() for term in ['وحدة', 'الوحدة'])] + item_cols = [col for col in table.columns if any(term in col.lower() for term in ['بند', 'وصف', 'البند', 'العمل'])] + + if quantity_cols and (unit_cols or item_cols): + quantity_col = quantity_cols[0] + unit_col = unit_cols[0] if unit_cols else None + item_col = item_cols[0] if item_cols else None + + # استخراج الكميات + for _, row in table.iterrows(): + if pd.notna(row[quantity_col]) and (item_col is None or pd.notna(row[item_col])): + quantity_value = extract_numbers_from_text(row[quantity_col]) + quantity = quantity_value[0] if quantity_value else None + + quantities.append({ + 'البند': row[item_col] if item_col else "غير محدد", + 'الوحدة': row[unit_col] if unit_col else "غير محدد", + 'الكمية': quantity + }) + + # استخراج الكميات من النص + lines = text.split('\n') + for line in lines: + # البحث عن الخطوط التي تحتوي على أرقام ووحدات قياس + if re.search(r'\d+(?:,\d+)*(?:\.\d+)?', line) and any(unit in line for unit in ['م2', 'م3', 'متر', 'طن', 'كجم', 'عدد']): + numbers = extract_numbers_from_text(line) + if numbers: + # استخراج وحدة القياس + unit_match = re.search(r'\b(م2|م3|متر مربع|متر مكعب|م\.ط|طن|كجم|عدد|قطعة)\b', line) + unit = unit_match.group(1) if unit_match else "غير محدد" + + quantities.append({ + 'البند': line, + 'الوحدة': unit, + 'الكمية': numbers[0] + }) + + # إنشاء DataFrame + if quantities: + quantities_df = pd.DataFrame(quantities) + return quantities_df + else: + return pd.DataFrame(columns=['البند', 'الوحدة', 'الكمية']) + + except Exception as e: + error_msg = f"خطأ في استخراج الكميات من ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def merge_pdfs(input_paths, output_path): + """ + دمج ملفات PDF متعددة في ملف واحد + + المعلمات: + input_paths: قائمة من مسارات ملفات PDF المراد دمجها + output_path: مسار ملف PDF الناتج + + الإرجاع: + True في حالة النجاح + """ + try: + # التحقق من وجود الملفات + for file_path in input_paths: + if not os.path.exists(file_path): + raise FileNotFoundError(f"الملف غير موجود: {file_path}") + + # إنشاء مجلد الإخراج إذا لم يكن موجودًا + output_dir = os.path.dirname(output_path) + create_directory_if_not_exists(output_dir) + + # دمج ملفات PDF + merger = PyPDF2.PdfMerger() + + for file_path in input_paths: + merger.append(file_path) + + merger.write(output_path) + merger.close() + + return True + + except Exception as e: + error_msg = f"خطأ في دمج ملفات PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) + + +def split_pdf(input_path, output_dir, prefix='page'): + """ + تقسيم ملف PDF إلى ملفات منفصلة لكل صفحة + + المعلمات: + input_path: مسار ملف PDF المراد تقسيمه + output_dir: دليل الإخراج + prefix: بادئة أسماء ملفات الإخراج + + الإرجاع: + قائمة من مسارات ملفات PDF الناتجة + """ + try: + # التحقق من وجود الملف + if not os.path.exists(input_path): + raise FileNotFoundError(f"الملف غير موجود: {input_path}") + + # إنشاء دليل الإخراج + create_directory_if_not_exists(output_dir) + + # قراءة ملف PDF + with open(input_path, 'rb') as file: + reader = PyPDF2.PdfReader(file) + output_files = [] + + # تقسيم كل صفحة إلى ملف منفصل + for page_idx in range(len(reader.pages)): + writer = PyPDF2.PdfWriter() + writer.add_page(reader.pages[page_idx]) + + output_filename = f"{prefix}_{page_idx+1}.pdf" + output_path = os.path.join(output_dir, output_filename) + + with open(output_path, 'wb') as output_file: + writer.write(output_file) + + output_files.append(output_path) + + return output_files + + except Exception as e: + error_msg = f"خطأ في تقسيم ملف PDF: {str(e)}" + print(error_msg) + traceback.print_exc() + raise Exception(error_msg) \ No newline at end of file diff --git a/utils/session_state.py b/utils/session_state.py new file mode 100644 index 0000000000000000000000000000000000000000..93bdbfb0b81cea9622b6ecf0b4405bb9b41cc429 --- /dev/null +++ b/utils/session_state.py @@ -0,0 +1,247 @@ +""" +وحدة إدارة حالة الجلسة +""" + +import streamlit as st +from datetime import datetime +import pandas as pd +import config + + +def initialize_session_state(): + """ + تهيئة متغيرات حالة الجلسة + """ + # المتغيرات الرئيسية لحالة المستخدم + if 'is_authenticated' not in st.session_state: + st.session_state.is_authenticated = False + + if 'user_info' not in st.session_state: + st.session_state.user_info = None + + # المتغيرات المتعلقة بالمناقصات والمشاريع + if 'current_project' not in st.session_state: + st.session_state.current_project = None + + if 'current_pricing' not in st.session_state: + st.session_state.current_pricing = None + + if 'pricing_history' not in st.session_state: + st.session_state.pricing_history = [] + + # المتغيرات المتعلقة بالمحتوى المحلي + if 'local_content_products' not in st.session_state: + st.session_state.local_content_products = pd.DataFrame({ + 'المنتج': [], + 'الكمية': [], + 'سعر_الوحدة': [], + 'التكلفة_الإجمالية': [], + 'نسبة_المحتوى_المحلي': [] + }) + + if 'local_content_services' not in st.session_state: + st.session_state.local_content_services = pd.DataFrame({ + 'الخدمة': [], + 'التكلفة': [], + 'نسبة_المحتوى_المحلي': [] + }) + + if 'local_content_labor' not in st.session_state: + st.session_state.local_content_labor = pd.DataFrame({ + 'فئة_العمالة': [], + 'العدد': [], + 'الراتب_الشهري': [], + 'المدة_بالأشهر': [], + 'نسبة_المحتوى_المحلي': [] + }) + + # المتغيرات المتعلقة بتحليل المستندات + if 'current_document' not in st.session_state: + st.session_state.current_document = None + + if 'analyzed_documents' not in st.session_state: + st.session_state.analyzed_documents = [] + + # المتغيرات المتعلقة بالتسعير + if 'manual_items' not in st.session_state: + st.session_state.manual_items = pd.DataFrame({ + 'رقم البند': [], + 'وصف البند': [], + 'الوحدة': [], + 'الكمية': [], + 'سعر الوحدة': [], + 'الإجمالي': [] + }) + + # المتغيرات المتعلقة بالمصادر + if 'resources' not in st.session_state: + st.session_state.resources = [] + + # المتغيرات المتعلقة بالإعدادات + if 'settings' not in st.session_state: + st.session_state.settings = { + 'ui_theme': config.UI_THEME, + 'locale': config.LOCALE, + 'enable_animations': config.ENABLE_ANIMATIONS + } + + +def save_current_pricing(): + """ + حفظ التسعير الحالي في سجل التسعير + + الإرجاع: + True في حالة النجاح، False في حالة الفشل + """ + try: + if st.session_state.current_pricing: + # إضافة معلومات إضافية + pricing_entry = st.session_state.current_pricing.copy() + pricing_entry['timestamp'] = datetime.now() + + # إضافة إلى سجل التسعير + st.session_state.pricing_history.append(pricing_entry) + + return True + return False + except Exception as e: + print(f"خطأ في حفظ التسعير الحالي: {str(e)}") + return False + + +def set_current_project(project_data): + """ + تعيين المشروع الحالي + + المعلمات: + project_data: بيانات المشروع + """ + st.session_state.current_project = project_data + + +def set_current_pricing(pricing_data): + """ + تعيين التسعير الحالي + + المعلمات: + pricing_data: بيانات التسعير + """ + st.session_state.current_pricing = pricing_data + + +def set_current_document(document_data): + """ + تعيين المستند الحالي + + المعلمات: + document_data: بيانات المستند + """ + st.session_state.current_document = document_data + + +def clear_session(): + """ + مسح بيانات الجلسة الحالية + """ + # الاحتفاظ بحالة المصادقة والمستخدم + is_authenticated = st.session_state.is_authenticated + user_info = st.session_state.user_info + settings = st.session_state.settings + + # مسح المتغيرات + st.session_state.clear() + + # إعادة تعيين حالة المصادقة والمستخدم + st.session_state.is_authenticated = is_authenticated + st.session_state.user_info = user_info + st.session_state.settings = settings + + # إعادة تهيئة متغيرات الجلسة + initialize_session_state() + + +def export_session_state(): + """ + تصدير حالة الجلسة الحالية + + الإرجاع: + قاموس يحتوي على حالة الجلسة + """ + # إنشاء نسخة من حالة الجلسة + session_data = {} + + # تخزين البيانات الرئيسية + if st.session_state.current_project: + session_data['current_project'] = st.session_state.current_project + + if st.session_state.current_pricing: + session_data['current_pricing'] = st.session_state.current_pricing + + if st.session_state.pricing_history: + session_data['pricing_history'] = st.session_state.pricing_history + + # تحويل DataFrames إلى قوائم من القواميس + if 'local_content_products' in st.session_state and not st.session_state.local_content_products.empty: + session_data['local_content_products'] = st.session_state.local_content_products.to_dict('records') + + if 'local_content_services' in st.session_state and not st.session_state.local_content_services.empty: + session_data['local_content_services'] = st.session_state.local_content_services.to_dict('records') + + if 'local_content_labor' in st.session_state and not st.session_state.local_content_labor.empty: + session_data['local_content_labor'] = st.session_state.local_content_labor.to_dict('records') + + # تخزين البيانات الأخرى + if 'manual_items' in st.session_state and not st.session_state.manual_items.empty: + session_data['manual_items'] = st.session_state.manual_items.to_dict('records') + + if st.session_state.resources: + session_data['resources'] = st.session_state.resources + + # إضافة بيانات الوقت + session_data['exported_at'] = datetime.now().isoformat() + + return session_data + + +def import_session_state(session_data): + """ + استيراد حالة الجلسة + + المعلمات: + session_data: قاموس يحتوي على حالة الجلسة + + الإرجاع: + True في حالة النجاح، False في حالة الفشل + """ + try: + # استيراد البيانات الرئيسية + if 'current_project' in session_data: + st.session_state.current_project = session_data['current_project'] + + if 'current_pricing' in session_data: + st.session_state.current_pricing = session_data['current_pricing'] + + if 'pricing_history' in session_data: + st.session_state.pricing_history = session_data['pricing_history'] + + # استيراد DataFrames + if 'local_content_products' in session_data: + st.session_state.local_content_products = pd.DataFrame(session_data['local_content_products']) + + if 'local_content_services' in session_data: + st.session_state.local_content_services = pd.DataFrame(session_data['local_content_services']) + + if 'local_content_labor' in session_data: + st.session_state.local_content_labor = pd.DataFrame(session_data['local_content_labor']) + + # استيراد البيانات الأخرى + if 'manual_items' in session_data: + st.session_state.manual_items = pd.DataFrame(session_data['manual_items']) + + if 'resources' in session_data: + st.session_state.resources = session_data['resources'] + + return True + except Exception as e: + print(f"خطأ في استيراد حالة الجلسة: {str(e)}") + return False \ No newline at end of file