Files
ERP/bot/app/defaults.py
T

253 lines
8.4 KiB
Python

"""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