# -*- coding: utf-8 -*- from __future__ import annotations import re import importlib.util import tempfile from io import BytesIO import numpy as np import pandas as pd COL_ID = "رقم البلاغ" COL_ADMIN = "الإدارة" COL_STATUS = "حالة البلاغ في النظام" COL_SOURCE = "مصدر البلاغ" COL_CREATED = "تاريخ الانشاء" COL_CLOSED = "تاريخ الاغلاق" COL_ELAPSED = "الوقت المنقضي" COL_HOURS = "الساعات" COL_SLA = "التوصيف" STATUS_CANON = [ "انتظار الاستجابة - مقاول", "انتظار الاستجابة - مراقب", "انتظار الاستجابة - مشرف", "قيد التنفيذ - مراقب", "قيد التنفيذ - مقاول", "معلق - اعادة فتح", ] SLA_ORDER = ["لم تتجاوز", "قارب على تجاوز SLA", "تجاوز SLA"] ALLOWED_SOURCES = {"Urbi", "تطبيق بلدي", "توكلنا", "مراكز الاتصال"} def _norm_ar(s: str) -> str: if not isinstance(s, str): return "" s = s.strip() s = ( s.replace("أ", "ا") .replace("إ", "ا") .replace("آ", "ا") .replace("ى", "ي") .replace("ة", "ه") ) s = re.sub(r"\s+", " ", s) s = s.replace(" -", "-").replace("- ", "-") return s def canon_status(raw: str) -> str: s = _norm_ar(raw) if "انتظار" in s and "استجابه" in s: if "مقاول" in s: return "انتظار الاستجابة - مقاول" if "مراقب" in s: return "انتظار الاستجابة - مراقب" if "مشرف" in s: return "انتظار الاستجابة - مشرف" if ("قيد" in s and "التنفيذ" in s) or ("التنفيذ" in s and ("قيد" in s or "جاري" in s)): if "مراقب" in s: return "قيد التنفيذ - مراقب" if "مقاول" in s: return "قيد التنفيذ - مقاول" if "معلق" in s and ("اعاده فتح" in s or "اعادة فتح" in s or "فتح" in s): return "معلق - اعادة فتح" return raw def canon_source(x: str) -> str: s = _norm_ar(x) if "urbi" in s.lower(): return "Urbi" if "بلدي" in s: return "تطبيق بلدي" if "توكلنا" in s: return "توكلنا" if ("مراكز" in s and "اتصال" in s) or ("مركز" in s and "اتصال" in s): return "مراكز الاتصال" return x def read_excel_safe(uploaded): data = uploaded.read() with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as tmp: tmp.write(data) path = tmp.name engines = [] if importlib.util.find_spec("calamine") or importlib.util.find_spec("python_calamine"): engines.append("calamine") engines += [None, "openpyxl"] last = None for eng in engines: try: return pd.read_excel(path, sheet_name=0, engine=eng) except Exception as e: last = e continue raise RuntimeError(f"تعذر قراءة الملف: {last}") def parse_created_col(s): a = pd.to_datetime(s, errors="coerce", dayfirst=False) b = pd.to_datetime(s, errors="coerce", dayfirst=True) if b.notna().sum() > a.notna().sum(): return b return a def preprocess(df: pd.DataFrame, closed_ts: pd.Timestamp): idx_created = df.columns.get_loc(COL_CREATED) drop_after = [ df.columns[i] for i in range(idx_created + 1, min(idx_created + 4, len(df.columns))) ] df = df.drop(columns=drop_after, errors="ignore").copy() sidx = df.columns.get_loc(COL_SOURCE) right_cols = list(df.columns[sidx + 1:]) if right_cols: df.drop(columns=right_cols, inplace=True) NEW_CLASS_COL = "التصنيف الجديد" BAD_CLASS = "السيارات التالفة" rows_before = len(df) if NEW_CLASS_COL in df.columns: df = df[ df[NEW_CLASS_COL] .astype(str) .map(_norm_ar) != _norm_ar(BAD_CLASS) ].copy() rows_after = len(df) deleted = rows_before - rows_after df["_deleted_bad_class"] = deleted df["_rows_before_filter"] = rows_before df["_rows_after_filter"] = rows_after if COL_CLOSED not in df.columns: df.insert(idx_created + 1, COL_CLOSED, pd.NaT) if COL_ELAPSED not in df.columns: df.insert(idx_created + 2, COL_ELAPSED, pd.NaT) if COL_HOURS not in df.columns: df.insert(idx_created + 3, COL_HOURS, pd.NA) df[COL_CREATED] = parse_created_col(df[COL_CREATED]) df[COL_CLOSED] = closed_ts df[COL_ELAPSED] = df[COL_CLOSED] - df[COL_CREATED] total_seconds = df[COL_ELAPSED].dt.total_seconds() hours_floor = np.floor_divide(total_seconds.fillna(0).astype("int64"), 3600) df[COL_HOURS] = pd.Series(hours_floor, index=df.index).astype("Int64") df["_status_canon"] = df[COL_STATUS].astype(str).map(canon_status) df = df[df["_status_canon"].isin(STATUS_CANON)].copy() df["_status_canon"] = pd.Categorical( df["_status_canon"], categories=STATUS_CANON, ordered=True ) df[COL_SOURCE] = df[COL_SOURCE].astype(str).map(canon_source) df = df[df[COL_SOURCE].isin(ALLOWED_SOURCES)].copy() h = df[COL_HOURS].astype("float") conds = [h < 72, (h >= 72) & (h <= 95)] choices = ["لم تتجاوز", "قارب على تجاوز SLA"] df[COL_SLA] = np.select(conds, choices, default="تجاوز SLA") df[COL_SLA] = pd.Categorical(df[COL_SLA], categories=SLA_ORDER, ordered=True) return df def pivots(df_proc: pd.DataFrame): p_open = ( df_proc.groupby([COL_ADMIN, "_status_canon"])[COL_ID] .count() .unstack("_status_canon", fill_value=0) .reindex(columns=STATUS_CANON, fill_value=0) ) p_open.loc["الإجمالي الكلي"] = p_open.sum(axis=0) p_open.columns.name = None p_sla = ( df_proc.groupby([COL_ADMIN, COL_SLA])[COL_ID] .count() .unstack(COL_SLA, fill_value=0) .reindex(columns=SLA_ORDER, fill_value=0) ) p_sla.loc["الإجمالي الكلي"] = p_sla.sum(axis=0) p_sla.columns.name = None return p_open, p_sla def build(xls: pd.DataFrame, closed_ts: pd.Timestamp) -> BytesIO: df_all = preprocess(xls.copy(), closed_ts) df_ar = df_all[df_all[COL_SOURCE] != "Urbi"].copy() p_open_ar, p_sla_ar = pivots(df_ar) df_urbi = df_all[df_all[COL_SOURCE] == "Urbi"].copy() if df_urbi.empty: p_urbi = pd.DataFrame({"ملاحظة": ["لا توجد بلاغات Urbi"]}) urbi_index = False else: p_urbi = ( df_urbi.groupby([COL_ADMIN, COL_SOURCE])[COL_ID] .count() .unstack(COL_SOURCE, fill_value=0) ) p_urbi.loc["الإجمالي الكلي"] = p_urbi.sum(axis=0) p_urbi.columns.name = None urbi_index = True out = BytesIO() with pd.ExcelWriter(out, engine="openpyxl") as w: p_open_ar.to_excel(w, sheet_name="١- المفتوحة والمعاد فتحها") p_sla_ar.to_excel(w, sheet_name="٢- التوصيف") p_urbi.to_excel(w, sheet_name="٣- مصادر أخرى", index=urbi_index) out.seek(0) return out def get_pivots_for_ppt(xls: pd.DataFrame, closed_ts: pd.Timestamp): df_all = preprocess(xls.copy(), closed_ts) df_ar = df_all[df_all[COL_SOURCE] != "Urbi"].copy() p_open_ar, p_sla_ar = pivots(df_ar) df_urbi = df_all[df_all[COL_SOURCE] == "Urbi"].copy() if df_urbi.empty: p_urbi = pd.DataFrame() else: p_urbi = ( df_urbi.groupby([COL_ADMIN, COL_SOURCE])[COL_ID] .count() .unstack(COL_SOURCE, fill_value=0) ) p_urbi.loc["الإجمالي الكلي"] = p_urbi.sum(axis=0) p_urbi.columns.name = None return p_open_ar, p_sla_ar, p_urbi