|
|
| from __future__ import annotations
|
|
|
| from io import BytesIO
|
| import re
|
|
|
| import pandas as pd
|
| from pptx import Presentation
|
| from pptx.dml.color import RGBColor
|
|
|
|
|
| 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 _keywords(s: str) -> set[str]:
|
| s = _norm_ar(s)
|
| stop = {
|
| "الاداره", "اداره", "الادارة",
|
| "العامه", "عامه", "العامة",
|
| "بلديه", "بلدية",
|
| "امانه", "امانة",
|
| "مدينه", "مدينة", "المدينه",
|
| "منطقه", "منطقة", "المنطقه",
|
| }
|
| toks: list[str] = []
|
| for t in s.split():
|
| if not t:
|
| continue
|
| if t.startswith("لل") and len(t) > 2:
|
| t = t[2:]
|
| elif t.startswith("ال") and len(t) > 2:
|
| t = t[2:]
|
| elif t.startswith("ل") and len(t) > 2:
|
| t = t[1:]
|
| if t and t not in stop:
|
| toks.append(t)
|
| return set(toks)
|
|
|
|
|
| def _find_main_table(slide):
|
| tables = [sh.table for sh in slide.shapes if sh.has_table]
|
| if not tables:
|
| raise RuntimeError("لم يتم العثور على جدول في الشريحة.")
|
| main = max(tables, key=lambda t: len(t.columns))
|
| return main
|
|
|
|
|
| def _detect_main_columns(table):
|
| header = table.rows[0].cells
|
| m = dict(admin=None, open=None, reopen=None, near=None, late=None, other=None)
|
|
|
| for i, c in enumerate(header):
|
| txt = c.text
|
| tnorm = _norm_ar(txt)
|
|
|
| if any(k in tnorm for k in ["الادارات", "الاداره", "الادارة"]):
|
| m["admin"] = i
|
| if "البلاغات المفتوحه" in tnorm or "البلاغات المفتوحة" in txt:
|
| m["open"] = i
|
| if "اعادة فتح" in tnorm or "المعاد فتحها" in tnorm:
|
| m["reopen"] = i
|
| if "قارب" in tnorm or "قارب على تجاوز" in tnorm:
|
| m["near"] = i
|
| if "المتأخره" in tnorm or "المتأخرة" in txt or "تجاوز sla" in tnorm:
|
| m["late"] = i
|
| if "مصادر اخرى" in tnorm or "مصادر أخرى" in txt:
|
| m["other"] = i
|
|
|
| if m["admin"] is None:
|
| m["admin"] = 0
|
|
|
| return m
|
|
|
|
|
| def _make_shape_text_white(shape) -> None:
|
| if not shape.has_text_frame:
|
| return
|
| for p in shape.text_frame.paragraphs:
|
| for run in p.runs:
|
| run.font.color.rgb = RGBColor(255, 255, 255)
|
|
|
|
|
| def _make_cell_text_white(cell) -> None:
|
| if not cell.text_frame:
|
| return
|
| for p in cell.text_frame.paragraphs:
|
| for run in p.runs:
|
| run.font.color.rgb = RGBColor(255, 255, 255)
|
|
|
|
|
| def _fill_date_placeholder(prs: Presentation, closed_dt: pd.Timestamp) -> None:
|
| weekday_map = {
|
| 0: "الاثنين",
|
| 1: "الثلاثاء",
|
| 2: "الأربعاء",
|
| 3: "الخميس",
|
| 4: "الجمعة",
|
| 5: "السبت",
|
| 6: "الأحد",
|
| }
|
| weekday_ar = weekday_map[closed_dt.weekday()]
|
| date_str = closed_dt.strftime("%Y-%m-%d")
|
| full_text = f"التحديث اليومي {weekday_ar} {date_str}"
|
|
|
| for slide in prs.slides:
|
| for shape in slide.shapes:
|
| if not shape.has_text_frame:
|
| continue
|
| txt = shape.text
|
| if "{{DATE}}" not in txt and "{DATE}" not in txt:
|
| continue
|
| txt = txt.replace("{{DATE}}", full_text).replace("{DATE}", full_text)
|
| shape.text = txt
|
| _make_shape_text_white(shape)
|
|
|
|
|
| def _fill_total_placeholders(
|
| prs: Presentation,
|
| open_total_all: int,
|
| near_total_all: int,
|
| late_total_all: int,
|
| other_total_all: int,
|
| ) -> None:
|
| repl = {
|
| "{OPEN_TOTAL}": str(open_total_all),
|
| "{{OPEN_TOTAL}}": str(open_total_all),
|
| "{NEAR_SLA_TOTAL}": str(near_total_all),
|
| "{{NEAR_SLA_TOTAL}}": str(near_total_all),
|
| "{LATE_TOTAL}": str(late_total_all),
|
| "{{LATE_TOTAL}}": str(late_total_all),
|
| "{OTHER_TOTAL}": str(other_total_all),
|
| "{{OTHER_TOTAL}}": str(other_total_all),
|
| }
|
|
|
| for slide in prs.slides:
|
| for shape in slide.shapes:
|
| if not shape.has_text_frame:
|
| continue
|
| text = shape.text
|
| changed = False
|
| for token, val in repl.items():
|
| if token in text:
|
| text = text.replace(token, val)
|
| changed = True
|
| if changed:
|
| shape.text = text
|
|
|
|
|
| def _find_reopen_column(df: pd.DataFrame) -> str | None:
|
| for col in df.columns:
|
| s = _norm_ar(str(col))
|
| if ("معلق" in s or "مغلق" in s) and ("فتح" in s):
|
| return col
|
| return None
|
|
|
|
|
| def _find_sla_columns(df: pd.DataFrame):
|
| near_col = None
|
| late_col = None
|
| for col in df.columns:
|
| s = _norm_ar(str(col))
|
| if "قارب" in s:
|
| near_col = col
|
| if "تجاوز" in s:
|
| late_col = col
|
| return near_col, late_col
|
|
|
|
|
| def _match_admin_indices(admin_in_slide: str, all_admins: list[str]) -> list[str]:
|
| s = _norm_ar(admin_in_slide)
|
| kw_s = _keywords(s)
|
|
|
| matches: list[str] = []
|
|
|
| is_muni = ("بلديه" in s or "بلدية" in s)
|
| is_south = "جنوب" in s
|
| is_north = "شمال" in s
|
| is_center = "وسط" in s
|
|
|
| is_main_clean = (
|
| ("النظافه" in s or "النظافة" in s)
|
| and ("اداره" in s or "الاداره" in s or "الادارة" in s)
|
| and ("عامه" in s or "العامه" in s or "العامة" in s)
|
| )
|
|
|
| is_main_projects = (
|
| ("مشاريع" in s)
|
| and ("اداره" in s or "الاداره" in s or "الادارة" in s)
|
| and ("عامه" in s or "العامه" in s or "العامة" in s)
|
| )
|
|
|
| for name in all_admins:
|
| n = _norm_ar(name)
|
| kw_n = _keywords(n)
|
|
|
| if is_muni and (is_south or is_north or is_center):
|
| if is_south and "جنوب" in n and (
|
| "تعمير" in n or ("رقابه" in n and "الخدمات" in n)
|
| ):
|
| matches.append(name)
|
| continue
|
|
|
| if is_north and "شمال" in n and (
|
| "تعمير" in n or ("رقابه" in n and "الخدمات" in n)
|
| ):
|
| matches.append(name)
|
| continue
|
|
|
| if is_center and "وسط" in n and (
|
| "تعمير" in n or ("رقابه" in n and "الخدمات" in n)
|
| ):
|
| matches.append(name)
|
| continue
|
|
|
| continue
|
|
|
| if is_main_clean:
|
| if "النظافه" in n or "النظافة" in n:
|
| matches.append(name)
|
| continue
|
|
|
| if is_main_projects:
|
| if ("مشاريع" in n) and ("تنسيق" not in n) and (
|
| "مدينة" in n or "المدينه" in n):
|
| matches.append(name)
|
| continue
|
|
|
|
|
| if is_main_projects:
|
| if "مشاريع" in n:
|
| matches.append(name)
|
| continue
|
|
|
| if ("تشغيل" in s and "صيان" in s):
|
| if ("تشغيل" in n and "صيان" in n) or ("اناره" in n) or ("انارة" in n):
|
| matches.append(name)
|
| continue
|
|
|
| if ("الاصحاح" in s and "البيئي" in s):
|
| if "الاصحاح" in n and "البيئي" in n:
|
| matches.append(name)
|
| continue
|
|
|
| if n == s:
|
| matches.append(name)
|
| continue
|
|
|
| if kw_n == kw_s or kw_n.issubset(kw_s) or kw_s.issubset(kw_n):
|
| matches.append(name)
|
| continue
|
|
|
| seen = set()
|
| ordered = []
|
| for name in all_admins:
|
| if name in matches and name not in seen:
|
| ordered.append(name)
|
| seen.add(name)
|
| return ordered
|
|
|
|
|
| def _aggregate_for_admin(admin_in_slide: str, df: pd.DataFrame) -> pd.Series:
|
| if df is None or df.empty:
|
| return pd.Series(0, index=df.columns if hasattr(df, "columns") else [])
|
|
|
| df = df.copy()
|
| df.index = df.index.astype(str)
|
| if "الإجمالي الكلي" in df.index:
|
| df = df.drop(index="الإجمالي الكلي")
|
|
|
| all_admins = list(df.index)
|
| idxs = _match_admin_indices(admin_in_slide, all_admins)
|
| if not idxs:
|
| return pd.Series(0, index=df.columns)
|
|
|
| sub = df.loc[idxs]
|
| if isinstance(sub, pd.Series):
|
| return sub
|
| return sub.sum(axis=0)
|
|
|
|
|
| def _fill_main_table(
|
| table,
|
| p_open: pd.DataFrame,
|
| p_sla: pd.DataFrame,
|
| p_other: pd.DataFrame | None,
|
| ):
|
| colmap = _detect_main_columns(table)
|
|
|
| reopen_col = _find_reopen_column(p_open)
|
| near_col, late_col = _find_sla_columns(p_sla)
|
|
|
| total_open = total_reopen = total_near = total_late = total_other = 0
|
| total_row_idx = None
|
|
|
| n_rows = len(table.rows)
|
|
|
| for r in range(1, n_rows):
|
| cells = table.rows[r].cells
|
| admin_txt = cells[colmap["admin"]].text.strip()
|
| norm_admin = _norm_ar(admin_txt)
|
|
|
| if not admin_txt:
|
| continue
|
|
|
| if "الاجمالي" in norm_admin:
|
| total_row_idx = r
|
| continue
|
|
|
| vals_open = _aggregate_for_admin(admin_txt, p_open)
|
| if reopen_col is not None:
|
| reopen_val = int(vals_open.get(reopen_col, 0))
|
| else:
|
| reopen_val = 0
|
| open_val = int(vals_open.sum() - reopen_val)
|
|
|
| vals_sla = _aggregate_for_admin(admin_txt, p_sla)
|
| near_val = int(vals_sla.get(near_col, 0)) if near_col else 0
|
| late_val = int(vals_sla.get(late_col, 0)) if late_col else 0
|
|
|
| if p_other is not None and not p_other.empty:
|
| vals_other = _aggregate_for_admin(admin_txt, p_other)
|
| other_val = int(vals_other.sum())
|
| else:
|
| other_val = 0
|
|
|
| if colmap["open"] is not None:
|
| cells[colmap["open"]].text = str(open_val)
|
| if colmap["reopen"] is not None:
|
| cells[colmap["reopen"]].text = str(reopen_val)
|
| if colmap["near"] is not None:
|
| cells[colmap["near"]].text = str(near_val)
|
| if colmap["late"] is not None:
|
| cells[colmap["late"]].text = str(late_val)
|
| if colmap["other"] is not None:
|
| cells[colmap["other"]].text = str(other_val)
|
|
|
| total_open += open_val
|
| total_reopen += reopen_val
|
| total_near += near_val
|
| total_late += late_val
|
| total_other += other_val
|
|
|
| if total_row_idx is not None:
|
| cells = table.rows[total_row_idx].cells
|
| if colmap["open"] is not None:
|
| cells[colmap["open"]].text = str(total_open)
|
| _make_cell_text_white(cells[colmap["open"]])
|
| if colmap["reopen"] is not None:
|
| cells[colmap["reopen"]].text = str(total_reopen)
|
| _make_cell_text_white(cells[colmap["reopen"]])
|
| if colmap["near"] is not None:
|
| cells[colmap["near"]].text = str(total_near)
|
| _make_cell_text_white(cells[colmap["near"]])
|
| if colmap["late"] is not None:
|
| cells[colmap["late"]].text = str(total_late)
|
| _make_cell_text_white(cells[colmap["late"]])
|
| if colmap["other"] is not None:
|
| cells[colmap["other"]].text = str(total_other)
|
| _make_cell_text_white(cells[colmap["other"]])
|
|
|
| return total_open, total_near, total_late, total_other
|
|
|
|
|
| def _fill_side_tables(
|
| slide,
|
| main_table,
|
| p_open: pd.DataFrame,
|
| p_sla: pd.DataFrame,
|
| p_other: pd.DataFrame | None,
|
| ):
|
| reopen_col = _find_reopen_column(p_open)
|
| near_col, late_col = _find_sla_columns(p_sla)
|
|
|
| for shape in slide.shapes:
|
| if not shape.has_table:
|
| continue
|
| table = shape.table
|
| if table is main_table:
|
| continue
|
|
|
| colmap = _detect_main_columns(table)
|
| admin_idx = colmap.get("admin")
|
| if admin_idx is None:
|
| continue
|
|
|
| n_rows = len(table.rows)
|
| for r in range(1, n_rows):
|
| cells = table.rows[r].cells
|
| admin_txt = cells[admin_idx].text.strip()
|
| if not admin_txt:
|
| continue
|
| if "الاجمالي" in _norm_ar(admin_txt):
|
| continue
|
|
|
| vals_open = _aggregate_for_admin(admin_txt, p_open)
|
| if reopen_col is not None:
|
| reopen_val = int(vals_open.get(reopen_col, 0))
|
| else:
|
| reopen_val = 0
|
| open_val = int(vals_open.sum() - reopen_val)
|
|
|
| vals_sla = _aggregate_for_admin(admin_txt, p_sla)
|
| near_val = int(vals_sla.get(near_col, 0)) if near_col else 0
|
| late_val = int(vals_sla.get(late_col, 0)) if late_col else 0
|
|
|
| if p_other is not None and not p_other.empty:
|
| vals_other = _aggregate_for_admin(admin_txt, p_other)
|
| other_val = int(vals_other.sum())
|
| else:
|
| other_val = 0
|
|
|
| if colmap["open"] is not None:
|
| cells[colmap["open"]].text = str(open_val)
|
| if colmap["reopen"] is not None:
|
| cells[colmap["reopen"]].text = str(reopen_val)
|
| if colmap["near"] is not None:
|
| cells[colmap["near"]].text = str(near_val)
|
| if colmap["late"] is not None:
|
| cells[colmap["late"]].text = str(late_val)
|
| if colmap["other"] is not None:
|
| cells[colmap["other"]].text = str(other_val)
|
|
|
|
|
| _CARD_PATTERN = re.compile(r"\{CARD_(OPEN|NEAR|LATE|OTHER)([^}]*)\}", re.DOTALL)
|
|
|
|
|
| def _fill_left_cards(
|
| prs: Presentation,
|
| p_open: pd.DataFrame,
|
| p_sla: pd.DataFrame,
|
| p_other: pd.DataFrame | None,
|
| ):
|
| reopen_col = _find_reopen_column(p_open)
|
| near_col, late_col = _find_sla_columns(p_sla)
|
|
|
| cache: dict[str, dict[str, int]] = {}
|
|
|
| def get_metrics(admin_name: str) -> dict[str, int]:
|
| admin_name = admin_name.strip()
|
| if not admin_name:
|
| return {"open": 0, "near": 0, "late": 0, "other": 0}
|
|
|
| if admin_name in cache:
|
| return cache[admin_name]
|
|
|
| vals_open = _aggregate_for_admin(admin_name, p_open)
|
| if reopen_col is not None:
|
| reopen_val = int(vals_open.get(reopen_col, 0))
|
| else:
|
| reopen_val = 0
|
| open_val = int(vals_open.sum() - reopen_val)
|
|
|
| vals_sla = _aggregate_for_admin(admin_name, p_sla)
|
| near_val = int(vals_sla.get(near_col, 0)) if near_col else 0
|
| late_val = int(vals_sla.get(late_col, 0)) if late_col else 0
|
|
|
| if p_other is not None and not p_other.empty:
|
| vals_other = _aggregate_for_admin(admin_name, p_other)
|
| other_val = int(vals_other.sum())
|
| else:
|
| other_val = 0
|
|
|
| metrics = {
|
| "open": open_val,
|
| "near": near_val,
|
| "late": late_val,
|
| "other": other_val,
|
| }
|
| cache[admin_name] = metrics
|
| return metrics
|
|
|
| from pptx.enum.text import PP_ALIGN
|
|
|
| for slide in prs.slides:
|
| for shape in slide.shapes:
|
| if not shape.has_text_frame:
|
| continue
|
| txt = shape.text
|
| if "CARD_" not in txt:
|
| continue
|
|
|
| def repl(match: re.Match) -> str:
|
| kind = match.group(1).upper()
|
| rest = match.group(2) or ""
|
|
|
| admin = rest.replace("\n", " ")
|
| admin = admin.replace(":", " ").replace("ـ", " ").replace("-", " ")
|
| admin = admin.strip()
|
|
|
| metrics = get_metrics(admin)
|
| key_map = {
|
| "OPEN": "open",
|
| "NEAR": "near",
|
| "LATE": "late",
|
| "OTHER": "other",
|
| }
|
| return str(metrics.get(key_map[kind], 0))
|
|
|
| new_txt = _CARD_PATTERN.sub(repl, txt)
|
| if new_txt != txt:
|
| shape.text = new_txt
|
| tf = shape.text_frame
|
| for p in tf.paragraphs:
|
| p.alignment = PP_ALIGN.CENTER
|
| for run in p.runs:
|
| run.font.color.rgb = RGBColor(255, 255, 255)
|
|
|
|
|
| def fill_ppt(
|
| template_path: str,
|
| p_open: pd.DataFrame,
|
| p_sla: pd.DataFrame,
|
| p_other: pd.DataFrame | None,
|
| closed_dt: pd.Timestamp,
|
| ) -> BytesIO:
|
| prs = Presentation(template_path)
|
| slide = prs.slides[0]
|
|
|
| main_table = _find_main_table(slide)
|
|
|
| open_total_all, near_total_all, late_total_all, other_total_all = _fill_main_table(
|
| main_table, p_open, p_sla, p_other
|
| )
|
|
|
| _fill_side_tables(slide, main_table, p_open, p_sla, p_other)
|
|
|
| _fill_left_cards(prs, p_open, p_sla, p_other)
|
|
|
| _fill_date_placeholder(prs, closed_dt)
|
|
|
| _fill_total_placeholders(
|
| prs,
|
| open_total_all=open_total_all,
|
| near_total_all=near_total_all,
|
| late_total_all=late_total_all,
|
| other_total_all=other_total_all,
|
| )
|
|
|
| out = BytesIO()
|
| prs.save(out)
|
| out.seek(0)
|
| return out
|
|
|