VoiceLedger / voiceledger /ledger /analytics.py
Sagar Patel
Finalize i18n, AI coach, receipt scanning, and UI polish for hackathon submission
edd2d73
Raw
History Blame Contribute Delete
6.11 kB
"""Business analytics helpers for VoiceLedger."""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pandas as pd
from voiceledger.ledger.customers import get_customer_balances
from voiceledger.ledger.database import get_transactions
from voiceledger.ledger.inventory import get_inventory
from voiceledger.ledger.settings import get_business_settings
DEFAULT_LOW_STOCK_THRESHOLD = 5
def calculate_daily_sales(db_path: str | Path | None = None, report_date: date | None = None) -> float:
"""Return total sales for the selected day."""
transactions = _transactions_for_date(db_path, report_date)
return _sum_amounts(transactions, ["sale"])
def calculate_daily_expenses(db_path: str | Path | None = None, report_date: date | None = None) -> float:
"""Return total expenses for the selected day."""
transactions = _transactions_for_date(db_path, report_date)
return _sum_amounts(transactions, ["expense", "inventory_purchase"])
def calculate_net_profit(db_path: str | Path | None = None, report_date: date | None = None) -> float:
"""Return sales minus expenses for the selected day."""
sales = calculate_daily_sales(db_path=db_path, report_date=report_date)
expenses = calculate_daily_expenses(db_path=db_path, report_date=report_date)
return round(sales - expenses, 2)
def top_selling_items(
db_path: str | Path | None = None,
report_date: date | None = None,
limit: int = 5,
) -> pd.DataFrame:
"""Return top selling items by sold quantity for the selected day."""
transactions = _transactions_for_date(db_path, report_date)
if transactions.empty:
return _empty_top_items_frame()
sales = transactions[
(transactions["transaction_type"] == "sale")
& transactions["item"].notna()
& transactions["quantity"].notna()
].copy()
if sales.empty:
return _empty_top_items_frame()
sales["quantity"] = pd.to_numeric(sales["quantity"], errors="coerce").fillna(0)
sales["amount"] = pd.to_numeric(sales["amount"], errors="coerce").fillna(0)
grouped = (
sales.groupby("item", as_index=False)
.agg(quantity_sold=("quantity", "sum"), sales_amount=("amount", "sum"))
.sort_values(["quantity_sold", "sales_amount"], ascending=[False, False])
.head(limit)
)
return grouped.reset_index(drop=True)
def outstanding_credit(db_path: str | Path | None = None) -> float:
"""Return total outstanding customer credit."""
balances = get_customer_balances(db_path)
if balances.empty or "outstanding_balance" not in balances:
return 0.0
total = pd.to_numeric(balances["outstanding_balance"], errors="coerce").fillna(0).sum()
return round(float(total), 2)
def low_stock_items(
db_path: str | Path | None = None,
threshold: float = DEFAULT_LOW_STOCK_THRESHOLD,
) -> pd.DataFrame:
"""Return inventory rows where current stock is below the threshold."""
inventory = get_inventory(db_path)
if inventory.empty or "current_stock" not in inventory:
return pd.DataFrame(columns=["item", "current_stock"])
stock = inventory.copy()
stock["current_stock"] = pd.to_numeric(stock["current_stock"], errors="coerce").fillna(0)
low_stock = stock[stock["current_stock"] < threshold]
return low_stock.sort_values(["current_stock", "item"], ascending=[True, True]).reset_index(drop=True)
def get_business_summary(db_path: str | Path | None = None, report_date: date | None = None) -> str:
"""Return a text summary of today's business performance for LLM analysis."""
settings = get_business_settings(db_path)
currency = settings["currency_symbol"]
sales = calculate_daily_sales(db_path, report_date)
expenses = calculate_daily_expenses(db_path, report_date)
profit = calculate_net_profit(db_path, report_date)
credit = outstanding_credit(db_path)
top_items = top_selling_items(db_path, report_date, limit=3)
threshold = float(settings.get("low_stock_threshold", DEFAULT_LOW_STOCK_THRESHOLD))
low_stock = low_stock_items(db_path, threshold=threshold)
summary = [
f"Business: {settings['business_name']}",
f"Date: {report_date or date.today()}",
f"Total Sales: {currency}{sales}",
f"Total Expenses: {currency}{expenses}",
f"Net Profit: {currency}{profit}",
f"Outstanding Customer Credit: {currency}{credit}",
]
if not top_items.empty:
items_str = ", ".join([f"{row['item']} ({row['quantity_sold']} sold)" for _, row in top_items.iterrows()])
summary.append(f"Top Selling Items: {items_str}")
if not low_stock.empty:
low_stock_str = ", ".join([f"{row['item']} ({row['current_stock']} left)" for _, row in low_stock.iterrows()])
summary.append(f"Low Stock Alerts: {low_stock_str}")
return "\n".join(summary)
def _transactions_for_date(db_path: str | Path | None, report_date: date | None) -> pd.DataFrame:
"""Return transactions for the selected local date."""
selected_date = report_date or date.today()
transactions = get_transactions(db_path)
if transactions.empty or "created_at" not in transactions:
return transactions
created_dates = pd.to_datetime(transactions["created_at"], errors="coerce").dt.date
return transactions.loc[created_dates == selected_date].copy()
def _sum_amounts(transactions: pd.DataFrame, transaction_types: list[str]) -> float:
"""Sum transaction amounts for selected transaction types."""
if transactions.empty or "transaction_type" not in transactions or "amount" not in transactions:
return 0.0
filtered = transactions[transactions["transaction_type"].isin(transaction_types)]
if filtered.empty:
return 0.0
total = pd.to_numeric(filtered["amount"], errors="coerce").fillna(0).sum()
return round(float(total), 2)
def _empty_top_items_frame() -> pd.DataFrame:
"""Return an empty top selling items frame with stable columns."""
return pd.DataFrame(columns=["item", "quantity_sold", "sales_amount"])