File size: 10,788 Bytes
94088ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca451aa
94088ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
"""
Data Access Layer — Repository pattern.

هذه هي الطبقة الوحيدة في النظام التي تعرف أن البيانات مخزّنة في ملفات CSV.
باقي الكود (logic.py + الصفحات) يتعامل مع الـ Repository interface فقط، لذلك
الانتقال إلى Supabase/PostgreSQL لاحقًا = كتابة SupabaseRepository بنفس الدوال،
بدون أي تعديل في منطق الحسابات أو الواجهة.

كل ملف CSV في data/ يطابق جدولًا في PostgreSQL بنفس أسماء الأعمدة حرفيًا.

⚠️ ملاحظة النشر على Hugging Face Spaces:
الكتابة في ملفات CSV هنا تتم على قرص مؤقت (ephemeral filesystem). أي تعديل
(Sync / إضافة نقاط / اعتماد مكافأة) يظهر فورًا ويبقى طوال تشغيل الـ Space،
لكنه يُفقد عند إعادة تشغيل أو إعادة بناء الـ Space. هذا مقبول لنسخة العرض
(demo only) — في الإنتاج تحل Supabase محل هذه الطبقة وتصبح الكتابة دائمة.
"""

from __future__ import annotations

import json
import os
from abc import ABC, abstractmethod
from datetime import datetime

import pandas as pd

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
# حالة الـ Sync تُحفظ خارج data/ عمدًا: مجلد data/ يمثل schema قاعدة البيانات
# بالضبط ويجب أن يبقى قابلًا للترحيل بـ COPY FROM CSV دون أعمدة أو ملفات دخيلة.
# في الإنتاج يصبح هذا الملف جدول synced_tasks أو UNIQUE constraint على المصدر.
SYNC_STATE_FILE = os.path.join(BASE_DIR, "sync_state.json")


class Repository(ABC):
    """الواجهة التي يعتمد عليها باقي النظام. أي مصدر بيانات ينفّذها."""

    # ---- قراءة (SELECT) ----
    @abstractmethod
    def get_employees(self) -> pd.DataFrame: ...

    @abstractmethod
    def get_points(self) -> pd.DataFrame: ...

    @abstractmethod
    def get_badges(self) -> pd.DataFrame: ...

    @abstractmethod
    def get_rewards(self) -> pd.DataFrame: ...

    @abstractmethod
    def get_reward_requests(self) -> pd.DataFrame: ...

    @abstractmethod
    def get_notifications(self) -> pd.DataFrame: ...

    # ---- كتابة (INSERT / UPDATE) ----
    @abstractmethod
    def record_points(self, employee_id: str, action: str, points: int,
                      created_at: str | None = None) -> None: ...

    @abstractmethod
    def award_badge(self, employee_id: str, badge_name: str,
                    awarded_at: str | None = None) -> None: ...

    @abstractmethod
    def send_notification(self, employee_id: str, message: str,
                          channel: str = "slack") -> None: ...

    @abstractmethod
    def create_reward_request(self, employee_id: str, reward_id: str,
                              reward_name: str, points_cost: int) -> None: ...

    @abstractmethod
    def resolve_reward_request(self, request_id: int, status: str) -> None: ...

    @abstractmethod
    def get_synced_task_ids(self) -> set[str]: ...

    @abstractmethod
    def mark_task_synced(self, task_id: str) -> None: ...


def _now() -> str:
    """ISO 8601 بنفس صيغة الداتا الموجودة: 2026-07-01T09:15:00"""
    return datetime.now().isoformat(timespec="seconds")


class CSVRepository(Repository):
    """
    تنفيذ الـ Repository فوق ملفات CSV باستخدام pandas.

    ملاحظة مهمة: الطوابع الزمنية تُقرأ كنصوص (بدون parse_dates) عمدًا، حتى تعود
    إلى الملف بنفس صيغتها ISO بالضبط عند الكتابة. التحويل إلى datetime يتم في
    logic.py وقت الحساب فقط.
    """

    def __init__(self, data_dir: str = DATA_DIR):
        self.data_dir = data_dir

    def _path(self, table: str) -> str:
        return os.path.join(self.data_dir, f"{table}.csv")

    def _read(self, table: str, keep_default_na: bool = True) -> pd.DataFrame:
        return pd.read_csv(self._path(table), keep_default_na=keep_default_na)

    def _write(self, table: str, df: pd.DataFrame) -> None:
        # index=False يحافظ على أسماء وترتيب الأعمدة كما هي — شرط الترحيل لـ PostgreSQL
        df.to_csv(self._path(table), index=False)

    @staticmethod
    def _next_id(df: pd.DataFrame) -> int:
        """محاكاة SERIAL PRIMARY KEY."""
        if df.empty or "id" not in df.columns:
            return 1
        return int(pd.to_numeric(df["id"], errors="coerce").max()) + 1

    # ---------------- قراءة ----------------

    def get_employees(self) -> pd.DataFrame:
        # keep_default_na=False ضروري هنا: قيمة current_level النصية "None" مدرجة
        # ضمن قيم NA الافتراضية في pandas، فتُقرأ NaN بدل نص. بدون هذا المعامل
        # ينكسر حساب ترقية المستوى ويُكتب العمود فارغًا في الملف عند أي تحديث.
        return self._read("employees", keep_default_na=False)

    def get_points(self) -> pd.DataFrame:
        return self._read("employee_points")

    def get_badges(self) -> pd.DataFrame:
        return self._read("employee_badges")

    def get_rewards(self) -> pd.DataFrame:
        return self._read("rewards")

    def get_reward_requests(self) -> pd.DataFrame:
        return self._read("reward_requests")

    def get_notifications(self) -> pd.DataFrame:
        return self._read("notifications")

    # ---------------- كتابة ----------------

    def record_points(self, employee_id: str, action: str, points: int,
                      created_at: str | None = None) -> None:
        """
        تسجيل حركة نقاط + تحديث رصيد الموظف.

        current_points عمود denormalized (القسم 9 من الـ spec) — أي حركة يجب أن
        تحدّث السجل والرصيد معًا. في PostgreSQL هذه transaction واحدة أو trigger.
        """
        df = self.get_points()
        row = pd.DataFrame([{
            "id": self._next_id(df),
            "employee_id": employee_id,
            "action": action,
            "points": int(points),
            "created_at": created_at or _now(),
        }])
        self._write("employee_points", pd.concat([df, row], ignore_index=True))
        self._adjust_balance(employee_id, int(points))

    def _adjust_balance(self, employee_id: str, delta: int) -> None:
        """
        تعديل current_points. المستوى (current_level) يرتفع فقط ولا ينزل:
        الدروع إنجاز دائم، وصرف النقاط في المتجر لا يسحب درعًا سبق كسبه.
        """
        emp = self.get_employees()
        mask = emp["employee_id"] == employee_id
        if not mask.any():
            raise KeyError(f"موظف غير موجود: {employee_id}")

        new_balance = int(emp.loc[mask, "current_points"].iloc[0]) + delta
        emp.loc[mask, "current_points"] = new_balance

        # استيراد محلي لتفادي دائرة الاستيراد (logic تستورد data_layer)
        from logic import LEVEL_ORDER, level_from_points

        current = str(emp.loc[mask, "current_level"].iloc[0])
        earned = level_from_points(new_balance)
        if LEVEL_ORDER.index(earned) > LEVEL_ORDER.index(current):
            emp.loc[mask, "current_level"] = earned

        self._write("employees", emp)

    def award_badge(self, employee_id: str, badge_name: str,
                    awarded_at: str | None = None) -> None:
        df = self.get_badges()
        row = pd.DataFrame([{
            "id": self._next_id(df),
            "employee_id": employee_id,
            "badge_name": badge_name,
            "awarded_at": awarded_at or _now(),
        }])
        self._write("employee_badges", pd.concat([df, row], ignore_index=True))

    def send_notification(self, employee_id: str, message: str,
                          channel: str = "slack") -> None:
        """محاكاة Slack/WhatsApp API — في الإنتاج تصبح استدعاء HTTP فعلي."""
        df = self.get_notifications()
        row = pd.DataFrame([{
            "id": self._next_id(df),
            "employee_id": employee_id,
            "message": message,
            "channel": channel,
            "sent_at": _now(),
        }])
        self._write("notifications", pd.concat([df, row], ignore_index=True))

    def create_reward_request(self, employee_id: str, reward_id: str,
                              reward_name: str, points_cost: int) -> None:
        df = self.get_reward_requests()
        row = pd.DataFrame([{
            "id": self._next_id(df),
            "employee_id": employee_id,
            "reward_id": reward_id,
            "reward_name": reward_name,
            "points_cost": int(points_cost),
            "status": "pending",
            "requested_at": _now(),
            "resolved_at": "",
        }])
        self._write("reward_requests", pd.concat([df, row], ignore_index=True))

    def resolve_reward_request(self, request_id: int, status: str) -> None:
        df = self.get_reward_requests()
        mask = df["id"] == request_id
        if not mask.any():
            raise KeyError(f"طلب غير موجود: {request_id}")
        df.loc[mask, "status"] = status
        df.loc[mask, "resolved_at"] = _now()
        self._write("reward_requests", df)

    # ---------------- حالة الـ Sync ----------------

    def get_synced_task_ids(self) -> set[str]:
        if not os.path.exists(SYNC_STATE_FILE):
            return set()
        try:
            with open(SYNC_STATE_FILE, encoding="utf-8") as f:
                return set(json.load(f).get("synced_task_ids", []))
        except (json.JSONDecodeError, OSError):
            return set()

    def mark_task_synced(self, task_id: str) -> None:
        ids = self.get_synced_task_ids()
        ids.add(task_id)
        with open(SYNC_STATE_FILE, "w", encoding="utf-8") as f:
            json.dump({"synced_task_ids": sorted(ids)}, f, indent=2)

    def reset_sync_state(self) -> None:
        """للعرض فقط: يسمح بإعادة تشغيل الـ Sync من الصفر."""
        if os.path.exists(SYNC_STATE_FILE):
            os.remove(SYNC_STATE_FILE)


def get_repository() -> Repository:
    """
    نقطة التبديل الوحيدة عند الانتقال للإنتاج:
        return SupabaseRepository(url=..., key=...)
    """
    return CSVRepository()