253 lines
8.4 KiB
Python
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
|