"""Centralized market conventions & domain defaults for procurement bot. This module isolates all procurement business heuristics and defaults (equal installments calculation, end-of-solar-month due dates, validity rules per material, project assignment logic, official vs unofficial defaults). drafts.py and nlu.py consume this module directly. """ from datetime import date, datetime, timedelta, timezone # --------------------------------------------------------------------------- # Jalali Calendar math (Pure Python, zero-dependency) # --------------------------------------------------------------------------- JALALI_MONTH_NAMES = [ "", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند" ] def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]: g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334] gy2 = gy if gm > 2 else gy - 1 days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1] jy = -1595 + (33 * (days // 12053)) days %= 12053 jy += 4 * (days // 1461) days %= 1461 if days > 365: jy += (days - 1) // 365 days = (days - 1) % 365 if days < 186: jm = 1 + (days // 31) jd = 1 + (days % 31) else: jm = 7 + ((days - 186) // 30) jd = 1 + ((days - 186) % 30) return jy, jm, jd def j2g(jy: int, jm: int, jd: int) -> tuple[int, int, int]: jy2 = jy + 1595 days = -355668 + (365 * jy2) + ((jy2 // 33) * 8) + (((jy2 % 33) + 3) // 4) + jd if jm < 7: days += (jm - 1) * 31 else: days += ((jm - 7) * 30) + 186 gy = 400 * (days // 146097) days %= 146097 if days > 36524: days -= 1 gy += 100 * (days // 36524) days %= 36524 if days >= 365: days += 1 gy += 4 * (days // 1461) days %= 1461 if days > 365: gy += (days - 1) // 365 days = (days - 1) % 365 gd = days + 1 sal_a = [0, 31, 29 if ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] gm = 0 while gm < 13 and gd > sal_a[gm]: gd -= sal_a[gm] gm += 1 return gy, gm, gd def is_jalali_leap(jy: int) -> bool: return (((jy + 38) * 31) % 128) <= 31 def jalali_month_days(jy: int, jm: int) -> int: if 1 <= jm <= 6: return 31 elif 7 <= jm <= 11: return 30 elif jm == 12: return 30 if is_jalali_leap(jy) else 29 return 30 def today_jalali(ref_date: date | None = None) -> tuple[int, int, int]: d = ref_date or date.today() return g2j(d.year, d.month, d.day) def jalali_to_iso(jy: int, jm: int, jd: int) -> str: gy, gm, gd = j2g(jy, jm, jd) return f"{gy:04d}-{gm:02d}-{gd:02d}" def jalali_format(jy: int, jm: int, jd: int) -> str: return f"{jy:04d}/{jm:02d}/{jd:02d}" def jalali_month_name(jm: int) -> str: return JALALI_MONTH_NAMES[jm] if 1 <= jm <= 12 else "" # --------------------------------------------------------------------------- # Market Conventions & Constants # --------------------------------------------------------------------------- DEFAULT_IS_OFFICIAL = True # Standard validity days in volatile markets (materials) DEFAULT_VALIDITY_DAYS_MAP: dict[str, int] = { "آهن": 1, "میلگرد": 1, "تیرآهن": 1, "ورق": 1, "پروفیل": 2, "سیمان": 3, "گچ": 7, "لوله": 7, "اتصالات": 7, "شیرآلات": 7, "شن": 7, "ماسه": 7, } FALLBACK_VALIDITY_DAYS: int = 7 def compute_validity_date(materials: list[str] | None = None, explicit_days: int | None = None, ref_date: date | None = None) -> tuple[str, str]: """Compute (iso_date, jalali_date_str) based on material or explicit days.""" base = ref_date or date.today() if explicit_days is not None and explicit_days > 0: days = explicit_days else: days = FALLBACK_VALIDITY_DAYS if materials: for m in materials: m_str = (m or "").lower() for k, v in DEFAULT_VALIDITY_DAYS_MAP.items(): if k in m_str: days = min(days, v) target = base + timedelta(days=days) jy, jm, jd = g2j(target.year, target.month, target.day) return f"{target.year:04d}-{target.month:02d}-{target.day:02d}", jalali_format(jy, jm, jd) # --------------------------------------------------------------------------- # Payment Terms Generators (Heuristics) # --------------------------------------------------------------------------- def build_equal_installments( total_amount: float, count: int, ref_date: date | None = None, ) -> list[dict]: """Build equal installments milestones. - 1st milestone: today (or ref_date) - 2nd..nth milestones: end of subsequent Jalali months Amounts: total_amount / count, rounded to thousands, with remainder added to last milestone. """ if count < 1: count = 1 base = ref_date or date.today() curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day) base_per_installment = int(total_amount // count) if total_amount > 0 else 0 remainder = int(total_amount - (base_per_installment * count)) if total_amount > 0 else 0 milestones: list[dict] = [] # Milestone 1: today m1_iso = f"{base.year:04d}-{base.month:02d}-{base.day:02d}" m1_jalali = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}" milestones.append({ "due_date": m1_iso, "due_date_jalali": m1_jalali, "amount": base_per_installment, "note": f"قسط اول (امروز - {curr_jd} {jalali_month_name(curr_jm)})", }) # Milestone 2..N: end of consecutive Jalali months target_jy = curr_jy target_jm = curr_jm for i in range(2, count + 1): target_jm += 1 if target_jm > 12: target_jm = 1 target_jy += 1 end_day = jalali_month_days(target_jy, target_jm) iso_str = jalali_to_iso(target_jy, target_jm, end_day) jalali_str = f"{target_jy}/{target_jm:02d}/{end_day:02d}" amt = base_per_installment + (remainder if i == count else 0) m_name = jalali_month_name(target_jm) milestones.append({ "due_date": iso_str, "due_date_jalali": jalali_str, "amount": amt, "note": f"قسط {i} (پایان {m_name})", }) return milestones def build_cash_today_termsheet( total_amount: float, discount_percent: float = 0.0, ref_date: date | None = None, ) -> dict: """Build cash today payment termsheet.""" base = ref_date or date.today() curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day) iso_date = f"{base.year:04d}-{base.month:02d}-{base.day:02d}" jalali_str = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}" discount_val = 0.0 payable = total_amount if discount_percent > 0 and total_amount > 0: discount_val = round((total_amount * discount_percent) / 100.0) payable = total_amount - discount_val disc_label = f" (تخفیف {int(discount_percent)}٪)" if discount_percent > 0 else "" return { "label": f"پرداخت یکباره امروز{disc_label}", "discount_type": "percent" if discount_percent > 0 else "none", "discount_value": discount_percent if discount_percent > 0 else 0.0, "milestones": [ { "due_date": iso_date, "due_date_jalali": jalali_str, "amount": payable, "note": "تسویه نقدی یکباره (امروز)", } ], "description": f"مبلغ فاکتور: {int(total_amount):,} | تخفیف: {int(discount_val):,} | خالص پرداختی: {int(payable):,}", } def propose_project_distribution(project_names: list[str], items: list[dict]) -> list[tuple[str, str]]: """Propose assignment of items to projects when multiple projects exist. Returns list of (item_material_name, assigned_project_name). """ clean_projects = [p.strip() for p in project_names if p and p.strip()] if not clean_projects or not items: return [] proposals: list[tuple[str, str]] = [] p_len = len(clean_projects) for idx, it in enumerate(items): assigned_p = clean_projects[idx % p_len] mat_name = it.get("material_name") or f"قلم {idx + 1}" proposals.append((mat_name, assigned_p)) return proposals # --------------------------------------------------------------------------- # Authorized Whitelist Users (Static Role Configuration) # --------------------------------------------------------------------------- AUTHORIZED_USERS: dict[int, dict] = { 1461166742: { "user_id": "03091956-99ad-4729-923a-a6aa1c5c13be", "display_name": "مدیر تدارکات", "role": "procurement_manager", "is_admin": True, }, 1839839922: { "user_id": "03091956-99ad-4729-923a-a6aa1c5c13be", "display_name": "همکار / تست", "role": "procurement_manager", "is_admin": False, }, }