diff --git a/.env b/.env
new file mode 100644
index 0000000..9e55395
--- /dev/null
+++ b/.env
@@ -0,0 +1,36 @@
+# Option A: single URL (recommended)
+DATABASE_URL=postgresql+psycopg2://admin:Package%40123%23@31.97.228.132:5432/pgworkolik
+DB_ECHO=false
+
+# App Settings
+APP_SECRET=80khAhsZiYbCXB_mehHfGZ-oAhmU9jxPp8AR11AUuvWz-wpUgIXliqVOfNihYIhV
+
+
+# Option B: parts (no DATABASE_URL needed if you set all parts)
+DB_HOST=31.97.228.132
+DB_PORT=5432
+DB_NAME=pgworkolik
+DB_USER=admin
+DB_PASSWORD=Package@123#
+DB_ECHO=false
+
+# App Settings
+APP_SECRET=80khAhsZiYbCXB_mehHfGZ-oAhmU9jxPp8AR11AUuvWz-wpUgIXliqVOfNihYIhV
+BACKGROUND_IMAGE_URL=assets/bg.jpg
+
+
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_USER=workolik360@gmail.com
+SMTP_PASSWORD=nggo euhg chus yyyw
+SMTP_USE_TLS=true
+SMTP_FROM_EMAIL=workolik360@gmail.com
+SMTP_FROM_NAME=Workolik Team
+
+REPORT_RECIPIENTS=Darshan@caman.au,darshan@caman.com.au,workolik360@gmail.com,ColinA@caman.au,ColinA@caman.com.au,tabs@tuckerfresh.com.au,jay@tuckerfresh.com.au,sanjay@tuckerfresh.com.au,veer@tuckerfresh.com.au
+
+
+BCC_RECIPIENTS=fazulilahi@gmail.com
+
+# Darshan@caman.au,ColinA@caman.au,tabs@tuckerfresh.com.au,
+# jay@tuckerfresh.com.au
\ No newline at end of file
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000..2af5b77
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,13 @@
+[theme]
+base = "light"
+primaryColor = "#4F46E5"
+backgroundColor = "#F8FAFC"
+secondaryBackgroundColor = "#FFFFFF"
+textColor = "#0F172A"
+font = "sans serif"
+
+[client]
+showSidebarNavigation = false
+
+[server]
+fileWatcherType = "poll"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cc53aad
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,22 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1 \
+ STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
+
+WORKDIR /app
+
+# System deps (timezone data for TZ support)
+RUN apt-get update && apt-get install -y --no-install-recommends tzdata && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt /app/
+RUN pip install -r requirements.txt
+
+COPY . /app
+
+EXPOSE 8501
+
+CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
+
+
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..586b136
--- /dev/null
+++ b/app.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+import os
+import streamlit as st
+from app_core.config.settings import AppSettings
+from app_core.services.auth_service import AuthService
+from app_core.ui.auth_ui import render_auth_card
+from app_core.ui.layout import apply_global_style, render_topbar, render_header, render_sidebar_logo
+
+settings = AppSettings() # loads env
+
+# App config
+st.set_page_config(
+ page_title="Workolik",
+ page_icon="assets/workolik.png",
+ layout="wide",
+ initial_sidebar_state="expanded",
+)
+
+apply_global_style(background_url=os.getenv("BACKGROUND_IMAGE_URL"))
+
+auth_service = AuthService()
+
+# Initialize session state
+if "auth_user" not in st.session_state:
+ st.session_state.auth_user = None
+
+# ? SIMPLE MENU (TEXT ONLY)
+menu = ["Analytics", "Data", "Mailer", "Mappings"]
+
+if st.session_state.auth_user is None:
+ render_topbar()
+ st.markdown('', unsafe_allow_html=True)
+ render_auth_card(auth_service)
+ st.stop()
+
+# Topbar
+render_topbar()
+
+# Dim background
+st.markdown("""
+
+""", unsafe_allow_html=True)
+
+with st.sidebar:
+ render_sidebar_logo()
+
+ st.markdown('
', unsafe_allow_html=True)
+
+# Routing
+if choice == "Analytics":
+ from pages.see_logs import render_page
+ render_page()
+
+elif choice == "Data":
+ from pages.see_payload import render_page
+ render_page()
+
+elif choice == "Mailer":
+ from pages.mailer import render_page
+ render_page()
+
+elif choice == "Mappings":
+ from pages.mappings import render_page
+ render_page()
\ No newline at end of file
diff --git a/app_core/__init__.py b/app_core/__init__.py
new file mode 100644
index 0000000..e481a53
--- /dev/null
+++ b/app_core/__init__.py
@@ -0,0 +1 @@
+# App Core Package
diff --git a/app_core/__pycache__/__init__.cpython-312.pyc b/app_core/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..bcf56b0
Binary files /dev/null and b/app_core/__pycache__/__init__.cpython-312.pyc differ
diff --git a/app_core/config/__init__.py b/app_core/config/__init__.py
new file mode 100644
index 0000000..d71355c
--- /dev/null
+++ b/app_core/config/__init__.py
@@ -0,0 +1 @@
+# Config Package
diff --git a/app_core/config/__pycache__/__init__.cpython-312.pyc b/app_core/config/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..a89a804
Binary files /dev/null and b/app_core/config/__pycache__/__init__.cpython-312.pyc differ
diff --git a/app_core/config/__pycache__/settings.cpython-312.pyc b/app_core/config/__pycache__/settings.cpython-312.pyc
new file mode 100644
index 0000000..694e777
Binary files /dev/null and b/app_core/config/__pycache__/settings.cpython-312.pyc differ
diff --git a/app_core/config/settings.py b/app_core/config/settings.py
new file mode 100644
index 0000000..07717c4
--- /dev/null
+++ b/app_core/config/settings.py
@@ -0,0 +1,78 @@
+import os
+from dotenv import load_dotenv
+from pydantic import BaseModel
+
+# Load .env first (if present)
+load_dotenv(dotenv_path=".env", override=False)
+# Also load .env-example.txt as a fallback for local dev (does not override)
+load_dotenv(dotenv_path=".env-example.txt", override=False)
+
+class AppSettings(BaseModel):
+ # Raw pieces
+ db_host: str | None = os.getenv("DB_HOST")
+ db_port: str | None = os.getenv("DB_PORT")
+ db_name: str | None = os.getenv("DB_NAME")
+ db_user: str | None = os.getenv("DB_USER")
+ db_password: str | None = os.getenv("DB_PASSWORD")
+ db_echo: bool = os.getenv("DB_ECHO", "false").lower() == "true"
+
+ # Optional complete URL (takes precedence if set)
+ database_url_env: str | None = os.getenv("DATABASE_URL")
+
+ app_secret: str = os.getenv("APP_SECRET", "change_me")
+ background_image_url: str | None = os.getenv("BACKGROUND_IMAGE_URL")
+
+ # SMTP / Email settings
+ smtp_host: str | None = os.getenv("SMTP_HOST")
+ smtp_port: int | None = int(os.getenv("SMTP_PORT", "587"))
+ smtp_user: str | None = os.getenv("SMTP_USER")
+ smtp_password: str | None = os.getenv("SMTP_PASSWORD")
+ smtp_use_tls: bool = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
+ smtp_from_email: str | None = os.getenv("SMTP_FROM_EMAIL")
+ smtp_from_name: str = os.getenv("SMTP_FROM_NAME", "Workolik Team")
+
+ # Default recipients for automated reports (comma-separated)
+ report_recipients: str | None = os.getenv("REPORT_RECIPIENTS")
+
+ @property
+ def database_url(self) -> str:
+ if self.database_url_env:
+ # Normalize asyncpg to psycopg2 if needed
+ if self.database_url_env.startswith("postgresql+asyncpg://"):
+ return self.database_url_env.replace(
+ "postgresql+asyncpg://", "postgresql+psycopg2://", 1
+ )
+ return self.database_url_env
+ # Build from parts
+ if all([self.db_host, self.db_port, self.db_name, self.db_user, self.db_password]):
+ return (
+ f"postgresql+psycopg2://{self.db_user}:{self.db_password}"
+ f"@{self.db_host}:{self.db_port}/{self.db_name}"
+ )
+ # Fallback empty (will error at runtime if used)
+ return ""
+
+# Fixed mapping of stores to tenant IDs and division codes
+# Used by analytics and data pages to scope queries per store
+STORES = [
+ {"label": "Porters Liquor Claremont - PC", "code": "PC", "tenant_id": 1},
+ {"label": "Porters Iluka - IP", "code": "IP", "tenant_id": 2},
+ {"label": "Cellarbrations at Morris Place - ML", "code": "ML", "tenant_id": 3},
+ {"label": "Cellarbrations at Lynwood - CL", "code": "CL4", "tenant_id": 4},
+ {"label": "Cellarbrations at Nicholson Road - NL", "code": "NL", "tenant_id": 5},
+ {"label": "Cellarbrations at Treeby - CL ", "code": "CL6", "tenant_id": 6},
+ {"label": "The Bottle-O Rossmoyne - RC", "code": "RC", "tenant_id": 7},
+ {"label": "Porters Liquor Piara Waters - PL", "code": "PL", "tenant_id": 8},
+]
+
+# Helper map for quick lookups by code (supports variants like CL-4 → CL4)
+STORE_CODE_TO_TENANT_ID: dict[str, int] = {
+ "PC": 1,
+ "IP": 2,
+ "ML": 3,
+ "CL4": 4, "CL-4": 4, "CL_4": 4, "CL": 4, # default CL → 4
+ "NL": 5, "NL5": 5, "NL-5": 5,
+ "CL6": 6, "CL-6": 6, "CL_6": 6,
+ "RC": 7,
+ "PL": 8,
+}
\ No newline at end of file
diff --git a/app_core/db/__init__.py b/app_core/db/__init__.py
new file mode 100644
index 0000000..8a8cbf1
--- /dev/null
+++ b/app_core/db/__init__.py
@@ -0,0 +1 @@
+# Database Package
diff --git a/app_core/db/__pycache__/__init__.cpython-312.pyc b/app_core/db/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..f4dcb93
Binary files /dev/null and b/app_core/db/__pycache__/__init__.cpython-312.pyc differ
diff --git a/app_core/db/__pycache__/database.cpython-312.pyc b/app_core/db/__pycache__/database.cpython-312.pyc
new file mode 100644
index 0000000..eaf8856
Binary files /dev/null and b/app_core/db/__pycache__/database.cpython-312.pyc differ
diff --git a/app_core/db/__pycache__/models.cpython-312.pyc b/app_core/db/__pycache__/models.cpython-312.pyc
new file mode 100644
index 0000000..f1461c3
Binary files /dev/null and b/app_core/db/__pycache__/models.cpython-312.pyc differ
diff --git a/app_core/db/database.py b/app_core/db/database.py
new file mode 100644
index 0000000..4fe2261
--- /dev/null
+++ b/app_core/db/database.py
@@ -0,0 +1,21 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, declarative_base
+from app_core.config.settings import AppSettings
+
+settings = AppSettings()
+
+if not settings.database_url:
+ raise RuntimeError(
+ "Database configuration missing. Set DATABASE_URL or DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD in a .env file at the project root."
+ )
+
+engine = create_engine(settings.database_url, pool_pre_ping=True, future=True, echo=settings.db_echo)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
+Base = declarative_base()
+
+def get_db_session():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/app_core/db/models.py b/app_core/db/models.py
new file mode 100644
index 0000000..8d66ad6
--- /dev/null
+++ b/app_core/db/models.py
@@ -0,0 +1,36 @@
+from sqlalchemy import Column, Integer, String, DateTime, func, UniqueConstraint
+from .database import Base
+
+class User(Base):
+ __tablename__ = "workolik_users"
+ __table_args__ = (
+ UniqueConstraint("email", name="uq_workolik_users_email"),
+ )
+
+ id = Column(Integer, primary_key=True, index=True)
+ email = Column(String(255), nullable=False, unique=True, index=True)
+ password_hash = Column(String(255), nullable=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+
+class EmailLog(Base):
+ __tablename__ = "email_logs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ sent_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+ recipients = Column(String(1024), nullable=False)
+ subject = Column(String(255), nullable=False)
+ status = Column(String(50), nullable=False) # sent / failed
+ error = Column(String(1024))
+ date_for = Column(String(32), nullable=False)
+
+class TriumphDebtorMapping(Base):
+ __tablename__ = "triumph_debtor_mappings"
+
+ id = Column(Integer, primary_key=True, index=True)
+ code = Column(String(50))
+ name = Column(String(255))
+ dbmacc = Column(String(50))
+ outlet = Column(String(255))
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/app_core/services/__pycache__/auth_service.cpython-312.pyc b/app_core/services/__pycache__/auth_service.cpython-312.pyc
new file mode 100644
index 0000000..f6e3c01
Binary files /dev/null and b/app_core/services/__pycache__/auth_service.cpython-312.pyc differ
diff --git a/app_core/services/__pycache__/daily_report.cpython-312.pyc b/app_core/services/__pycache__/daily_report.cpython-312.pyc
new file mode 100644
index 0000000..124f6a9
Binary files /dev/null and b/app_core/services/__pycache__/daily_report.cpython-312.pyc differ
diff --git a/app_core/services/__pycache__/mailer_service.cpython-312.pyc b/app_core/services/__pycache__/mailer_service.cpython-312.pyc
new file mode 100644
index 0000000..c235111
Binary files /dev/null and b/app_core/services/__pycache__/mailer_service.cpython-312.pyc differ
diff --git a/app_core/services/__pycache__/mappings_service.cpython-312.pyc b/app_core/services/__pycache__/mappings_service.cpython-312.pyc
new file mode 100644
index 0000000..fdfcf9c
Binary files /dev/null and b/app_core/services/__pycache__/mappings_service.cpython-312.pyc differ
diff --git a/app_core/services/__pycache__/scheduler_service.cpython-312.pyc b/app_core/services/__pycache__/scheduler_service.cpython-312.pyc
new file mode 100644
index 0000000..2e5aef4
Binary files /dev/null and b/app_core/services/__pycache__/scheduler_service.cpython-312.pyc differ
diff --git a/app_core/services/auth_service.py b/app_core/services/auth_service.py
new file mode 100644
index 0000000..0447e28
--- /dev/null
+++ b/app_core/services/auth_service.py
@@ -0,0 +1,46 @@
+from typing import Optional
+from sqlalchemy.orm import Session
+from sqlalchemy import select
+import bcrypt
+from app_core.db.database import engine, Base, SessionLocal
+from app_core.db.models import User
+
+# Create tables on import
+Base.metadata.create_all(bind=engine)
+
+class AuthService:
+ def __init__(self) -> None:
+ self._session_factory = SessionLocal
+
+ def _hash_password(self, raw_password: str) -> str:
+ salt = bcrypt.gensalt()
+ return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
+
+ def _verify_password(self, raw_password: str, hashed: str) -> bool:
+ try:
+ return bcrypt.checkpw(raw_password.encode("utf-8"), hashed.encode("utf-8"))
+ except Exception:
+ return False
+
+ def signup(self, email: str, password: str) -> tuple[bool, str]:
+ email = email.strip().lower()
+ if not email or not password:
+ return False, "Email and password are required."
+ with self._session_factory() as db: # type: Session
+ exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
+ if exists:
+ return False, "Email already registered."
+ user = User(email=email, password_hash=self._hash_password(password))
+ db.add(user)
+ db.commit()
+ return True, "Account created. Please login."
+
+ def login(self, email: str, password: str) -> tuple[bool, Optional[dict], str]:
+ email = email.strip().lower()
+ if not email or not password:
+ return False, None, "Email and password are required."
+ with self._session_factory() as db: # type: Session
+ user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
+ if not user or not self._verify_password(password, user.password_hash):
+ return False, None, "Invalid credentials."
+ return True, {"id": user.id, "email": user.email}, "Login successful."
diff --git a/app_core/services/daily_report.py b/app_core/services/daily_report.py
new file mode 100644
index 0000000..de9cbe1
--- /dev/null
+++ b/app_core/services/daily_report.py
@@ -0,0 +1,53 @@
+import os
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+from app_core.config.settings import AppSettings
+from app_core.services.mailer_service import MailerService
+
+
+def main(for_date: str | None = None, force: bool = False) -> int:
+ settings = AppSettings()
+ service = MailerService(settings)
+
+ if for_date:
+ try:
+ chosen = datetime.strptime(for_date, "%Y-%m-%d").date()
+ except ValueError:
+ print(f"Invalid date format: {for_date}. Expected YYYY-MM-DD.")
+ return 1
+ else:
+ today_ist = datetime.now(ZoneInfo("Asia/Kolkata")).date()
+ chosen = service.select_report_date(preferred=today_ist)
+
+ if not chosen:
+ print("No data available to send.")
+ return 1
+
+ if not force and service.has_sent_for_date(str(chosen)):
+ print(f"Already sent for {chosen}; skipping.")
+ return 0
+
+ df = service.fetch_daily_rows(chosen)
+ if df.empty:
+ print("Selected date has no rows; nothing to send.")
+ return 0
+
+ row = df.iloc[0].to_dict()
+ html = service.build_email_html(row, df)
+
+ recipients_env = settings.report_recipients or os.getenv("REPORT_RECIPIENTS")
+ if not recipients_env:
+ print("REPORT_RECIPIENTS env var is empty. Set it to comma-separated emails.")
+ return 2
+ recipients = [r.strip() for r in recipients_env.split(',') if r.strip()]
+ ok, msg = service.send_email(recipients, subject=f"Daily Digest - {chosen}", html=html)
+ service.log_email(recipients, subject=f"Daily Digest - {chosen}", date_for=str(chosen), status="sent" if ok else "failed", error=None if ok else msg)
+ print("Sent" if ok else f"Failed: {msg}")
+ return 0 if ok else 3
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
diff --git a/app_core/services/mailer_service.py b/app_core/services/mailer_service.py
new file mode 100644
index 0000000..143fdf0
--- /dev/null
+++ b/app_core/services/mailer_service.py
@@ -0,0 +1,327 @@
+from __future__ import annotations
+
+import smtplib
+import sys
+import os
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from typing import List, Tuple
+from datetime import date
+from datetime import date
+import pandas as pd
+from sqlalchemy import text
+import streamlit as st
+
+# Add the project root to Python path
+project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+from app_core.config.settings import AppSettings, STORES
+from app_core.db.database import engine, SessionLocal
+from app_core.db.models import EmailLog
+
+
+class MailerService:
+ def __init__(self, settings: AppSettings | None = None) -> None:
+ self.settings = settings or AppSettings()
+
+ def fetch_daily_rows(self, report_date) -> pd.DataFrame:
+ sql = (
+ 'SELECT * FROM "tenantpostings" '
+ 'WHERE "created_at"::date = %(d)s '
+ 'ORDER BY "id" DESC '
+ 'LIMIT 10000'
+ )
+ with engine.connect() as conn:
+ df = pd.read_sql(sql, conn, params={"d": report_date})
+ return df
+
+ def select_report_date(self, preferred: date | None = None) -> date | None:
+ """Return preferred date if it has data; else most recent date with data; else None."""
+ with engine.connect() as conn:
+ dates_df = pd.read_sql(
+ 'SELECT "created_at"::date AS d, COUNT(*) AS c\n'
+ 'FROM "tenantpostings"\n'
+ 'GROUP BY d\n'
+ 'ORDER BY d DESC',
+ conn,
+ )
+ if dates_df.empty:
+ return None
+ # Normalize
+ if 'd' not in dates_df.columns:
+ return None
+ dates_df['d'] = pd.to_datetime(dates_df['d'], errors='coerce')
+ available = [d.date() for d in dates_df['d'].dropna().tolist()]
+ if preferred and preferred in available:
+ return preferred
+ return available[0] if available else None
+
+ def build_email_html(self, row: dict, df: pd.DataFrame | None = None) -> str:
+ outlet = row.get("outlet_name") or row.get("register_name") or "Outlet"
+ division = row.get("division_code") or "PC"
+ status = (row.get("triumph_status") or "Posted successfully").capitalize()
+ register_close_id = row.get("register_close_id", "—")
+ register_id = row.get("register_id", "—")
+
+ def lines_for(ptype: str) -> list[str]:
+ """Return formatted lines for all rows of a processing_type.
+ Example line: 3,616.19 (Event ID: 2904783)
+ """
+ if df is None or df.empty or 'processing_type' not in df.columns:
+ return []
+ sub = df[df['processing_type'].astype(str).str.upper() == ptype.upper()] if 'processing_type' in df.columns else pd.DataFrame()
+ if sub.empty:
+ return []
+
+ # De-duplicate by triumph_event to avoid double-counting retries
+ if 'triumph_event' in sub.columns:
+ sub = sub.sort_values(['triumph_event', 'id'], ascending=[True, False]).drop_duplicates(subset=['triumph_event'], keep='first')
+
+ result: list[str] = []
+ for _, r in sub.sort_values('id', ascending=False).iterrows():
+ amt = r.get('total_amount')
+ evt = r.get('triumph_event', '—')
+ try:
+ amt_str = f"{float(amt):,.2f}"
+ except Exception:
+ amt_str = str(amt) if amt is not None else '—'
+ result.append(f"{amt_str} (Event ID: {evt})")
+ return result
+
+ journal_lines = lines_for('JOURNAL')
+ bank_journal_lines = lines_for('BANKING_JOURNAL')
+ invoice_lines = lines_for('INVOICE')
+ receipt_lines = lines_for('RECEIPT')
+
+ # Optional: transaction summary by store (single table)
+ store_summary_table_html = ""
+ events_matrix_html = ""
+ if isinstance(df, pd.DataFrame) and not df.empty and ('tenant_id' in df.columns):
+ def summarize_for(store: dict) -> dict[str, str]:
+ sid = store.get('tenant_id')
+ name = store.get('label')
+ sub = df[df['tenant_id'] == sid]
+
+ # De-duplicate each processing type context within the store
+ if not sub.empty and 'triumph_event' in sub.columns and 'processing_type' in sub.columns:
+ # Filter out non-event rows before dedupe if necessary, but here we just dedupe everything with an event ID
+ # We keep the one with highest ID for each event
+ has_event = sub['triumph_event'].fillna('').astype(str).str.strip() != ''
+ sub_with_events = sub[has_event].sort_values(['processing_type', 'triumph_event', 'id'], ascending=[True, True, False]).drop_duplicates(subset=['processing_type', 'triumph_event'], keep='first')
+ sub_no_events = sub[~has_event]
+ sub = pd.concat([sub_with_events, sub_no_events]).sort_values('id', ascending=False)
+
+ def pick_total(kind: str) -> tuple[str, int]:
+ if sub.empty or 'processing_type' not in sub.columns:
+ return ("0.00", 0)
+ s = sub[sub['processing_type'].astype(str).str.upper() == kind]
+ if s.empty:
+ return ("0.00", 0)
+ try:
+ total = float(s['total_amount'].fillna(0).sum()) if 'total_amount' in s.columns else 0.0
+ except Exception:
+ total = 0.0
+ return (f"{total:,.2f}", len(s))
+ def has_rows(kind: str) -> bool:
+ if sub.empty or 'processing_type' not in sub.columns:
+ return False
+ s = sub[sub['processing_type'].astype(str).str.upper() == kind]
+ return not s.empty
+ def latest_event(kind: str) -> str:
+ if sub.empty or 'processing_type' not in sub.columns:
+ return "—"
+ s = sub[sub['processing_type'].astype(str).str.upper() == kind]
+ if s.empty:
+ return "—"
+ series = s.get('triumph_event') if 'triumph_event' in s.columns else None
+ if series is None or series.empty:
+ return "—"
+ try:
+ return str(series.dropna().astype(str).iloc[0])
+ except Exception:
+ return "—"
+ def latest_status_emoji(kind: str) -> str:
+ if sub.empty or 'processing_type' not in sub.columns:
+ return ""
+ s = sub[sub['processing_type'].astype(str).str.upper() == kind]
+ if s.empty:
+ return ""
+ status_series = s.get('triumph_status') if 'triumph_status' in s.columns else None
+ if status_series is None or status_series.empty:
+ return ""
+ try:
+ val = str(status_series.iloc[0]).strip().lower()
+ except Exception:
+ val = ""
+ if any(x in val for x in ["success", "ok", "completed", "done"]):
+ return " ✅"
+ if any(x in val for x in ["fail", "error", "invalid", "dead"]):
+ return " ❌"
+ if any(x in val for x in ["pending", "queue", "waiting", "processing"]):
+ return " ⚠️"
+ return ""
+ j_total, _ = pick_total('JOURNAL')
+ b_total, _ = pick_total('BANKING_JOURNAL')
+ i_total, _ = pick_total('INVOICE')
+ r_total, _ = pick_total('RECEIPT')
+ j_eid = latest_event('JOURNAL'); j_stat = latest_status_emoji('JOURNAL')
+ b_eid = latest_event('BANKING_JOURNAL'); b_stat = latest_status_emoji('BANKING_JOURNAL')
+ i_eid = latest_event('INVOICE'); i_stat = latest_status_emoji('INVOICE')
+ r_eid = latest_event('RECEIPT'); r_stat = latest_status_emoji('RECEIPT')
+ def render_cell(exists: bool, total: str, eid: str, stat: str, ptype: str = "") -> str:
+ if not exists:
+ return "Nill"
+
+ # For INVOICE and RECEIPT, show individual line items if multiple exist
+ if ptype.upper() in ['INVOICE', 'RECEIPT'] and sub is not None and not sub.empty:
+ type_sub = sub[sub['processing_type'].astype(str).str.upper() == ptype.upper()]
+ if len(type_sub) > 1: # Multiple transactions
+ individual_lines = []
+ for _, r in type_sub.sort_values('id', ascending=False).iterrows():
+ amt = r.get('total_amount')
+ evt = r.get('triumph_event', '—')
+ status_val = str(r.get('triumph_status', '')).strip().lower()
+ status_emoji = ""
+ if any(x in status_val for x in ["success", "ok", "completed", "done"]):
+ status_emoji = " ✅"
+ elif any(x in status_val for x in ["fail", "error", "invalid", "dead"]):
+ status_emoji = " ❌"
+ elif any(x in status_val for x in ["pending", "queue", "waiting", "processing"]):
+ status_emoji = " ⚠️"
+ try:
+ amt_str = f"{float(amt):,.2f}"
+ except Exception:
+ amt_str = str(amt) if amt is not None else '—'
+ individual_lines.append(f"{amt_str} ({evt}){status_emoji}
")
+
+ return f"{total}
Total ({len(type_sub)} items)
{''.join(individual_lines)}"
+
+ return f"{total}
({eid}) {stat}"
+
+ return {
+ "name": name,
+ "journal": render_cell(has_rows('JOURNAL'), j_total, j_eid, j_stat),
+ "banking": render_cell(has_rows('BANKING_JOURNAL'), b_total, b_eid, b_stat),
+ "invoice": render_cell(has_rows('INVOICE'), i_total, i_eid, i_stat, 'INVOICE'),
+ "receipt": render_cell(has_rows('RECEIPT'), r_total, r_eid, r_stat, 'RECEIPT'),
+ }
+
+ rows = [summarize_for(s) for s in STORES]
+ # Build single HTML table
+ header = (
+ ""
+ "| Store Name | "
+ "Journal | "
+ "Banking Journal | "
+ "Account Sales | "
+ "Account Payments | "
+ "
"
+ )
+ body = []
+ for r in rows:
+ body.append(
+ ""
+ f"| {r['name']} | "
+ f"{r['journal']} | "
+ f"{r['banking']} | "
+ f"{r['invoice']} | "
+ f"{r['receipt']} | "
+ "
"
+ )
+ store_summary_table_html = (
+ ""
+ "
Transaction Summary by Store
"
+ "
"
+ + header + "".join(body) + "
"
+ )
+
+ html = f"""
+
+
Hello Tucker Fresh,
+
Here’s your daily digest of posted transactions:
+ {store_summary_table_html}
+
Thank you for staying updated with us.
+
Best regards,
Workolik Team
+
+ """
+ return html
+
+ def send_email(self, recipients: List[str], subject: str, html: str) -> Tuple[bool, str]:
+ s = self.settings
+ if not all([s.smtp_host, s.smtp_port, s.smtp_user, s.smtp_password, s.smtp_from_email]):
+ return False, "SMTP settings are incomplete."
+
+ # Optional BCC via env (comma-separated), default empty
+ bcc_env = os.getenv("BCC_RECIPIENTS", "").strip()
+ bcc_recipients = [e.strip() for e in bcc_env.split(',') if e.strip()] if bcc_env else []
+ all_recipients = recipients + bcc_recipients
+
+ msg = MIMEMultipart("alternative")
+ msg["From"] = f"{s.smtp_from_name} <{s.smtp_from_email}>"
+ msg["To"] = ", ".join(recipients)
+ msg["Subject"] = subject
+ msg.attach(MIMEText(html, "html"))
+
+ try:
+ server = smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=30)
+ if s.smtp_use_tls:
+ server.starttls()
+ server.login(s.smtp_user, s.smtp_password)
+ server.sendmail(s.smtp_from_email, all_recipients, msg.as_string())
+ server.quit()
+ return True, "sent"
+ except Exception as e:
+ return False, str(e)
+
+ def log_email(self, recipients: List[str], subject: str, date_for: str, status: str, error: str | None = None) -> None:
+ with SessionLocal() as db:
+ entry = EmailLog(
+ recipients=", ".join(recipients),
+ subject=subject,
+ status=status,
+ error=error,
+ date_for=date_for,
+ )
+ db.add(entry)
+ db.commit()
+
+ def has_sent_for_date(self, date_for: str) -> bool:
+ """Return True if a successful send log exists for the given date."""
+ with SessionLocal() as db:
+ row = (
+ db.query(EmailLog)
+ .filter(EmailLog.date_for == date_for, EmailLog.status == "sent")
+ .order_by(EmailLog.sent_at.desc())
+ .first()
+ )
+ return row is not None
+
+ def recent_logs(self, limit: int = 50) -> list[dict]:
+ return _get_recent_logs_cached(limit)
+
+
+@st.cache_data(ttl=60) # Cache for 1 minute
+def _get_recent_logs_cached(limit: int = 50) -> list[dict]:
+ """Cached function to get recent email logs."""
+ with SessionLocal() as db:
+ rows = (
+ db.query(EmailLog)
+ .order_by(EmailLog.sent_at.desc())
+ .limit(limit)
+ .all()
+ )
+ return [
+ {
+ "id": r.id,
+ "sent_at": r.sent_at,
+ "recipients": r.recipients,
+ "subject": r.subject,
+ "status": r.status,
+ "error": r.error,
+ "date_for": r.date_for,
+ }
+ for r in rows
+ ]
+
diff --git a/app_core/services/mappings_service.py b/app_core/services/mappings_service.py
new file mode 100644
index 0000000..1e41af1
--- /dev/null
+++ b/app_core/services/mappings_service.py
@@ -0,0 +1,52 @@
+from typing import List, Optional
+from sqlalchemy.orm import Session
+from app_core.db.database import SessionLocal
+from app_core.db.models import TriumphDebtorMapping
+from datetime import datetime
+
+class MappingsService:
+ def __init__(self):
+ pass
+
+ def get_all_mappings(self) -> List[TriumphDebtorMapping]:
+ with SessionLocal() as db:
+ return db.query(TriumphDebtorMapping).order_by(TriumphDebtorMapping.id.asc()).all()
+
+ def get_mapping_by_id(self, mapping_id: int) -> Optional[TriumphDebtorMapping]:
+ with SessionLocal() as db:
+ return db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
+
+ def create_mapping(self, code: str, name: str, dbmacc: str, outlet: str) -> TriumphDebtorMapping:
+ with SessionLocal() as db:
+ mapping = TriumphDebtorMapping(
+ code=code,
+ name=name,
+ dbmacc=dbmacc,
+ outlet=outlet
+ )
+ db.add(mapping)
+ db.commit()
+ db.refresh(mapping)
+ return mapping
+
+ def update_mapping(self, mapping_id: int, code: str, name: str, dbmacc: str, outlet: str) -> bool:
+ with SessionLocal() as db:
+ mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
+ if mapping:
+ mapping.code = code
+ mapping.name = name
+ mapping.dbmacc = dbmacc
+ mapping.outlet = outlet
+ mapping.updated_at = datetime.now()
+ db.commit()
+ return True
+ return False
+
+ def delete_mapping(self, mapping_id: int) -> bool:
+ with SessionLocal() as db:
+ mapping = db.query(TriumphDebtorMapping).filter(TriumphDebtorMapping.id == mapping_id).first()
+ if mapping:
+ db.delete(mapping)
+ db.commit()
+ return True
+ return False
diff --git a/app_core/services/scheduler_service.py b/app_core/services/scheduler_service.py
new file mode 100644
index 0000000..62d08a4
--- /dev/null
+++ b/app_core/services/scheduler_service.py
@@ -0,0 +1,89 @@
+import logging
+import os
+from datetime import datetime
+from zoneinfo import ZoneInfo
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.executors.pool import ThreadPoolExecutor
+from apscheduler.jobstores.memory import MemoryJobStore
+
+from app_core.services.daily_report import main as run_daily_report
+
+# Configure logging
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+class SchedulerService:
+ def __init__(self):
+ self.scheduler = None
+ self.ist = ZoneInfo("Asia/Kolkata")
+
+ def start_scheduler(self):
+ """Start the background scheduler for daily email reports."""
+ if self.scheduler and self.scheduler.running:
+ logger.info("Scheduler is already running")
+ return
+
+ # Configure job stores and executors
+ jobstores = {
+ 'default': MemoryJobStore()
+ }
+ executors = {
+ 'default': ThreadPoolExecutor(20)
+ }
+ job_defaults = {
+ 'coalesce': False,
+ 'max_instances': 1
+ }
+
+ self.scheduler = BackgroundScheduler(
+ jobstores=jobstores,
+ executors=executors,
+ job_defaults=job_defaults,
+ timezone=self.ist
+ )
+
+ # Schedule daily email at 8:00 PM IST (20:00)
+ self.scheduler.add_job(
+ func=self._send_daily_report,
+ trigger=CronTrigger(hour=20, minute=0, timezone=self.ist),
+ id='daily_email_report',
+ name='Daily Email Report',
+ replace_existing=True
+ )
+
+ # Start the scheduler
+ self.scheduler.start()
+ logger.info("Daily email scheduler started - will send reports at 8:00 PM IST")
+
+ def stop_scheduler(self):
+ """Stop the background scheduler."""
+ if self.scheduler and self.scheduler.running:
+ self.scheduler.shutdown()
+ logger.info("Daily email scheduler stopped")
+
+ def _send_daily_report(self):
+ """Internal method to send daily report."""
+ try:
+ logger.info(f"Starting daily report at {datetime.now(self.ist)}")
+ result = run_daily_report()
+ if result == 0:
+ logger.info("Daily report sent successfully")
+ else:
+ logger.warning(f"Daily report failed with exit code: {result}")
+ except Exception as e:
+ logger.error(f"Error sending daily report: {str(e)}")
+
+ def get_next_run_time(self):
+ """Get the next scheduled run time for the daily report."""
+ if not self.scheduler or not self.scheduler.running:
+ return None
+
+ job = self.scheduler.get_job('daily_email_report')
+ if job:
+ return job.next_run_time
+ return None
+
+ def is_running(self):
+ """Check if scheduler is running."""
+ return self.scheduler is not None and self.scheduler.running
diff --git a/app_core/ui/__pycache__/auth_ui.cpython-312.pyc b/app_core/ui/__pycache__/auth_ui.cpython-312.pyc
new file mode 100644
index 0000000..d8eb256
Binary files /dev/null and b/app_core/ui/__pycache__/auth_ui.cpython-312.pyc differ
diff --git a/app_core/ui/__pycache__/layout.cpython-312.pyc b/app_core/ui/__pycache__/layout.cpython-312.pyc
new file mode 100644
index 0000000..d4a7492
Binary files /dev/null and b/app_core/ui/__pycache__/layout.cpython-312.pyc differ
diff --git a/app_core/ui/auth_ui.py b/app_core/ui/auth_ui.py
new file mode 100644
index 0000000..cab55a6
--- /dev/null
+++ b/app_core/ui/auth_ui.py
@@ -0,0 +1,37 @@
+import re
+import streamlit as st
+from app_core.services.auth_service import AuthService
+
+
+def _is_valid_email(value: str) -> bool:
+ if not value:
+ return False
+ pattern = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
+ return re.match(pattern, value.strip()) is not None
+
+
+def render_auth_card(auth_service: AuthService) -> None:
+ left, center, right = st.columns([1, 1.2, 1])
+ with center:
+
+ st.markdown('Welcome !!
', unsafe_allow_html=True)
+ st.markdown('Sign in to continue
', unsafe_allow_html=True)
+
+ with st.form("login_form", clear_on_submit=False):
+ email = st.text_input("Email", placeholder="you@example.com", key="login_email")
+ password = st.text_input("Password", type="password", placeholder="••••••••", key="login_password")
+ submitted = st.form_submit_button("Sign in →", use_container_width=True)
+ if submitted:
+ if not _is_valid_email(email):
+ st.error("Enter a valid email address.")
+ elif not password:
+ st.error("Password is required.")
+ else:
+ ok, user, msg = auth_service.login(email, password)
+ if ok and user:
+ st.session_state.auth_user = user
+ st.success(msg)
+ st.rerun()
+ else:
+ st.error(msg)
+ st.markdown('', unsafe_allow_html=True)
diff --git a/app_core/ui/layout.py b/app_core/ui/layout.py
new file mode 100644
index 0000000..809e4b3
--- /dev/null
+++ b/app_core/ui/layout.py
@@ -0,0 +1,314 @@
+import streamlit as st
+from app_core.config.settings import STORES, STORE_CODE_TO_TENANT_ID
+
+def apply_global_style(background_url: str | None = None) -> None:
+ css = """
+
+ """
+ st.markdown(css, unsafe_allow_html=True)
+
+ # Optional login/background image with 50% transparency
+ if background_url:
+ # Support @prefix and local files by embedding as base64 when needed
+ try:
+ import os, base64
+ url = background_url.lstrip('@').strip()
+ if url.startswith('http://') or url.startswith('https://'):
+ data_url = url
+ else:
+ # Treat as local file path
+ # Map shorthand names to assets/ if needed
+ if url in {"bg.jpg", "workolik.png"}:
+ url = os.path.join("assets", url)
+ if os.path.exists(url):
+ ext = os.path.splitext(url)[1].lower()
+ mime = 'image/jpeg' if ext in ['.jpg', '.jpeg'] else 'image/png' if ext == '.png' else 'image/webp' if ext == '.webp' else 'image/*'
+ with open(url, 'rb') as f:
+ b64 = base64.b64encode(f.read()).decode()
+ data_url = f'data:{mime};base64,{b64}'
+ else:
+ data_url = url # fallback; let browser try
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+ except Exception:
+ pass
+
+
+def render_header(brand_name: str = "Workolik") -> None:
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+def render_topbar(brand_name: str = "") -> None:
+ st.markdown(
+ f"""
+ \n
\n \n {brand_name}\n
\n
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+def render_sidebar_logo(brand_name: str = "Workolik") -> None:
+ import streamlit as st
+ import base64
+ import os
+
+ try:
+ # Read the image file and encode it as base64
+ logo_path = os.path.join("assets", "workolik.png")
+ if os.path.exists(logo_path):
+ with open(logo_path, "rb") as img_file:
+ img_data = base64.b64encode(img_file.read()).decode()
+
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+ else:
+ raise FileNotFoundError("Logo file not found")
+ except Exception as e:
+ # Fallback to text logo if image fails to load
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+def render_store_selector() -> tuple[int | None, str | None]:
+ """Render a compact, classy store selector box.
+
+ Returns (tenant_id, label). Also persists selection in session_state.
+ """
+ st.markdown(
+ """
+
+
+
+
🛍️
+
Choose the store you want to view
+
✨
+
+
👉 Click a card to select
+
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ # Sub caption + clear selection
+ current_label = st.session_state.get("store_label")
+ left_cap, right_clear = st.columns([6, 1])
+ with left_cap:
+ st.caption("Please choose a store before surfing !")
+ if current_label:
+ st.caption(f"Selected: {current_label}")
+ with right_clear:
+ if st.button("Clear", key="clear_store_sel"):
+ st.session_state["tenant_id"] = None
+ st.session_state["store_label"] = None
+ st.experimental_rerun() if hasattr(st, "experimental_rerun") else st.rerun()
+
+ # We no longer use query params; selection happens in-session only
+ chosen_from_query: str | None = None
+
+ # Grid of store boxes (soft gradient cards with per-store colors and emojis)
+ emoji_map = {"PC": "🍷", "IP": "🍻", "ML": "🥂", "CL4": "🍸", "NL": "🥃", "CL6": "🍾", "RC": "🍹", "PL": "🍺"}
+
+ color_rgb = {"PC": (37,99,235), "IP": (22,163,74), "ML": (245,158,11), "CL4": (220,38,38), "NL": (124,58,237), "CL6": (234,179,8), "RC": (6, 182, 212), "PL": (236, 72, 153)}
+ preselect_label = st.session_state.get("store_label")
+ chosen_label = None
+ # No search box; show all stores
+ filtered_stores = STORES
+ # Always render 3 columns per row (e.g., 3 + 3 for 6 stores)
+ rows = [filtered_stores[i:i+3] for i in range(0, len(filtered_stores), 3)]
+ for row in rows:
+ cols = st.columns(3)
+ for i, store in enumerate(row):
+ with cols[i]:
+ icon = emoji_map.get(store["code"], "🏬")
+ r, g, b = color_rgb.get(store["code"], (14,165,233))
+ is_selected = (preselect_label == store["label"]) or (chosen_from_query == store["label"]) # highlight current
+ border_alpha = 0.48 if is_selected else 0.28
+ shadow = "0 18px 42px rgba(2,6,23,0.16)" if is_selected else "0 12px 28px rgba(2,6,23,0.10)"
+ border_width = "2px" if is_selected else "1px"
+ check = " ✅" if is_selected else ""
+ # Render a card-like button that sets selection without changing URL
+ clicked = st.button(
+ f"{icon} {store['label']}{check}",
+ key=f"store_card_{store['code']}",
+ use_container_width=True,
+ type="secondary",
+ )
+ # Lightweight card styling via inline CSS targeting this button
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+ if clicked:
+ st.session_state["tenant_id"] = store["tenant_id"]
+ st.session_state["store_label"] = store["label"]
+ chosen_label = store["label"]
+ st.rerun()
+
+ # Resolve tenant_id
+ effective_label = chosen_label or preselect_label
+ selected = next((s for s in STORES if s["label"] == effective_label), None)
+ tenant_id = selected["tenant_id"] if selected else None
+
+ # Persist
+ st.session_state["tenant_id"] = tenant_id
+ st.session_state["store_label"] = selected["label"] if selected else None
+ st.session_state["division_code"] = None
+
+ return tenant_id, (selected["label"] if selected else None)
\ No newline at end of file
diff --git a/assets/bg.jpg b/assets/bg.jpg
new file mode 100644
index 0000000..1f9238d
Binary files /dev/null and b/assets/bg.jpg differ
diff --git a/assets/workolik.png b/assets/workolik.png
new file mode 100644
index 0000000..0f450e2
Binary files /dev/null and b/assets/workolik.png differ
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..fcbb514
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+version: "3.9"
+
+services:
+ workolik:
+ build: .
+ container_name: workolik
+ env_file: .env
+ environment:
+ - TZ=Asia/Kolkata
+ - STREAMLIT_LOG_LEVEL=info
+ restart: unless-stopped
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.workolik.rule=Host(`tuckerfresh.workolik.com`)"
+ - "traefik.http.routers.workolik.entrypoints=websecure"
+ - "traefik.http.routers.workolik.tls=true"
+ - "traefik.http.routers.workolik.tls.certresolver=letsencrypt"
+ - "traefik.http.services.workolik.loadbalancer.server.port=8501"
+ networks:
+ - frontend
+ command: ["/bin/sh", "-c", "python scripts/validate_setup.py && echo 'Starting Streamlit...' && streamlit run app.py --server.port=8501 --server.address=0.0.0.0 --server.headless=true"]
+
+ workolik-scheduler:
+ build: .
+ container_name: workolik-scheduler
+ env_file: .env
+ environment:
+ - TZ=Asia/Kolkata
+ restart: unless-stopped
+ networks:
+ - frontend
+ command: ["/bin/sh", "-c", "python scripts/validate_setup.py && python scripts/scheduler_standalone.py"]
+
+networks:
+ frontend:
+ external: true
+
+
diff --git a/docs/sample_email.txt b/docs/sample_email.txt
new file mode 100644
index 0000000..20c309a
--- /dev/null
+++ b/docs/sample_email.txt
@@ -0,0 +1,61 @@
+Subject: Daily Digest - YYYY-MM-DD
+From: Workolik Team
+To: alice@example.com, bob@example.com
+
+Hello Tucker Fresh,
+
+Here’s your daily digest of posted transactions.
+
+Per-store summary (stacked lines: total — event id status):
+
+Porters Liquor Claremont - PC
+- Journal: 3,881.24 — 2904783 ✅
+- Banking Journal: 0.00 — —
+- Account Sales: 2,116.55 — 188210 ✅
+- Account Payments: 980.00 — 188050 ✅
+
+Porters Iluka - IP
+- Journal: 540.00 — 2905100 ✅
+- Banking Journal: 0.00 — —
+- Account Sales: 320.00 — 188305 ✅
+- Account Payments: 0.00 — —
+
+Cellarbrations at Morris Place - ML
+- Journal: 1,592.10 — 2904783 ✅
+- Banking Journal: 50.00 — 99001 ⚠️
+- Account Sales: 560.00 — 188210 ✅
+- Account Payments: 0.00 — —
+
+Cellarbrations at Lynwood - CL (Store 4)
+- Journal: 225.00 — 2906000 ✅
+- Banking Journal: 0.00 — —
+- Account Sales: Nill
+- Account Payments: Nill
+
+Cellarbrations at Nicholson Road - NL
+- Journal: 410.75 — 2907000 ✅
+- Banking Journal: 0.00 — —
+- Account Sales: 120.00 — 188500 ✅
+- Account Payments: 0.00 — —
+
+Cellarbrations at Lynwood - CL (Store 6)
+- Journal: 300.00 — 2908000 ✅
+- Banking Journal: 0.00 — —
+- Account Sales: Nill
+- Account Payments: Nill
+
+
+Thank you for staying updated with us.
+
+Best regards,
+Workolik Team
+
+---
+How this mail is triggered (summary):
+1) scheduler_standalone.py runs a daily job at 20:00 IST (Asia/Kolkata).
+2) It calls app_core/services/daily_report.py:main().
+3) That loads the full-day data, builds HTML via MailerService.build_email_html()
+ including the per-store totals and Event IDs matrix, then sends via SMTP.
+4) Recipients from REPORT_RECIPIENTS; duplicate sends are avoided by DB check
+ against email_logs.date_for.
+
diff --git a/pages/__pycache__/mailer.cpython-312.pyc b/pages/__pycache__/mailer.cpython-312.pyc
new file mode 100644
index 0000000..428bce9
Binary files /dev/null and b/pages/__pycache__/mailer.cpython-312.pyc differ
diff --git a/pages/__pycache__/mappings.cpython-312.pyc b/pages/__pycache__/mappings.cpython-312.pyc
new file mode 100644
index 0000000..b02e840
Binary files /dev/null and b/pages/__pycache__/mappings.cpython-312.pyc differ
diff --git a/pages/__pycache__/see_logs.cpython-312.pyc b/pages/__pycache__/see_logs.cpython-312.pyc
new file mode 100644
index 0000000..a241161
Binary files /dev/null and b/pages/__pycache__/see_logs.cpython-312.pyc differ
diff --git a/pages/__pycache__/see_payload.cpython-312.pyc b/pages/__pycache__/see_payload.cpython-312.pyc
new file mode 100644
index 0000000..84d7772
Binary files /dev/null and b/pages/__pycache__/see_payload.cpython-312.pyc differ
diff --git a/pages/mailer.py b/pages/mailer.py
new file mode 100644
index 0000000..171cca0
--- /dev/null
+++ b/pages/mailer.py
@@ -0,0 +1,118 @@
+import streamlit as st
+import pandas as pd
+from datetime import date, datetime
+from zoneinfo import ZoneInfo
+from app_core.services.mailer_service import MailerService
+
+
+def render_page():
+ if st.session_state.get("auth_user") is None:
+ st.warning("Please login to continue.")
+ st.stop()
+
+ st.markdown("## Mailer")
+ st.caption("Automated daily email sending logs and status.")
+
+ service = MailerService()
+ ist = ZoneInfo("Asia/Kolkata")
+
+ st.markdown("### Scheduler Status")
+
+ # Check if scheduler container is running by checking if daily report was sent today
+ today_logs = [log for log in service.recent_logs(limit=1000) if log.get('date_for') == str(date.today())]
+ daily_report_sent_today = any('Daily Report' in str(log.get('subject', '')) for log in today_logs)
+
+ if daily_report_sent_today:
+ st.success("Scheduler is running - Daily report already sent today")
+ else:
+ # Check if it's past 8 PM IST today
+ now_ist = datetime.now(ist)
+ eight_pm_today = now_ist.replace(hour=20, minute=0, second=0, microsecond=0)
+
+ if now_ist >= eight_pm_today:
+ st.warning("Scheduler is running - Waiting for next scheduled run (8:00 PM IST daily)")
+ else:
+ next_run_ist = eight_pm_today
+ st.success(f"Scheduler is running - Next report will be sent at {next_run_ist.strftime('%B %d, %Y at %I:%M %p IST')}")
+
+ st.markdown("---")
+
+ # Show system status
+ st.markdown("### System Status")
+ col1, col2, col3 = st.columns(3)
+
+ with col1:
+ st.metric("Total Emails Sent", len([log for log in service.recent_logs(limit=1000) if log.get('status') == 'sent']))
+
+ with col2:
+ failed_count = len([log for log in service.recent_logs(limit=1000) if log.get('status') == 'failed'])
+ st.metric("Failed Sends", failed_count, delta=f"-{failed_count}" if failed_count > 0 else None)
+
+ with col3:
+ today_logs = [log for log in service.recent_logs(limit=1000) if log.get('date_for') == str(date.today())]
+ st.metric("Today's Sends", len(today_logs))
+
+ st.markdown("---")
+
+ # Manual trigger section
+ st.markdown("### Manual Controls")
+ mcol1, mcol2, mcol3 = st.columns([2, 2, 3])
+
+ with mcol1:
+ target_date = st.date_input("Report Date", value=date.today())
+
+ with mcol2:
+ st.write("") # Spacer
+ force_resend = st.checkbox("Force Resend", value=True, help="Send the report even if it was already sent for this date.")
+
+ with mcol3:
+ st.write("") # Spacer
+ if st.button("Send Report Now", type="primary", use_container_width=True):
+ with st.spinner(f"Sending report for {target_date}..."):
+ try:
+ from app_core.services.daily_report import main as run_daily_report
+ # Pass the selected date and force flag
+ result = run_daily_report(for_date=str(target_date), force=force_resend)
+ if result == 0:
+ st.success(f"Report for {target_date} sent successfully!")
+ st.rerun()
+ else:
+ st.error(f"Failed to send report (exit code: {result})")
+ except Exception as e:
+ st.error(f"Error: {str(e)}")
+
+ st.caption("Select a date to manually trigger or re-trigger the daily report email. This is useful for reconciliations.")
+
+ st.markdown("---")
+
+ # Show email logs table
+ st.markdown("### Email Logs")
+ logs = service.recent_logs(limit=100)
+ if not logs:
+ st.info("No email logs yet. Automated emails will appear here once sent.")
+ else:
+ df_logs = pd.DataFrame(logs)
+ col_map = {
+ "id": "ID",
+ "sent_at": "Sent At",
+ "recipients": "Recipients",
+ "subject": "Subject",
+ "status": "Status",
+ "error": "Error",
+ "date_for": "Report Date",
+ }
+ df_logs = df_logs[["id", "sent_at", "date_for", "recipients", "subject", "status", "error"]]
+ df_logs = df_logs.rename(columns=col_map)
+
+ # Add status styling
+ def style_status(val):
+ if val == 'sent':
+ return 'background-color: #D1FAE5; color: #065F46; font-weight: 600;'
+ elif val == 'failed':
+ return 'background-color: #FEE2E2; color: #991B1B; font-weight: 600;'
+ return ''
+
+ styled_logs = df_logs.style.map(style_status, subset=['Status'])
+ st.dataframe(styled_logs, use_container_width=True, height=400)
+
+# trigger reload
diff --git a/pages/mappings.py b/pages/mappings.py
new file mode 100644
index 0000000..faa8a1c
--- /dev/null
+++ b/pages/mappings.py
@@ -0,0 +1,189 @@
+import streamlit as st
+import pandas as pd
+from app_core.services.mappings_service import MappingsService
+from app_core.config.settings import STORES
+
+
+def render_page():
+ if st.session_state.get("auth_user") is None:
+ st.warning("Please login to continue.")
+ st.stop()
+
+ st.markdown("""
+
+ """, unsafe_allow_html=True)
+
+ st.markdown("## 📋 Triumph Debtor Mappings")
+ st.caption("Manage POS account sale mappings to Triumph debtor codes — filtered by store.")
+
+ service = MappingsService()
+ all_mappings = service.get_all_mappings()
+
+ # Store labels from config — used only for the "Add New" dropdown
+ store_labels = [s["label"] for s in STORES]
+
+ tab1, tab2 = st.tabs(["🔍 View & Search", "➕ Add New Mapping"])
+
+ # ── TAB 1: View & Edit ────────────────────────────────────────────────────
+ with tab1:
+ st.markdown("### 🔍 Current Mappings")
+
+ if not all_mappings:
+ st.info("No mappings found. Use the '➕ Add New Mapping' tab to create one.")
+ else:
+ # Build dataframe from raw DB values
+ data = [
+ {
+ "ID": m.id,
+ "POS Code": m.code or "",
+ "Account Name": m.name or "",
+ "Triumph Code": m.dbmacc or "",
+ "Outlet": (m.outlet or "").strip(),
+ "Created At": m.created_at.strftime("%Y-%m-%d %H:%M") if m.created_at else "—",
+ "Updated At": m.updated_at.strftime("%Y-%m-%d %H:%M") if m.updated_at else "—",
+ }
+ for m in all_mappings
+ ]
+ df_full = pd.DataFrame(data)
+
+ # Distinct outlet names actually in DB
+ distinct_outlets = sorted([
+ o for o in df_full["Outlet"].dropna().unique().tolist() if o.strip()
+ ])
+
+ f1, f2 = st.columns([1, 2])
+ with f1:
+ selected_store = st.selectbox(
+ "🏪 Filter by Store",
+ options=["All Stores"] + distinct_outlets,
+ index=0,
+ )
+ with f2:
+ search_query = st.text_input(
+ "🔎 Search",
+ placeholder="POS Code, Account Name, or Triumph Code…",
+ )
+
+ df = df_full.copy()
+
+ if selected_store != "All Stores":
+ df = df[df["Outlet"] == selected_store]
+
+ if search_query:
+ q = search_query
+ df = df[
+ df["POS Code"].str.contains(q, case=False, na=False) |
+ df["Account Name"].str.contains(q, case=False, na=False) |
+ df["Triumph Code"].str.contains(q, case=False, na=False)
+ ]
+
+ store_label = selected_store if selected_store != "All Stores" else "all stores"
+ st.caption(f"Showing **{len(df)}** mapping(s) for **{store_label}**.")
+
+ st.markdown("#### 📝 Edit Mappings")
+ st.caption("Double-click any editable cell to modify. Changes are saved when you press Enter.")
+
+ st.data_editor(
+ df,
+ hide_index=True,
+ use_container_width=True,
+ num_rows="dynamic",
+ disabled=["ID", "Created At", "Updated At"],
+ column_config={
+ "ID": st.column_config.NumberColumn(format="%d", width="small"),
+ "POS Code": st.column_config.TextColumn(max_chars=50, width="medium"),
+ "Account Name": st.column_config.TextColumn(max_chars=255, width="large"),
+ "Triumph Code": st.column_config.TextColumn(max_chars=50, width="medium"),
+ "Outlet": st.column_config.TextColumn(max_chars=255, width="large"),
+ "Created At": st.column_config.TextColumn(width="medium"),
+ "Updated At": st.column_config.TextColumn(width="medium"),
+ },
+ key="mapping_editor_v2",
+ )
+
+ if st.session_state.get("mapping_editor_v2"):
+ edited_rows = st.session_state.mapping_editor_v2.get("edited_rows", {})
+ deleted_rows = st.session_state.mapping_editor_v2.get("deleted_rows", [])
+
+ if edited_rows or deleted_rows:
+ changes_made = False
+
+ for idx, patch in edited_rows.items():
+ mapping_id = df.iloc[idx]["ID"]
+ row = df.iloc[idx]
+ new_code = patch.get("POS Code", row["POS Code"])
+ new_name = patch.get("Account Name", row["Account Name"])
+ new_triumph = patch.get("Triumph Code", row["Triumph Code"])
+ new_outlet = patch.get("Outlet", row["Outlet"])
+ if service.update_mapping(mapping_id, new_code, new_name, new_triumph, new_outlet):
+ changes_made = True
+
+ for idx in deleted_rows:
+ if service.delete_mapping(df.iloc[idx]["ID"]):
+ changes_made = True
+
+ if changes_made:
+ st.toast("✅ Mappings updated and synced!", icon="🚀")
+ st.rerun()
+
+ # ── TAB 2: Add New ────────────────────────────────────────────────────────
+ with tab2:
+ st.markdown("### ➕ Create New Mapping")
+ st.caption("All fields are mandatory.")
+
+ with st.form("new_mapping_form", clear_on_submit=True):
+ c1, c2 = st.columns(2)
+ with c1:
+ new_code = st.text_input("POS Code", placeholder="e.g. 0273",
+ help="Unique identifier from your POS system.")
+ new_name = st.text_input("Account Sale Name", placeholder="e.g. Suriya",
+ help="The name as it appears on account invoices.")
+ with c2:
+ new_triumph = st.text_input("Triumph Debtor Code (DBMACC#)", placeholder="e.g. SURI0273",
+ help="The debtor code in Triumph ERP.")
+ new_outlet = st.selectbox(
+ "Store / Outlet",
+ options=["Select a Store"] + store_labels,
+ index=0,
+ help="Select the store this mapping belongs to.",
+ )
+
+ st.markdown("
", unsafe_allow_html=True)
+ if st.form_submit_button("Create Mapping", type="primary", use_container_width=True):
+ if not all([new_code.strip(), new_name.strip(), new_triumph.strip()]) or new_outlet == "— Select a Store —":
+ st.error("⚠️ All fields are required — including selecting a store.")
+ else:
+ service.create_mapping(new_code.strip(), new_name.strip(), new_triumph.strip(), new_outlet)
+ st.success(f"✅ Mapping for **{new_name}** created under **{new_outlet}**!")
+ st.balloons()
+ st.rerun()
+
+ st.markdown("---")
+ with st.expander("📖 Field definitions"):
+ st.write("""
+ - **POS Code** — Unique identifier from your POS system.
+ - **Account Name** — Name used on account sales invoices.
+ - **Triumph Code (DBMACC#)** — Corresponding debtor code in Triumph ERP.
+ - **Store / Outlet** — Store this mapping is assigned to.
+
+ *Any change here is immediately picked up by the background event processor.*
+ """)
+
+
+if __name__ == "__main__":
+ render_page()
diff --git a/pages/see_logs.py b/pages/see_logs.py
new file mode 100644
index 0000000..32baec9
--- /dev/null
+++ b/pages/see_logs.py
@@ -0,0 +1,380 @@
+import streamlit as st
+import pandas as pd
+import plotly.express as px
+import plotly.graph_objects as go
+from datetime import datetime, date, timedelta
+from app_core.db.database import engine
+from sqlalchemy import text
+from app_core.ui.layout import render_store_selector
+
+
+@st.cache_data(ttl=300) # Cache for 5 minutes
+def _load_available_dates(tenant_id: int, days_back: int = 60):
+ """Load available dates with data for the tenant."""
+ with engine.connect() as conn:
+ dates_df = pd.read_sql(
+ 'SELECT "created_at"::date AS d, COUNT(*) AS c\n'
+ 'FROM "tenantpostings"\n'
+ 'WHERE "created_at" >= (CURRENT_DATE - INTERVAL \'60 days\') AND "tenant_id" = %(t)s\n'
+ 'GROUP BY d\n'
+ 'ORDER BY d DESC',
+ conn,
+ params={"t": tenant_id},
+ )
+ if not pd.api.types.is_datetime64_any_dtype(dates_df['d']):
+ dates_df['d'] = pd.to_datetime(dates_df['d'], errors='coerce')
+ return dates_df
+
+
+@st.cache_data(ttl=300) # Cache for 5 minutes
+def _load_daily_data(tenant_id: int, target_date: date):
+ """Load daily data for a specific tenant and date."""
+ day_sql = (
+ 'SELECT * FROM "tenantpostings" '
+ 'WHERE "created_at"::date = %(d)s AND "tenant_id" = %(t)s '
+ 'ORDER BY "id" DESC '
+ 'LIMIT 10000'
+ )
+ with engine.connect() as conn:
+ df = pd.read_sql(day_sql, conn, params={"d": target_date, "t": tenant_id})
+
+ # De-duplicate by triumph_event to avoid logical doubling
+ if not df.empty and 'triumph_event' in df.columns:
+ has_event = df['triumph_event'].fillna('').astype(str).str.strip() != ''
+ df_with_events = df[has_event].sort_values(['processing_type', 'triumph_event', 'id'], ascending=[True, True, False]).drop_duplicates(subset=['processing_type', 'triumph_event'], keep='first')
+ df_no_events = df[~has_event]
+ df = pd.concat([df_with_events, df_no_events]).sort_values('id', ascending=False)
+
+ return df
+
+
+@st.cache_data(ttl=300) # Cache for 5 minutes
+def _load_trend_data(tenant_id: int, days_back: int = 30):
+ """Load trend data for charts."""
+ with engine.connect() as conn:
+ totals_agg = pd.read_sql(
+ 'SELECT "created_at"::date AS d, SUM("total_amount") AS total\n'
+ 'FROM "tenantpostings"\n'
+ 'WHERE "created_at" >= (CURRENT_DATE - INTERVAL \'30 days\') AND "tenant_id" = %(t)s\n'
+ " AND UPPER(COALESCE(\"processing_type\", '')) = 'JOURNAL'\n"
+ 'GROUP BY d\n'
+ 'ORDER BY d ASC',
+ conn,
+ params={"t": tenant_id},
+ )
+ if not pd.api.types.is_datetime64_any_dtype(totals_agg['d']):
+ totals_agg['d'] = pd.to_datetime(totals_agg['d'], errors='coerce')
+ return totals_agg
+
+
+def _normalize_name(name: str) -> str:
+ return "".join(ch for ch in name.lower() if ch.isalnum())
+
+
+def _build_display_map(df: pd.DataFrame) -> dict[str, str]:
+ overrides = {
+ "triumph_status": "Status",
+ "triumph_event": "Event",
+ "outlet_name": "Outlet Name",
+ "tenant_id": "Tenant ID",
+ "processing_type": "Processing Type",
+ "total_amount": "Total Amount",
+ "created_at": "Date",
+ "updated_at": "Updated At",
+ "id": "SNo",
+ }
+ display_map: dict[str, str] = {}
+ used: set[str] = set()
+ for col in df.columns:
+ key = col.lower()
+ if key in overrides:
+ display_name = overrides[key]
+ else:
+ # Convert snake_case to Title Case
+ display_name = col.replace("_", " ").title()
+
+ # Ensure unique display names
+ final_name = display_name
+ counter = 1
+ while final_name in used:
+ final_name = f"{display_name} ({counter})"
+ counter += 1
+
+ display_map[col] = final_name
+ used.add(final_name)
+ return display_map
+
+
+def _pick_existing_columns(df: pd.DataFrame, names: list[str]) -> list[str]:
+ """Pick columns that exist in the DataFrame from a list of names."""
+ found = []
+ for name in names:
+ if name in df.columns:
+ found.append(name)
+ return found
+
+
+def _format_date_columns(df: pd.DataFrame) -> pd.DataFrame:
+ """Format date columns to show only date part"""
+ df_formatted = df.copy()
+ for col in df_formatted.columns:
+ if 'created_at' in col.lower() or 'date' in col.lower():
+ if pd.api.types.is_datetime64_any_dtype(df_formatted[col]):
+ df_formatted[col] = df_formatted[col].dt.date
+ else:
+ # Try to convert to datetime first
+ try:
+ df_formatted[col] = pd.to_datetime(df_formatted[col]).dt.date
+ except:
+ pass
+ return df_formatted
+
+
+def _journal_total(frame: pd.DataFrame) -> float:
+ """Sum total_amount for JOURNAL rows only."""
+ if frame is None or frame.empty or 'total_amount' not in frame.columns:
+ return 0.0
+
+ # We assume 'frame' is already de-duplicated by triumph_event at load time
+ if 'processing_type' in frame.columns:
+ mask = frame['processing_type'].astype(str).str.upper() == 'JOURNAL'
+ frame = frame[mask]
+
+ return float(frame['total_amount'].sum()) if not frame.empty else 0.0
+
+
+def _stat_card(title: str, value: str, color: str, icon: str) -> str:
+ return f"""
+
+
{icon}
+
{value}
+
{title}
+
+ """
+
+
+def render_page():
+ if st.session_state.get("auth_user") is None:
+ st.warning("Please login to continue.")
+ st.stop()
+
+ # Store selector (must be chosen before loading analytics)
+ tenant_id, store_label = render_store_selector()
+ if not tenant_id:
+ st.info("Please choose a store to view analytics.")
+ return
+
+ st.markdown("## 📊 Dashboard")
+ # Date picker for selecting any date
+ picker_col1, _ = st.columns([1, 3])
+ with picker_col1:
+ selected_date = st.date_input("Report date", value=date.today(), max_value=date.today())
+ st.markdown("---")
+
+ # Target date preference is today, but we will fall back to most recent date with data
+ today = date.today()
+
+ # Database connection with caching
+ try:
+ # Find most recent available dates with data (last 60 days)
+ dates_df = _load_available_dates(tenant_id)
+
+ if dates_df.empty:
+ st.warning("No data available in the last 60 days.")
+ return
+
+ # Prefer the user-selected date if present; else pick the most recent date
+ available_dates = list(dates_df['d'].dt.date)
+ if selected_date in available_dates:
+ date_shown = selected_date
+ else:
+ date_shown = available_dates[0]
+
+ # Comparison dates: the most recent prior dates (up to two)
+ prior_dates = [d for d in available_dates if d < date_shown]
+ compare_date = prior_dates[0] if prior_dates else None
+ compare_date2 = prior_dates[1] if len(prior_dates) > 1 else None
+
+ # Load frames using cached functions
+ df = _load_daily_data(tenant_id, date_shown)
+ df_compare = _load_daily_data(tenant_id, compare_date) if compare_date else pd.DataFrame()
+ df_compare2 = _load_daily_data(tenant_id, compare_date2) if compare_date2 else pd.DataFrame()
+
+ if date_shown == selected_date:
+ st.success(f"📅 Showing data for {date_shown.strftime('%B %d, %Y')} ({len(df):,} records)")
+ else:
+ st.info(f"📅 Showing most recent data: {date_shown.strftime('%B %d, %Y')} ({len(df):,} records)")
+
+ except Exception as e:
+ st.error(f"Database connection failed: {str(e)}")
+ return
+
+ # Calculate key metrics (Total Amount uses JOURNAL only)
+ total_amount = _journal_total(df)
+ total_transactions = len(df)
+ success_count = len(df[df['triumph_status'] == 'success']) if 'triumph_status' in df.columns else 0
+ failed_count = len(df[df['triumph_status'] == 'failed']) if 'triumph_status' in df.columns else 0
+ pending_count = len(df[df['triumph_status'] == 'pending']) if 'triumph_status' in df.columns else 0
+
+ # Status summary cards
+ st.markdown("### 📈 Today's Overview")
+ col1, col2, col3, col4, col5 = st.columns(5)
+
+ with col1:
+ st.markdown(_stat_card("Total Amount", f"${total_amount:,.2f}", "#059669", "💰"), unsafe_allow_html=True)
+ with col2:
+ st.markdown(_stat_card("Transactions", f"{total_transactions:,}", "#2563EB", "📊"), unsafe_allow_html=True)
+ with col3:
+ st.markdown(_stat_card("Success", f"{success_count:,}", "#059669", "✅"), unsafe_allow_html=True)
+ with col4:
+ st.markdown(_stat_card("Failed", f"{failed_count:,}", "#DC2626", "❌"), unsafe_allow_html=True)
+ with col5:
+ st.markdown(_stat_card("Pending", f"{pending_count:,}", "#D97706", "⏳"), unsafe_allow_html=True)
+
+ st.markdown("---")
+
+ # Build simple frames map for totals lookup
+ frames_by_date = {date_shown: df}
+ if 'df_compare' in locals() and compare_date:
+ frames_by_date[compare_date] = df_compare
+ if 'df_compare2' in locals() and 'compare_date2' in locals() and compare_date2:
+ frames_by_date[compare_date2] = df_compare2
+
+ # Stock-like trend line: last 14 days JOURNAL totals, with last 3 days highlighted
+ st.markdown("### 📈 Sales Trend")
+ totals_agg = _load_trend_data(tenant_id)
+ last14 = totals_agg.tail(14).copy() if not totals_agg.empty else pd.DataFrame(columns=['d','total'])
+ if not last14.empty:
+ x_labels = last14['d'].dt.strftime('%b %d')
+ fig_line = go.Figure()
+ fig_line.add_trace(
+ go.Scatter(
+ x=x_labels,
+ y=last14['total'],
+ mode='lines+markers',
+ name='Sales',
+ line=dict(color="#2563EB", width=2.6),
+ marker=dict(size=4, color="#2563EB"),
+ line_shape='spline',
+ hovertemplate="%{x}
$%{y:,.2f}",
+ )
+ )
+ # Highlight last 3 points
+ last3 = last14.tail(3).reset_index(drop=True)
+ colors = ["#94A3B8", "#DC2626", "#16A34A"] # old->gray, prev->red, latest->green
+ labels = ["Prev-2", "Prev", "Latest"]
+ for i in range(len(last3)):
+ fig_line.add_trace(
+ go.Scatter(
+ x=[last3['d'].dt.strftime('%b %d').iloc[i]],
+ y=[last3['total'].iloc[i]],
+ mode='markers',
+ name=labels[i],
+ marker=dict(color=colors[i], size=9, symbol='circle'),
+ hovertemplate=f"{labels[i]}: %{{x}}
$%{{y:,.2f}}",
+ )
+ )
+ # Profit/Loss vs previous day
+ if len(last3) >= 2 and last3['total'].iloc[1] != 0:
+ shown_total = float(last3['total'].iloc[2]) if len(last3) == 3 else float(last3['total'].iloc[-1])
+ prev_total = float(last3['total'].iloc[-2])
+ delta = (shown_total - prev_total) / prev_total * 100.0
+ arrow = '▲' if delta >= 0 else '▼'
+ color = '#16A34A' if delta >= 0 else '#DC2626'
+ fig_line.add_annotation(
+ x=1, y=1.1, xref='paper', yref='paper', showarrow=False,
+ text=f"{arrow} {delta:.1f}% vs {last3['d'].dt.strftime('%b %d').iloc[-2]}",
+ font=dict(color=color, size=14), align='right'
+ )
+ fig_line.update_layout(
+ height=320,
+ showlegend=True,
+ yaxis_title="Total Amount ($)",
+ xaxis_title=None,
+ margin=dict(t=30, b=30, l=30, r=20),
+ plot_bgcolor='white',
+ hovermode='x unified'
+ )
+ fig_line.update_yaxes(showgrid=True, gridcolor='#E5E7EB', zeroline=False)
+ fig_line.update_xaxes(showgrid=False, zeroline=False)
+ st.plotly_chart(fig_line, use_container_width=True)
+
+ st.markdown("---")
+
+ # Data table section
+ st.markdown("### 📋 Detailed Data")
+
+ # Minimal columns for default view
+ minimal_names = [
+ "id",
+ "created_at",
+ "outlet_name",
+ "processing_type",
+ "total_amount",
+ "triumph_status",
+ "triumph_event",
+ ]
+ minimal_cols = _pick_existing_columns(df, minimal_names)
+
+ # Controls row: search only
+ q = st.text_input("Search", placeholder="Type to filter rows across all columns")
+
+ # Filter data based on search
+ if q:
+ mask = df.astype(str).apply(lambda x: x.str.contains(q, case=False, na=False)).any(axis=1)
+ df_filtered = df[mask]
+ else:
+ df_filtered = df.copy()
+
+ # Always use minimal columns
+ display_cols = minimal_cols if minimal_cols else list(df_filtered.columns[:8])
+
+ # Build display names
+ display_map = _build_display_map(df_filtered)
+
+ # Format the display dataframe
+ df_display = df_filtered[display_cols].copy()
+ df_display.columns = [display_map.get(col, col) for col in display_cols]
+ # Format date columns
+ df_display = _format_date_columns(df_display)
+
+ # Format numeric columns
+ for col in df_display.columns:
+ if 'amount' in col.lower() and df_display[col].dtype in ['float64', 'int64']:
+ df_display[col] = df_display[col].apply(lambda x: f"${x:,.2f}" if pd.notna(x) else "")
+
+ # Always apply status styling
+ if 'Status' in df_display.columns:
+ def style_status(val):
+ if val == 'success':
+ return 'background-color: #D1FAE5; color: #065F46; font-weight: 600;'
+ elif val == 'failed':
+ return 'background-color: #FEE2E2; color: #991B1B; font-weight: 600;'
+ elif val == 'pending':
+ return 'background-color: #FEF3C7; color: #92400E; font-weight: 600;'
+ return ''
+
+ styled_df = df_display.style.map(style_status, subset=['Status'])
+ st.dataframe(styled_df, use_container_width=True, height=400)
+ else:
+ st.dataframe(df_display, use_container_width=True, height=400)
+
+ # Download button
+ if st.button("📥 Download Today's Data as CSV", type="primary"):
+ csv = df_filtered.to_csv(index=False)
+ st.download_button(
+ label="Download CSV",
+ data=csv,
+ file_name=f"workolik_data_{date_shown.strftime('%Y%m%d')}.csv",
+ mime="text/csv"
+ )
diff --git a/pages/see_payload.py b/pages/see_payload.py
new file mode 100644
index 0000000..ad14e19
--- /dev/null
+++ b/pages/see_payload.py
@@ -0,0 +1,285 @@
+import streamlit as st
+import pandas as pd
+from sqlalchemy import text
+from app_core.db.database import engine
+from app_core.ui.layout import render_store_selector
+
+
+@st.cache_data(ttl=300) # Cache for 5 minutes
+def _load_tenant_data(tenant_id: int, limit: int = 10000):
+ """Load data for a specific tenant with caching."""
+ with engine.connect() as conn:
+ df = pd.read_sql(
+ text('SELECT * FROM "tenantpostings" WHERE "tenant_id" = :t ORDER BY "id" DESC LIMIT :limit'),
+ conn,
+ params={"t": tenant_id, "limit": limit},
+ )
+ return df
+
+
+def _detect_status_column(df: pd.DataFrame) -> str | None:
+ candidates = ["status", "state", "result", "triumph_status"]
+ lower_map = {c.lower(): c for c in df.columns}
+ for key in candidates:
+ if key in lower_map:
+ return lower_map[key]
+ for c in df.columns:
+ if "status" in c.lower():
+ return c
+ return None
+
+
+def _normalize_name(name: str) -> str:
+ return "".join(ch for ch in name.lower() if ch.isalnum())
+
+
+def _build_display_map(df: pd.DataFrame) -> dict[str, str]:
+ overrides = {
+ "triumph_status": "Status",
+ "triumph_event": "Event",
+ "outlet_name": "Outlet Name",
+ "tenant_id": "Tenant ID",
+ "processing_type": "Processing Type",
+ "total_amount": "Total Amount",
+ "created_at": "Date",
+ "updated_at": "Updated At",
+ "id": "SNo",
+ }
+ display_map: dict[str, str] = {}
+ used: set[str] = set()
+ for col in df.columns:
+ key = col.lower()
+ if key in overrides:
+ label = overrides[key]
+ else:
+ label = col.replace("_", " ").title()
+ base = label
+ suffix = 2
+ while label in used:
+ label = f"{base} {suffix}"
+ suffix += 1
+ used.add(label)
+ display_map[col] = label
+ return display_map
+
+
+def _format_status_with_emoji(styler: "pd.io.formats.style.Styler", df: pd.DataFrame, status_col: str | None) -> "pd.io.formats.style.Styler":
+ if status_col is None or status_col not in df.columns:
+ return styler
+
+ def fmt(val):
+ v = str(val)
+ v_lower = v.lower()
+ if any(k in v_lower for k in ["success", "ok", "completed", "done", "active"]):
+ return f"✅ {v}"
+ if any(k in v_lower for k in ["fail", "error", "dead", "invalid"]):
+ return f"❌ {v}"
+ if any(k in v_lower for k in ["pending", "queue", "waiting", "processing"]):
+ return f"⏳ {v}"
+ return v
+
+ return styler.format({status_col: fmt})
+
+
+def _badge_status_cells(styler: "pd.io.formats.style.Styler", df: pd.DataFrame, status_col: str | None) -> "pd.io.formats.style.Styler":
+ if status_col is None or status_col not in df.columns:
+ return styler
+
+ def badge(val):
+ v = str(val).lower()
+ bg = "#E2E8F0"; color = "#0F172A"
+ if any(k in v for k in ["success", "ok", "completed", "done", "active"]):
+ bg = "#E6F7EE"; color = "#166534"
+ elif any(k in v for k in ["fail", "error", "dead", "invalid"]):
+ bg = "#FDECEC"; color = "#991B1B"
+ elif any(k in v for k in ["pending", "queue", "waiting", "processing"]):
+ bg = "#FEF5E6"; color = "#92400E"
+ return f"background-color: {bg}; color:{color}; border-radius: 999px; padding: 4px 8px;"
+
+ return styler.map(badge, subset=pd.IndexSlice[:, [status_col]])
+
+
+def _zebra_style(df: pd.DataFrame) -> "pd.io.formats.style.Styler":
+ df2 = df.reset_index(drop=True)
+
+ def zebra(row: pd.Series):
+ return ["background-color: rgba(2,6,23,0.03);" if (row.name % 2 == 0) else ""] * len(row)
+
+ styler = df2.style.apply(zebra, axis=1)
+ styler = styler.set_table_styles([
+ {"selector": "th", "props": "position: sticky; top: 0; background: #F0F6FF; color:#0F172A; font-weight:700;"},
+ {"selector": "tbody td", "props": "border-top: 1px solid rgba(15,23,42,0.06);"},
+ {"selector": "table", "props": "border-collapse: separate; border-spacing: 0;"},
+ ])
+ styler = styler.hide(axis="index")
+ return styler
+
+
+def _format_two_decimals_for_amounts(styler: "pd.io.formats.style.Styler", df: pd.DataFrame) -> "pd.io.formats.style.Styler":
+ candidates_norm = {"totalamount", "total_amount", "amount", "totalamounts", "totalamounttotals"}
+ targets = []
+ for c in df.columns:
+ if _normalize_name(c) in candidates_norm and pd.api.types.is_numeric_dtype(df[c]):
+ targets.append(c)
+ if targets:
+ styler = styler.format(formatter="{:.2f}", subset=pd.IndexSlice[:, targets])
+ return styler
+
+
+def _format_date_columns(df: pd.DataFrame) -> pd.DataFrame:
+ """Format date columns to show only date part"""
+ df_formatted = df.copy()
+ for col in df_formatted.columns:
+ if 'created_at' in col.lower() or 'date' in col.lower():
+ if pd.api.types.is_datetime64_any_dtype(df_formatted[col]):
+ df_formatted[col] = df_formatted[col].dt.date
+ else:
+ # Try to convert to datetime first
+ try:
+ df_formatted[col] = pd.to_datetime(df_formatted[col]).dt.date
+ except:
+ pass
+ return df_formatted
+
+
+def _pick_existing_columns(df: pd.DataFrame, names: list[str]) -> list[str]:
+ lower_map = {c.lower(): c for c in df.columns}
+ picked = []
+ for n in names:
+ if n.lower() in lower_map:
+ picked.append(lower_map[n.lower()])
+ return picked
+
+
+def _stat_card(title: str, value: int | str, color: str, emoji: str) -> str:
+ return f"""
+
+ """
+
+
+def render_page():
+ if st.session_state.get("auth_user") is None:
+ st.warning("Please login to continue.")
+ st.stop()
+
+ # Store selector (required before loading data view)
+ tenant_id, _ = render_store_selector()
+ if not tenant_id:
+ st.info("Please choose a store to view data.")
+ return
+
+ st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ st.title("DataHub")
+ st.caption("Inspect data from Warehouse.")
+
+ st.info("Connected to database ✅.")
+
+ df = _load_tenant_data(tenant_id)
+
+ status_col_global = _detect_status_column(df)
+
+ if status_col_global:
+ s = df[status_col_global].astype(str).str.lower()
+ ok = s.str_contains("success|ok|completed|done|active").sum() if hasattr(s, 'str_contains') else s.str.contains("success|ok|completed|done|active").sum()
+ bad = s.str_contains("fail|error|dead|invalid").sum() if hasattr(s, 'str_contains') else s.str.contains("fail|error|dead|invalid").sum()
+ pend = s.str_contains("pending|queue|waiting|processing").sum() if hasattr(s, 'str_contains') else s.str.contains("pending|queue|waiting|processing").sum()
+ total = len(df)
+ st.markdown('', unsafe_allow_html=True)
+ c1, c2, c3, c4 = st.columns([1,1,1,2])
+ with c1: st.markdown(_stat_card("Success", ok, "#166534", "✅"), unsafe_allow_html=True)
+ with c2: st.markdown(_stat_card("Failed", bad, "#991B1B", "❌"), unsafe_allow_html=True)
+ with c3: st.markdown(_stat_card("Pending", pend, "#92400E", "⏳"), unsafe_allow_html=True)
+ with c4: st.caption(f"Total rows: {total}")
+ st.markdown('
', unsafe_allow_html=True)
+
+ minimal_names = [
+ "id",
+ "created_at",
+ "outlet_name",
+ "processing_type",
+ "total_amount",
+ "triumph_status",
+ "triumph_event",
+ ]
+ minimal_cols = _pick_existing_columns(df, minimal_names)
+
+ # Controls row: search only
+ q = st.text_input("Search", placeholder="Type to filter rows across all columns")
+
+ # Apply global search
+ filtered = df
+ if q:
+ q_lower = q.lower()
+ filtered = filtered[filtered.apply(lambda r: r.astype(str).str.lower().str.contains(q_lower).any(), axis=1)]
+
+ # Always use minimal columns
+ visible_cols = minimal_cols
+ if visible_cols:
+ filtered = filtered[visible_cols]
+
+ # Pagination (moved below the table; small controls)
+ total_rows = len(filtered)
+ default_page_size = 25
+ total_pages = max(1, (total_rows + default_page_size - 1) // default_page_size)
+ page_num_state_key = "payload_page_num"
+ if page_num_state_key not in st.session_state:
+ st.session_state[page_num_state_key] = 1
+ start = (st.session_state[page_num_state_key] - 1) * default_page_size
+ end = start + default_page_size
+ page_df = filtered.iloc[start:end]
+
+ # Build display names and style
+ display_map = _build_display_map(page_df)
+ display_df = page_df.rename(columns=display_map)
+ # Format date columns
+ display_df = _format_date_columns(display_df)
+ status_col_original = _detect_status_column(page_df)
+ status_col_display = display_map.get(status_col_original)
+
+ styled = _zebra_style(display_df)
+ styled = _format_two_decimals_for_amounts(styled, display_df)
+ # Always apply status badges
+ if status_col_display:
+ styled = _format_status_with_emoji(styled, display_df, status_col_display)
+ styled = _badge_status_cells(styled, display_df, status_col_display)
+ styled = _format_two_decimals_for_amounts(styled, display_df)
+ styled = styled.set_table_styles([
+ {"selector": "th", "props": "position: sticky; top: 0; background: #F0F6FF; color:#0F172A; font-weight:700;"},
+ {"selector": "tbody td", "props": "border-top: 1px solid rgba(15,23,42,0.06);"},
+ {"selector": "table", "props": "border-collapse: separate; border-spacing: 0;"},
+ ]).hide(axis="index")
+
+ st.dataframe(styled, use_container_width=True, height=520)
+
+ # Bottom pagination controls
+ p1, p2, p3 = st.columns([1, 2, 1])
+ with p1:
+ st.caption(f"Showing {len(page_df)} of {total_rows} rows")
+ with p2:
+ st.caption("Page")
+ st.session_state[page_num_state_key] = st.number_input(
+ " ", min_value=1, max_value=total_pages, value=st.session_state[page_num_state_key], step=1, label_visibility="collapsed")
+ with p3:
+ download_df = filtered.rename(columns=_build_display_map(filtered))
+ st.download_button(
+ "Download filtered CSV",
+ data=download_df.to_csv(index=False).encode("utf-8"),
+ file_name="tenantpostings_filtered.csv",
+ use_container_width=True,
+ )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..446841f
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,11 @@
+streamlit>=1.36.0
+sqlalchemy>=2.0.0
+psycopg2-binary>=2.9.9
+bcrypt>=4.1.2
+python-dotenv>=1.0.1
+pydantic>=2.8.2
+cryptography>=42.0.8
+pandas>=2.2.2
+plotly>=5.17.0
+APScheduler>=3.10.4
+tzlocal>=5.3.1
diff --git a/scripts/scheduler_standalone.py b/scripts/scheduler_standalone.py
new file mode 100644
index 0000000..f59fcb8
--- /dev/null
+++ b/scripts/scheduler_standalone.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""
+Standalone scheduler service for Workolik daily email reports.
+This runs independently of the Streamlit application to avoid multiple instances.
+"""
+import os
+import sys
+import logging
+from datetime import datetime
+from zoneinfo import ZoneInfo
+from apscheduler.schedulers.blocking import BlockingScheduler
+from apscheduler.triggers.cron import CronTrigger
+
+# Add the project root to Python path (scripts/ -> project root)
+project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+from app_core.services.daily_report import main as run_daily_report
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+def send_daily_report():
+ """Send the daily report with database-based deduplication using existing email_logs table."""
+ try:
+
+ # Check if we already sent today's report
+ today = datetime.now(ZoneInfo('Asia/Kolkata')).date()
+ today_str = today.strftime('%Y-%m-%d')
+
+ from app_core.db.database import SessionLocal
+ from sqlalchemy import text
+
+ db = SessionLocal()
+ try:
+ # Check if daily report was already sent today using existing email_logs table
+ # Match the exact subject we generate in daily_report.py ("Daily Digest - YYYY-MM-DD")
+ result = db.execute(
+ text("SELECT id FROM email_logs WHERE date_for = :date_for AND subject = :subject LIMIT 1"),
+ {"date_for": today_str, "subject": f"Daily Digest - {today_str}"}
+ ).fetchone()
+
+ if result:
+ logger.info(f"Daily report already sent today ({today}), skipping...")
+ return
+
+ except Exception as e:
+ logger.error(f"Database error checking existing reports: {e}")
+ return
+ finally:
+ db.close()
+
+ logger.info(f"Starting daily report at {datetime.now(ZoneInfo('Asia/Kolkata'))}")
+ result = run_daily_report()
+ if result == 0:
+ logger.info("Daily report sent successfully")
+ else:
+ logger.warning(f"Daily report failed with exit code: {result}")
+
+ except Exception as e:
+ logger.error(f"Error sending daily report: {str(e)}")
+
+def main():
+ """Main scheduler function."""
+ logger.info("Starting Workolik Daily Email Scheduler")
+
+ # Create scheduler
+ scheduler = BlockingScheduler(timezone=ZoneInfo('Asia/Kolkata'))
+
+ # Schedule daily email at 8:00 PM IST (20:00)
+ scheduler.add_job(
+ func=send_daily_report,
+ trigger=CronTrigger(hour=20, minute=0, timezone=ZoneInfo('Asia/Kolkata')),
+ id='daily_email_report',
+ name='Daily Email Report',
+ replace_existing=True
+ )
+
+ logger.info("Daily email scheduler started - will send reports at 8:00 PM IST")
+
+ try:
+ # Keep the scheduler running
+ scheduler.start()
+ except KeyboardInterrupt:
+ logger.info("Scheduler stopped by user")
+ scheduler.shutdown()
+ except Exception as e:
+ logger.error(f"Scheduler error: {e}")
+ scheduler.shutdown()
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/send_past_reports.py b/scripts/send_past_reports.py
new file mode 100644
index 0000000..045969c
--- /dev/null
+++ b/scripts/send_past_reports.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+import os
+import sys
+from datetime import date
+from zoneinfo import ZoneInfo
+
+# Add the project root to Python path
+project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+from app_core.config.settings import AppSettings
+from app_core.services.mailer_service import MailerService
+
+def send_report_for_date(service, settings, report_date):
+ print(f"--- Processing date: {report_date} ---")
+
+ # Check if we should skip if already sent (the user said "we need to send", so I'll skip the check unless specified)
+ # if service.has_sent_for_date(str(report_date)):
+ # print(f"Already sent for {report_date}; skipping.")
+ # return
+
+ df = service.fetch_daily_rows(report_date)
+ if df.empty:
+ print(f"No data for {report_date}. Skipping.")
+ return
+
+ row = df.iloc[0].to_dict()
+ html = service.build_email_html(row, df)
+
+ recipients_env = settings.report_recipients or os.getenv("REPORT_RECIPIENTS")
+ if not recipients_env:
+ print("Error: REPORT_RECIPIENTS env var is empty.")
+ return
+
+ recipients = [r.strip() for r in recipients_env.split(',') if r.strip()]
+ subject = f"Daily Digest - {report_date}"
+
+ print(f"Sending email to: {recipients}")
+ ok, msg = service.send_email(recipients, subject=subject, html=html)
+
+ service.log_email(
+ recipients=recipients,
+ subject=subject,
+ date_for=str(report_date),
+ status="sent" if ok else "failed",
+ error=None if ok else msg
+ )
+
+ if ok:
+ print(f"Successfully sent report for {report_date}")
+ else:
+ print(f"Failed to send report for {report_date}: {msg}")
+
+def main():
+ settings = AppSettings()
+ service = MailerService(settings)
+
+ dates_to_send = [
+ date(2026, 3, 21),
+ date(2026, 3, 22),
+ ]
+
+ for d in dates_to_send:
+ send_report_for_date(service, settings, d)
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/send_specific_report.py b/scripts/send_specific_report.py
new file mode 100644
index 0000000..eb0d9c5
--- /dev/null
+++ b/scripts/send_specific_report.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""
+Send a one-off Daily Digest email for a specific date using the app's template.
+
+Default date: 14.10.2025 (dd.mm.yyyy)
+Default recipient: suriyakumar.vijayanayagam@gmail.com
+
+Usage examples:
+ python scripts/send_specific_report.py
+ python scripts/send_specific_report.py --date 14.10.2025 --to you@example.com
+"""
+
+import argparse
+import os
+import sys
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+# Ensure project root on PYTHONPATH when running from scripts/
+PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if PROJECT_ROOT not in sys.path:
+ sys.path.insert(0, PROJECT_ROOT)
+
+from app_core.config.settings import AppSettings
+from app_core.services.mailer_service import MailerService
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Send a daily digest for a specific date")
+ parser.add_argument(
+ "--date",
+ help="Target date in dd.mm.yyyy format (e.g., 14.10.2025)",
+ default="14.10.2025",
+ )
+ parser.add_argument(
+ "--to",
+ help="Recipient email (comma-separated for multiple)",
+ default="suriyakumar.vijayanayagam@gmail.com",
+ )
+ return parser.parse_args()
+
+
+def parse_ddmmyyyy(value: str) -> datetime.date:
+ try:
+ return datetime.strptime(value, "%d.%m.%Y").date()
+ except ValueError as ex:
+ raise SystemExit(f"Invalid date '{value}'. Use dd.mm.yyyy (e.g., 14.10.2025)") from ex
+
+
+def main() -> int:
+ args = parse_args()
+ target_date = parse_ddmmyyyy(args.date)
+ recipients = [e.strip() for e in args.to.split(",") if e.strip()]
+
+ print("=" * 60)
+ print(f"SENDING Daily Digest for {target_date} to: {', '.join(recipients)}")
+ print("=" * 60)
+
+ settings = AppSettings()
+ service = MailerService(settings)
+
+ # Fetch rows for the date
+ df = service.fetch_daily_rows(target_date)
+ if df.empty:
+ print(f"❌ No rows found for {target_date}. Nothing to send.")
+ return 1
+
+ # Build HTML using first row context + full DataFrame for per-store summary
+ row = df.iloc[0].to_dict()
+ html = service.build_email_html(row, df)
+
+ subject = f"Daily Digest - {target_date}"
+ print(f"Subject: {subject}")
+
+ ok, msg = service.send_email(recipients, subject, html)
+ if ok:
+ print("Email sent successfully")
+ service.log_email(recipients, subject, str(target_date), "sent", None)
+ print("Logged in database")
+ return 0
+ else:
+ print(f"Email failed: {msg}")
+ service.log_email(recipients, subject, str(target_date), "failed", msg)
+ print("Failure logged in database")
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+
+
diff --git a/scripts/test_mail.py b/scripts/test_mail.py
new file mode 100644
index 0000000..889afe1
--- /dev/null
+++ b/scripts/test_mail.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""
+Single test file for mail service - does everything in one place
+"""
+from datetime import datetime, date
+from zoneinfo import ZoneInfo
+
+print("=" * 60)
+print("📧 MAIL SERVICE TEST")
+print("=" * 60)
+
+try:
+ from app_core.services.mailer_service import MailerService
+ from app_core.config.settings import AppSettings
+
+ # Initialize services
+ settings = AppSettings()
+ service = MailerService(settings)
+
+ # Get most recent date with data
+ chosen_date = service.select_report_date()
+ if not chosen_date:
+ print("❌ No data available")
+ exit(1)
+
+ print(f"✅ Using date: {chosen_date}")
+
+ # Fetch data
+ df = service.fetch_daily_rows(chosen_date)
+ print(f"✅ Found {len(df)} records")
+
+ # Build email
+ row = df.iloc[0].to_dict()
+ html = service.build_email_html(row, df)
+ print(f"✅ Email HTML generated ({len(html)} characters)")
+
+ # Show what would be logged
+ ist = ZoneInfo("Asia/Kolkata")
+ now_ist = datetime.now(ist)
+
+ print(f"\n📝 Data that would be inserted in email_logs:")
+ print(f" sent_at: {now_ist}")
+ print(f" recipients: loyalydigital@gmail.com")
+ print(f" subject: Daily Digest - {chosen_date}")
+ print(f" status: sent")
+ print(f" date_for: {chosen_date}")
+ print(f" error: null")
+
+ # Ask user
+ print(f"\n🚀 Send email to loyalydigital@gmail.com? (y/n):")
+ send_confirm = input(" Send? ").strip().lower()
+
+ if send_confirm == 'y':
+ print(f"\n📤 Sending email...")
+
+ recipients = ["loyalydigital@gmail.com"]
+ subject = f"Daily Digest - {chosen_date}"
+
+ ok, msg = service.send_email(recipients, subject, html)
+
+ if ok:
+ print(f"✅ Email sent successfully!")
+ service.log_email(recipients, subject, str(chosen_date), "sent", None)
+ print(f"✅ Logged in database")
+ else:
+ print(f"❌ Email failed: {msg}")
+ service.log_email(recipients, subject, str(chosen_date), "failed", msg)
+ print(f"✅ Failed attempt logged")
+ else:
+ print(f"\n⏭️ Email not sent (test mode)")
+
+ # Show recent logs
+ print(f"\n📋 Recent email logs:")
+ logs = service.recent_logs(limit=5)
+ for log in logs:
+ print(f" {log['status']} - {log['subject']} at {log['sent_at']}")
+
+except Exception as e:
+ print(f"❌ Error: {e}")
+ import traceback
+ traceback.print_exc()
+
+print(f"\n" + "=" * 60)
+print("🏁 Done!")
+print("=" * 60)
diff --git a/scripts/validate_setup.py b/scripts/validate_setup.py
new file mode 100644
index 0000000..3b71ec5
--- /dev/null
+++ b/scripts/validate_setup.py
@@ -0,0 +1,104 @@
+import os
+import sys
+from datetime import datetime
+
+# Ensure project root is on PYTHONPATH when running from scripts/
+project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if project_root not in sys.path:
+ sys.path.insert(0, project_root)
+
+from app_core.config.settings import AppSettings
+from app_core.db.database import engine
+from sqlalchemy import text
+
+
+def check_env(settings: AppSettings) -> list[str]:
+ missing: list[str] = []
+ required = [
+ ("DATABASE_URL", settings.database_url or os.getenv("DATABASE_URL")),
+ ("SMTP_HOST", settings.smtp_host),
+ ("SMTP_PORT", settings.smtp_port),
+ ("SMTP_USER", settings.smtp_user),
+ ("SMTP_PASSWORD", settings.smtp_password),
+ ("SMTP_FROM_EMAIL", settings.smtp_from_email),
+ ("REPORT_RECIPIENTS", settings.report_recipients or os.getenv("REPORT_RECIPIENTS")),
+ ]
+ for key, val in required:
+ if not val:
+ missing.append(key)
+ return missing
+
+
+def check_db_connection() -> tuple[bool, str | None]:
+ try:
+ with engine.connect() as conn:
+ # SQLAlchemy 2.0: wrap SQL in text() or use exec_driver_sql
+ conn.execute(text("SELECT 1"))
+ return True, None
+ except Exception as e:
+ return False, str(e)
+
+
+def check_smtp_login(s: AppSettings) -> tuple[bool, str | None]:
+ import smtplib
+
+ try:
+ server = smtplib.SMTP(s.smtp_host, s.smtp_port, timeout=20)
+ if s.smtp_use_tls:
+ server.starttls()
+ if s.smtp_user and s.smtp_password:
+ server.login(s.smtp_user, s.smtp_password)
+ # Probe NOOP and quit without sending
+ server.noop()
+ server.quit()
+ return True, None
+ except Exception as e:
+ return False, str(e)
+
+
+def main() -> int:
+ print("=== Workolik Production Validation ===")
+ settings = AppSettings()
+
+ # 1) Environment variables
+ missing = check_env(settings)
+ if missing:
+ print("❌ Missing required env vars:", ", ".join(sorted(set(missing))))
+ else:
+ print("✅ Required env vars present")
+
+ # Optional BCC
+ bcc = os.getenv("BCC_RECIPIENTS", "").strip()
+ if bcc:
+ print(f"✅ BCC_RECIPIENTS set: {bcc}")
+ else:
+ print("ℹ️ BCC_RECIPIENTS not set (no BCC will be added)")
+
+ # 2) Database connectivity
+ ok_db, err_db = check_db_connection()
+ if ok_db:
+ print("✅ Database connectivity OK")
+ else:
+ print(f"❌ Database connectivity FAILED: {err_db}")
+
+ # 3) SMTP connectivity (no email will be sent)
+ ok_smtp, err_smtp = check_smtp_login(settings)
+ if ok_smtp:
+ print("✅ SMTP login OK (no email sent)")
+ else:
+ print(f"❌ SMTP login FAILED: {err_smtp}")
+
+ # 4) Scheduler subject check (ensure dedupe matches)
+ today_str = datetime.now().date().strftime('%Y-%m-%d')
+ expected_subject = f"Daily Digest - {today_str}"
+ print(f"✅ Scheduler dedupe subject pattern: {expected_subject}")
+
+ failures = (1 if missing else 0) + (0 if ok_db else 1) + (0 if ok_smtp else 1)
+ print("=== Validation Complete ===")
+ return 0 if failures == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+
+
diff --git a/scripts/verify_scheduler.py b/scripts/verify_scheduler.py
new file mode 100644
index 0000000..469be24
--- /dev/null
+++ b/scripts/verify_scheduler.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+print("🔍 VERIFYING SCHEDULER SETUP")
+print("=" * 50)
+
+# Check current time
+ist = ZoneInfo('Asia/Kolkata')
+now = datetime.now(ist)
+print(f"Current IST time: {now.strftime('%Y-%m-%d %H:%M:%S %Z')}")
+
+# Check 8 PM today
+eight_pm = now.replace(hour=20, minute=0, second=0, microsecond=0)
+print(f"8:00 PM today: {eight_pm.strftime('%Y-%m-%d %H:%M:%S %Z')}")
+
+# Test scheduler
+try:
+ from app_core.services.scheduler_service import SchedulerService
+ s = SchedulerService()
+ s.start_scheduler()
+ print(f"✅ Scheduler started: {s.is_running()}")
+
+ next_run = s.get_next_run_time()
+ if next_run:
+ next_run_ist = next_run.astimezone(ist)
+ print(f"✅ Next run: {next_run_ist.strftime('%Y-%m-%d %H:%M:%S %Z')}")
+ else:
+ print("❌ No next run time found")
+
+ s.stop_scheduler()
+ print("✅ Scheduler stopped")
+
+except Exception as e:
+ print(f"❌ Scheduler error: {e}")
+
+# Test daily report
+try:
+ from app_core.services.daily_report import main
+ print("\n🧪 Testing daily report...")
+ result = main()
+ print(f"✅ Daily report result: {result}")
+except Exception as e:
+ print(f"❌ Daily report error: {e}")
+
+print("\n" + "=" * 50)
+print("✅ VERIFICATION COMPLETE")
+print("=" * 50)