Bot & Automation

jorgasisten

/root/hermes-projects/jorgasisten

apps/jorgasisten/services/moq_checker.py text
import logging
import re
from typing import Any

from jorgasisten.core.config import Settings, get_settings
from jorgasisten.services.sheets import GoogleSheetsClient

logger = logging.getLogger(__name__)

class MOQCheckerService:
    def __init__(self, settings: Settings | None = None) -> None:
        self.settings = settings or get_settings()

    def check_moq_for_spreadsheet(self, spreadsheet_id: str) -> tuple[bool, list[dict[str, Any]], str]:
        """
        Checks current stock against MOQ limits for a specific spreadsheet ID.
        Returns:
            (success, list_of_low_stock_items, status_message)
        """
        sheets_client = GoogleSheetsClient(self.settings, spreadsheet_id=spreadsheet_id)
        try:
            catalog = sheets_client.get_catalog()
        except Exception as exc:
            logger.exception("Failed to fetch sheet catalog for MOQ check: %s", exc)
            return False, [], f"Gagal membaca katalog spreadsheet: {exc}"

        sheet_titles = [schema.title for schema in catalog]

        # Find MOQ sheet
        moq_title = next((t for t in sheet_titles if t.lower() == "moq"), None)
        if not moq_title:
            return False, [], "Sheet 'MOQ' tidak ditemukan di spreadsheet ini."

        # Find Stock sheet
        stock_title = next(
            (t for t in sheet_titles if t.lower() in {"stok barang total", "stok", "stock", "inventaris"}),
            None,
        )
        if not stock_title:
            # Fallback to any sheet containing 'stok' or 'stock'
            stock_title = next(
                (t for t in sheet_titles if "stok" in t.lower() or "stock" in t.lower()),
                None,
            )

        if not stock_title:
            return False, [], "Sheet stok/stock tidak ditemukan di spreadsheet ini."

        logger.info("Running MOQ check: MOQ sheet='%s', Stock sheet='%s'", moq_title, stock_title)

        try:
            moq_rows = sheets_client.find_rows(moq_title, {}, limit=999999)
            stock_rows = sheets_client.find_rows(stock_title, {}, limit=999999)
        except Exception as exc:
            logger.exception("Failed to fetch rows for MOQ check: %s", exc)
            return False, [], f"Gagal mengambil baris data sheet: {exc}"

        # Parse MOQ limits
        moq_limits: dict[str, float] = {}
        for row in moq_rows:
            # Find item name
            item_name = self._find_value_by_key_patterns(row.values, ["nama barang", "nama_barang", "nama", "item"])
            if not item_name:
                continue

            # Find MOQ value
            moq_val = self._parse_number(
                self._find_value_by_key_patterns(row.values, ["column_4", "stok minimum", "moq", "stok_minimum"])
            )
            if moq_val is None:
                continue

            normalized_name = self._normalize_name(item_name)
            moq_limits[normalized_name] = moq_val

        if not moq_limits:
            return True, [], "Tidak ada aturan MOQ minimum yang valid atau terisi di sheet MOQ."

        # Parse stock quantities
        stock_data: dict[str, dict[str, Any]] = {}
        for row in stock_rows:
            item_name = self._find_value_by_key_patterns(row.values, ["nama barang", "nama_barang", "nama", "item"])
            if not item_name:
                continue

            # Find stock quantity
            patterns = ["stok akhir", "stok_akhir", "stok", "stock", "kuantiti", "qty"]
            stock_qty = self._parse_number(
                self._find_value_by_key_patterns(row.values, patterns)
            )
            if stock_qty is None:
                stock_qty = 0.0

            sku = self._find_value_by_key_patterns(row.values, ["kode barang", "kode_barang", "sku", "kode"]) or ""

            normalized_name = self._normalize_name(item_name)
            stock_data[normalized_name] = {
                "sku": sku,
                "name": item_name,
                "qty": stock_qty,
            }

        # Compare stock with MOQ
        low_stock_items: list[dict[str, Any]] = []
        for norm_name, moq_limit in moq_limits.items():
            stock_item = stock_data.get(norm_name)
            current_qty = stock_item["qty"] if stock_item else 0.0
            sku = stock_item["sku"] if stock_item else ""
            display_name = stock_item["name"] if stock_item else norm_name.title()

            if current_qty < moq_limit:
                low_stock_items.append({
                    "sku": sku,
                    "name": display_name,
                    "qty": current_qty,
                    "moq": moq_limit,
                })

        return True, low_stock_items, "Sukses memeriksa MOQ."

    def format_moq_alert_message(self, low_stock_items: list[dict[str, Any]], spreadsheet_name: str) -> str:
        """
        Formats the alert message for low stock items.
        """
        if not low_stock_items:
            return (
                f"✅ **Semua stok aman** di spreadsheet **{spreadsheet_name}**.\n"
                "Tidak ada barang di bawah MOQ saat ini."
            )

        lines = [
            "⚠️ **PERINGATAN STOK MENIPIS (DI BAWAH MOQ)** ⚠️",
            f"Spreadsheet: **{spreadsheet_name}**",
            f"Ditemukan **{len(low_stock_items)}** barang yang harus segera di-restock:\n",
        ]

        for idx, item in enumerate(low_stock_items, start=1):
            sku_str = f" `{item['sku']}`" if item["sku"] else ""
            qty_val = int(item["qty"]) if item["qty"].is_integer() else item["qty"]
            moq_val = int(item["moq"]) if item["moq"].is_integer() else item["moq"]
            lines.append(
                f"{idx}.{sku_str} **{item['name']}**\n"
                f"   Detail: Stok saat ini **{qty_val}** (Batas MOQ: **{moq_val}**)\n"
            )

        lines.append("👇 Silakan lakukan re-stock barang di atas secepatnya.")
        return "\n".join(lines)

    def _normalize_name(self, name: str) -> str:
        return re.sub(r"\s+", " ", str(name).strip().lower())

    def _find_value_by_key_patterns(self, data: dict[str, Any], patterns: list[str]) -> Any:
        for pattern in patterns:
            pattern_norm = pattern.lower().replace("_", " ").replace(" ", "")
            for key, val in data.items():
                key_norm = key.lower().replace("_", " ").replace(" ", "")
                if pattern_norm in key_norm or key_norm in pattern_norm:
                    return val
        return None

    def _parse_number(self, value: Any) -> float | None:
        if value is None:
            return None
        val_str = str(value).strip().replace(",", ".")
        if not val_str:
            return None
        try:
            return float(val_str)
        except ValueError:
            return None