diff --git a/ai procurement/app/drafts.py b/ai procurement/app/drafts.py
index 27db768..cbad879 100644
--- a/ai procurement/app/drafts.py
+++ b/ai procurement/app/drafts.py
@@ -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
diff --git a/ai procurement/app/handlers/quote.py b/ai procurement/app/handlers/quote.py
index 74df784..696ee1e 100644
--- a/ai procurement/app/handlers/quote.py
+++ b/ai procurement/app/handlers/quote.py
@@ -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]
diff --git a/ai procurement/app/resolve.py b/ai procurement/app/resolve.py
index fb0af5f..cde72f0 100644
--- a/ai procurement/app/resolve.py
+++ b/ai procurement/app/resolve.py
@@ -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]:
diff --git a/ai procurement/app/texts.py b/ai procurement/app/texts.py
index bd62cd8..8afc7b4 100644
--- a/ai procurement/app/texts.py
+++ b/ai procurement/app/texts.py
@@ -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 = ["📋 پیشنویس استعلام"]
+
+ 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("🏢 تأمینکننده")
+ 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"{esc(sup_name)}{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"{esc(sup_name)}{official_badge} ✨ (تأمینکننده جدید)")
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("— (مشخص نشده)")
+ 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📦 اقلام ({fa_num(len(items))})")
+ 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"{esc(name)}"]
+ 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("✨ (کالای جدید)")
+ 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(" 🏗️ پروژه مشخص نشده")
+
+ # 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" = {fa_num(line_sum)} تومان"
+ 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💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
+ elif not has_any_price:
+ lines.append("\n💰 مجموع مبلغ: محاسبهنشده (قیمت اقلام وارد نشده)")
+ else:
+ lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
+
+ # 💳 شرایط پرداخت
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))
+ lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})")
+ 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}")
+
+ 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💳 شرایط پرداخت: نقدی / توافقی (پیشفرض)")
+
+ # 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"):
- lines.append(f"یادداشت: {esc(payload['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📋 وضعیت فیلدها:")
+ lines.append(missing_block(missing))
+
+ # Footer
+ lines.append("\n━━━━━━━━━━━━━━")
+ if has_blocking:
+ lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
+ lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
+ elif has_warnings:
+ lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
+ lines.append("میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
+ else:
+ lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
+ lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
- lines.append("\n📋 وضعیت فیلدها:")
- lines.append(missing_block(missing))
- lines.append("\nبا پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
return "\n".join(lines)