feat(bot): overhaul quote review card template with Jalali dates and DB-aligned validations
This commit is contained in:
@@ -300,14 +300,14 @@ def compute_quote_missing(payload: dict) -> list[dict]:
|
||||
add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True)
|
||||
if not item.get("quantity"):
|
||||
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
|
||||
if not (item.get("unit_id") or item.get("unit_name")):
|
||||
add(f"item:{i}:unit", f"واحد «{name}»", False, True)
|
||||
if not item.get("unit_id"):
|
||||
add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True)
|
||||
if not item.get("unit_price"):
|
||||
add(f"item:{i}:price", f"قیمت واحد «{name}»", False, False)
|
||||
add(f"item:{i}:price", f"قیمت واحد برای «{name}»", False, False)
|
||||
if items and not payload.get("termsheets"):
|
||||
add("terms", "شرایط پرداخت", False, False)
|
||||
if not payload.get("notes"):
|
||||
add("notes", "یادداشت", False, False)
|
||||
add("notes", "توضیحات", False, False)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -57,8 +57,13 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
|
||||
if res.resolved:
|
||||
payload["supplier_id"] = res.candidates[0].id
|
||||
payload["supplier_name"] = res.candidates[0].name
|
||||
c = res.candidates[0]
|
||||
payload["supplier_id"] = c.id
|
||||
payload["supplier_name"] = c.name
|
||||
extra = c.extra or {}
|
||||
for k in ("location", "phone", "contact_person", "category_name"):
|
||||
if extra.get(k):
|
||||
payload[f"supplier_{k}"] = extra[k]
|
||||
options.pop("sup", None)
|
||||
elif res.candidates:
|
||||
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
|
||||
@@ -91,7 +91,19 @@ class Resolver:
|
||||
|
||||
async def suppliers(self, q: str | None = None) -> list[Candidate]:
|
||||
rows = await self._api.list_suppliers(q=q, page_size=20)
|
||||
items = [Candidate(id=str(s["id"]), name=s.get("name") or str(s["id"])) for s in rows]
|
||||
items = [
|
||||
Candidate(
|
||||
id=str(s["id"]),
|
||||
name=s.get("name") or str(s["id"]),
|
||||
extra={
|
||||
"location": s.get("location"),
|
||||
"phone": s.get("phone"),
|
||||
"contact_person": s.get("contact_person"),
|
||||
"category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None,
|
||||
},
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
return _rank(q, items) if q else items
|
||||
|
||||
async def materials(self, q: str | None = None) -> list[Candidate]:
|
||||
|
||||
+234
-56
@@ -18,10 +18,71 @@ def esc(value) -> str:
|
||||
return html.escape(str(value if value is not None else "—"))
|
||||
|
||||
|
||||
def fa_date(value: str | None) -> str:
|
||||
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 fa_num(val, use_fa_digits: bool = True) -> str:
|
||||
"""Format integer/float with thousands separator and Persian digits."""
|
||||
if val is None or val == "":
|
||||
return "—"
|
||||
try:
|
||||
num = float(val)
|
||||
if num.is_integer():
|
||||
s = f"{int(num):,}"
|
||||
else:
|
||||
s = f"{num:,.2f}".rstrip("0").rstrip(".")
|
||||
if use_fa_digits:
|
||||
trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬")
|
||||
return s.translate(trans)
|
||||
return s
|
||||
except (ValueError, TypeError):
|
||||
return str(val)
|
||||
|
||||
|
||||
def fa_digits(val) -> str:
|
||||
"""Convert ASCII digits to Persian digits without thousands separators."""
|
||||
if not val:
|
||||
return ""
|
||||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
return str(val).translate(trans)
|
||||
|
||||
|
||||
def fa_date(value, use_fa_digits: bool = True) -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
return str(value)[:10]
|
||||
s = str(value)[:10]
|
||||
try:
|
||||
parts = [int(p) for p in s.replace("/", "-").split("-")]
|
||||
if len(parts) == 3 and parts[0] > 1900:
|
||||
jy, jm, jd = g2j(parts[0], parts[1], parts[2])
|
||||
res = f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||||
if use_fa_digits:
|
||||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
return res.translate(trans)
|
||||
return res
|
||||
except Exception:
|
||||
pass
|
||||
if use_fa_digits:
|
||||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||
return s.translate(trans)
|
||||
return s
|
||||
|
||||
|
||||
WELCOME = (
|
||||
@@ -90,79 +151,196 @@ ACCEPT_DONE_TS = "شرایط انتخابشده: {label}"
|
||||
|
||||
|
||||
def missing_block(missing: list[dict]) -> str:
|
||||
"""✅/❌ list of what's captured vs. still empty (⚠️ = nice-to-have)."""
|
||||
"""Checklist of missing vs. captured fields."""
|
||||
if not missing:
|
||||
return "همه فیلدها کامل است ✅"
|
||||
lines = []
|
||||
for m in missing:
|
||||
mark = MISSING_MARKS[m["ok"]] if m["blocking"] else (MISSING_MARKS.get(m["ok"], WARNING_MARK))
|
||||
if not m["blocking"] and not m["ok"]:
|
||||
mark = WARNING_MARK
|
||||
lines.append(f"{mark} {m['label']}")
|
||||
if m.get("ok"):
|
||||
mark = "✅"
|
||||
elif m.get("blocking"):
|
||||
mark = "❌"
|
||||
else:
|
||||
mark = "⚠️"
|
||||
lines.append(f"{mark} {esc(m.get('label'))}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
|
||||
lines = ["🧾 پیشنویس استعلام:"]
|
||||
lines = ["📋 <b>پیشنویس استعلام</b>"]
|
||||
|
||||
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing)
|
||||
has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
|
||||
|
||||
if not has_blocking and not has_warnings:
|
||||
lines.append("✅ همهی اطلاعات کامل است، آماده ثبت.")
|
||||
elif has_blocking:
|
||||
lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.")
|
||||
else:
|
||||
lines.append("⚠️ پیشنویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.")
|
||||
|
||||
if status_note:
|
||||
lines.append(status_note)
|
||||
sup = payload.get("supplier_name") or "—"
|
||||
|
||||
lines.append("━━━━━━━━━━━━━━\n")
|
||||
|
||||
# 🏢 تأمینکننده
|
||||
lines.append("🏢 <b>تأمینکننده</b>")
|
||||
sup_name = payload.get("supplier_name")
|
||||
is_official = payload.get("is_official", True)
|
||||
official_badge = " (رسمی)" if is_official else " (غیررسمی)"
|
||||
|
||||
if payload.get("supplier_id"):
|
||||
sup_label = f"{esc(sup)} ✅"
|
||||
elif payload.get("supplier_name"):
|
||||
sup_label = f"{esc(sup)} ✨ (تأمینکننده جدید)"
|
||||
lines.append(f"<b>{esc(sup_name)}</b>{official_badge} ✅")
|
||||
if payload.get("supplier_location"):
|
||||
lines.append(f"📍 {esc(payload['supplier_location'])}")
|
||||
contact_parts = []
|
||||
if payload.get("supplier_phone"):
|
||||
contact_parts.append(fa_digits(payload["supplier_phone"]))
|
||||
if payload.get("supplier_contact_person"):
|
||||
contact_parts.append(esc(payload["supplier_contact_person"]))
|
||||
if contact_parts:
|
||||
lines.append(f"📞 {' — '.join(contact_parts)}")
|
||||
elif sup_name:
|
||||
lines.append(f"<b>{esc(sup_name)}</b>{official_badge} ✨ <i>(تأمینکننده جدید)</i>")
|
||||
else:
|
||||
sup_label = "—"
|
||||
lines.append(f"تأمینکننده: {sup_label}")
|
||||
proj_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else [])
|
||||
if proj_names:
|
||||
lines.append(f"پروژه(ها): {esc('، '.join(proj_names))}")
|
||||
lines.append(f"نوع: {'رسمی' if payload.get('is_official', True) else 'برآوردی'}")
|
||||
if payload.get("expiry_date"):
|
||||
lines.append(f"اعتبار تا: {fa_date(payload['expiry_date'])}")
|
||||
lines.append("— <i>(مشخص نشده)</i>")
|
||||
|
||||
cat_name = payload.get("supplier_category_name")
|
||||
if cat_name:
|
||||
lines.append(f"\n🏷️ موضوع: {esc(cat_name)}")
|
||||
|
||||
# 📦 اقلام
|
||||
items = payload.get("items") or []
|
||||
if items:
|
||||
lines.append("\n📦 اقلام:")
|
||||
for i, it in enumerate(items, start=1):
|
||||
name = it.get('material_name') or '?'
|
||||
if it.get('brand_name'):
|
||||
name += f" ({it['brand_name']})"
|
||||
parts = [f"{i}. {esc(name)}"]
|
||||
if it.get('specs'):
|
||||
parts.append(esc("، ".join(it['specs'])))
|
||||
if it.get("quantity"):
|
||||
parts.append(f"مقدار {esc(it['quantity'])}")
|
||||
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
|
||||
if unit:
|
||||
parts.append(str(unit))
|
||||
if it.get("unit_price"):
|
||||
parts.append(f"قیمت {esc(it['unit_price'])}")
|
||||
if not it.get("material_id"):
|
||||
parts.append("✨ کالای جدید")
|
||||
lines.append(" — ".join(str(p) for p in parts))
|
||||
else:
|
||||
lines.append("\nاقلام: هنوز چیزی ثبت نشده ❌")
|
||||
total_amount = 0.0
|
||||
has_any_price = False
|
||||
|
||||
if items:
|
||||
lines.append(f"\n📦 <b>اقلام ({fa_num(len(items))})</b>")
|
||||
default_proj = payload.get("project_name")
|
||||
for i, it in enumerate(items, start=1):
|
||||
name = it.get("material_name") or f"قلم {i}"
|
||||
title_parts = [f"<b>{esc(name)}</b>"]
|
||||
if it.get("brand_name"):
|
||||
title_parts.append(esc(it["brand_name"]))
|
||||
if it.get("specs"):
|
||||
title_parts.append(esc("، ".join(it["specs"])))
|
||||
if not it.get("material_id"):
|
||||
title_parts.append("✨ <i>(کالای جدید)</i>")
|
||||
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
|
||||
|
||||
# Project
|
||||
item_proj = it.get("project_name") or default_proj
|
||||
if item_proj:
|
||||
lines.append(f" 🏗️ {esc(item_proj)}")
|
||||
else:
|
||||
lines.append(" 🏗️ <i>پروژه مشخص نشده</i>")
|
||||
|
||||
# Qty, Unit, Price, Total
|
||||
qty = it.get("quantity")
|
||||
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
|
||||
unit_price = it.get("unit_price")
|
||||
|
||||
calc_line = " "
|
||||
if qty is not None:
|
||||
calc_line += f"{fa_num(qty)} {esc(unit or '')}".strip()
|
||||
else:
|
||||
calc_line += "مقدار نامشخص"
|
||||
|
||||
if unit_price:
|
||||
has_any_price = True
|
||||
calc_line += f" × {fa_num(unit_price)}"
|
||||
if qty is not None:
|
||||
try:
|
||||
line_sum = float(qty) * float(unit_price)
|
||||
total_amount += line_sum
|
||||
calc_line += f" = <b>{fa_num(line_sum)} تومان</b>"
|
||||
except (ValueError, TypeError):
|
||||
calc_line += " تومان"
|
||||
else:
|
||||
calc_line += " تومان"
|
||||
else:
|
||||
calc_line += " × — = — تومان"
|
||||
|
||||
lines.append(calc_line)
|
||||
|
||||
if has_any_price and total_amount > 0:
|
||||
lines.append(f"\n💰 <b>مجموع مبلغ: {fa_num(total_amount)} تومان</b>")
|
||||
elif not has_any_price:
|
||||
lines.append("\n💰 مجموع مبلغ: <i>محاسبهنشده (قیمت اقلام وارد نشده)</i>")
|
||||
else:
|
||||
lines.append("\n📦 اقلام: <i>هنوز چیزی ثبت نشده ❌</i>")
|
||||
|
||||
# 💳 شرایط پرداخت
|
||||
sheets = payload.get("termsheets") or []
|
||||
if sheets:
|
||||
ts_parts = []
|
||||
for t in sheets:
|
||||
lbl = esc(t.get('label'))
|
||||
if t.get("milestones"):
|
||||
steps_txt = " + ".join(f"{fa_date(m.get('due_date'))}: {esc(m.get('amount'))}" for m in t["milestones"] if m.get("amount"))
|
||||
if steps_txt:
|
||||
lbl += f" [{steps_txt}]"
|
||||
elif t.get("discount_value"):
|
||||
lbl += f" (تخفیف {esc(t.get('discount_value'))})"
|
||||
ts_parts.append(lbl)
|
||||
lines.append("\n💳 شرایط پرداخت: " + "؛ ".join(ts_parts))
|
||||
if payload.get("notes"):
|
||||
lines.append(f"یادداشت: {esc(payload['notes'])}")
|
||||
lines.append(f"\n💳 <b>شرایط پرداخت ({fa_num(len(sheets))})</b>")
|
||||
for s_idx, t in enumerate(sheets, start=1):
|
||||
lbl = t.get("label") or f"گزینه {s_idx}"
|
||||
milestones = t.get("milestones") or t.get("schedule_steps") or []
|
||||
if milestones:
|
||||
lines.append(f"▫️ {esc(lbl)} — {fa_num(len(milestones))} مرحله")
|
||||
step_sum = 0.0
|
||||
for m in milestones:
|
||||
due = fa_date(m.get("due_date"))
|
||||
amt = m.get("amount")
|
||||
amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی"
|
||||
if amt:
|
||||
try:
|
||||
step_sum += float(amt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
note = f" ({esc(m['note'])})" if m.get("note") else ""
|
||||
lines.append(f" • {due} — {amt_str}{note}")
|
||||
|
||||
lines.append("\n📋 وضعیت فیلدها:")
|
||||
if total_amount > 0 and step_sum > 0:
|
||||
diff = abs(total_amount - step_sum)
|
||||
if diff < 1.0:
|
||||
lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است")
|
||||
else:
|
||||
lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان")
|
||||
if len(items) > 0:
|
||||
lines.append(f" شامل: هر {fa_num(len(items))} قلم")
|
||||
else:
|
||||
disc = ""
|
||||
if t.get("discount_value"):
|
||||
disc = f" (تخفیف {fa_num(t['discount_value'])})"
|
||||
lines.append(f"▫️ {esc(lbl)}{disc}")
|
||||
elif items:
|
||||
lines.append("\n💳 شرایط پرداخت: <i>نقدی / توافقی (پیشفرض)</i>")
|
||||
|
||||
# Metadata
|
||||
meta_lines = []
|
||||
if payload.get("quote_date"):
|
||||
meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}")
|
||||
if payload.get("expiry_date"):
|
||||
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
|
||||
if payload.get("proforma_file"):
|
||||
meta_lines.append(f"📎 پیشفاکتور: {esc(payload['proforma_file'])}")
|
||||
if payload.get("notes"):
|
||||
meta_lines.append(f"📝 توضیحات: {esc(payload['notes'])}")
|
||||
|
||||
if meta_lines:
|
||||
lines.append("")
|
||||
lines.extend(meta_lines)
|
||||
|
||||
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
|
||||
if has_blocking or has_warnings:
|
||||
lines.append("\n📋 <b>وضعیت فیلدها:</b>")
|
||||
lines.append(missing_block(missing))
|
||||
lines.append("\nبا پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
|
||||
# Footer
|
||||
lines.append("\n━━━━━━━━━━━━━━")
|
||||
if has_blocking:
|
||||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
elif has_warnings:
|
||||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||
lines.append("میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
else:
|
||||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user