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