fix(bot): strip html tags for Bale, fallback to direct DB catalogs, align validation strictly with DB schema

This commit is contained in:
2026-09-29 01:49:42 +03:30
parent 17458d8697
commit da68fc683f
6 changed files with 217 additions and 99 deletions
+4 -13
View File
@@ -67,28 +67,19 @@ class BaleClient:
async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None, async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None,
reply_to: int | None = None) -> dict: reply_to: int | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "text": text[:4000], "parse_mode": "HTML"} payload: dict = {"chat_id": chat_id, "text": text[:4000]}
if reply_markup: if reply_markup:
payload[REPLY_MARKUP] = reply_markup payload[REPLY_MARKUP] = reply_markup
if reply_to: if reply_to:
payload["reply_to_message_id"] = reply_to payload["reply_to_message_id"] = reply_to
try: return await self._call("sendMessage", payload)
return await self._call("sendMessage", payload)
except BaleError:
# parse_mode / markup rejection fallback: retry plain, keep the keyboard.
payload.pop("parse_mode", None)
return await self._call("sendMessage", payload)
async def edit_message_text(self, chat_id: int, message_id: int, text: str, async def edit_message_text(self, chat_id: int, message_id: int, text: str,
reply_markup: dict | None = None) -> dict: reply_markup: dict | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000], "parse_mode": "HTML"} payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000]}
if reply_markup: if reply_markup:
payload[REPLY_MARKUP] = reply_markup payload[REPLY_MARKUP] = reply_markup
try: return await self._call("editMessageText", payload)
return await self._call("editMessageText", payload)
except BaleError:
payload.pop("parse_mode", None)
return await self._call("editMessageText", payload)
async def delete_message(self, chat_id: int, message_id: int) -> bool: async def delete_message(self, chat_id: int, message_id: int) -> bool:
payload: dict = {"chat_id": chat_id, "message_id": message_id} payload: dict = {"chat_id": chat_id, "message_id": message_id}
+13 -5
View File
@@ -281,29 +281,37 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
def compute_quote_missing(payload: dict) -> list[dict]: def compute_quote_missing(payload: dict) -> list[dict]:
"""What is still empty — the user's explicit ask («یادآوری چیزهای خالی»).""" """What is still empty — strictly aligned with DB schema requirements."""
out: list[dict] = [] out: list[dict] = []
def add(key: str, label: str, ok: bool, blocking: bool) -> None: def add(key: str, label: str, ok: bool, blocking: bool) -> None:
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking}) out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
sup_ok = bool(payload.get("supplier_id")) # In DB: supplier_id is NOT NULL in quotes table.
sup_ok = bool(payload.get("supplier_id") or payload.get("supplier_name"))
add("supplier", "تأمین‌کننده", sup_ok, True) add("supplier", "تأمین‌کننده", sup_ok, True)
# In DB: project_id is nullable (optional)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False) add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
# In DB: expiry_date is nullable (optional)
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False) add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
items = payload.get("items") or [] items = payload.get("items") or []
add("items", "قلم (کالا/مقدار)", bool(items), True) add("items", "قلم (کالا/مقدار)", bool(items), True)
for i, item in enumerate(items, start=1): for i, item in enumerate(items, start=1):
name = item.get("material_name") or f"قلم {i}" name = item.get("material_name") or f"قلم {i}"
if not item.get("material_id"): has_mat = bool(item.get("material_id") or item.get("material_name"))
add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True) if not has_mat:
add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True)
if not item.get("quantity"): if not item.get("quantity"):
add(f"item:{i}:qty", f"مقدار «{name}»", False, True) add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
if not item.get("unit_id"): if not item.get("unit_id") and not item.get("unit_name"):
add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True) add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True)
if not item.get("unit_price"): 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"): if items and not payload.get("termsheets"):
add("terms", "شرایط پرداخت", False, False) add("terms", "شرایط پرداخت", False, False)
if not payload.get("notes"): if not payload.get("notes"):
+1 -1
View File
@@ -79,4 +79,4 @@ async def _whoami(ctx, chat_id: int) -> None:
await ctx.send(chat_id, UNLINKED) await ctx.send(chat_id, UNLINKED)
return return
role = ROLE_FA.get(link.role or "", link.role or "بدون نقش") role = ROLE_FA.get(link.role or "", link.role or "بدون نقش")
await ctx.send(chat_id, f"اتصال: <b>{esc(link.display_name)}</b> — {role}") await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}")
+5 -5
View File
@@ -103,9 +103,9 @@ def _flow_user(flow):
def _accept_summary(quote: dict, chosen: int) -> str: def _accept_summary(quote: dict, chosen: int) -> str:
lines = [ lines = [
f"✔️ <b>پذیرش استعلام</b> #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}", f"✔️ پذیرش استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
"", "",
"<b>اقلام:</b>", "اقلام:",
] ]
for li in quote.get("line_items") or []: for li in quote.get("line_items") or []:
mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?" mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?"
@@ -116,7 +116,7 @@ def _accept_summary(quote: dict, chosen: int) -> str:
parts.append(f"قیمت {esc(price)}") parts.append(f"قیمت {esc(price)}")
lines.append(" — ".join(parts)) lines.append(" — ".join(parts))
lines.append("") lines.append("")
lines.append("<b>شرایط پرداخت:</b>") lines.append("شرایط پرداخت:")
for i, ts in enumerate(quote.get("termsheets") or []): for i, ts in enumerate(quote.get("termsheets") or []):
mark = "👉" if i == chosen else " " mark = "👉" if i == chosen else " "
discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else "" discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else ""
@@ -276,7 +276,7 @@ async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str: def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
lines = ["🚚 <b>اعلام رسیدن اقلام</b>", ""] lines = ["🚚 اعلام رسیدن اقلام", ""]
for li in items: for li in items:
mat = (li.get("material") or {}).get("name") or "?" mat = (li.get("material") or {}).get("name") or "?"
ordered = li.get("quantity") or 0 ordered = li.get("quantity") or 0
@@ -284,7 +284,7 @@ def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = Non
unit = ((li.get("unit") or {}).get("name_fa")) or "" unit = ((li.get("unit") or {}).get("name_fa")) or ""
got = "" got = ""
if quantities and li["id"] in quantities: if quantities and li["id"] in quantities:
got = f" → <b>الان: {esc(quantities[li['id']])} {unit}</b>" got = f" → الان: {esc(quantities[li['id']])} {unit}"
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}") lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
return "\n".join(lines) return "\n".join(lines)
+145 -26
View File
@@ -7,7 +7,9 @@ import difflib
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from sqlalchemy import text
from app.api.client import ApiError, ErpClient from app.api.client import ApiError, ErpClient
from app.db import SessionLocal
from app.nlu import normalize_text from app.nlu import normalize_text
@@ -45,6 +47,108 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
return [item for ratio, item in scored[:limit] if ratio >= 0.40] return [item for ratio, item in scored[:limit] if ratio >= 0.40]
async def _db_units() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).mappings().all()
return [
Candidate(
id=str(u["id"]),
name=u.get("name_fa") or u.get("code") or str(u["id"]),
extra={"code": u.get("code")},
)
for u in rows
]
except Exception:
return []
async def _db_categories() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, name, slug FROM material_categories"))).mappings().all()
return [
Candidate(
id=str(c["id"]),
name=c.get("name") or c.get("slug") or str(c["id"]),
)
for c in rows
]
except Exception:
return []
async def _db_projects() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, name FROM projects"))).mappings().all()
return [
Candidate(
id=str(p["id"]),
name=p.get("name") or str(p["id"]),
)
for p in rows
]
except Exception:
return []
async def _db_suppliers(q: str | None = None) -> list[Candidate]:
try:
async with SessionLocal() as s:
if q:
rows = (await s.execute(
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id "
"WHERE s.name ILIKE :q LIMIT 20"),
{"q": f"%{q}%"}
)).mappings().all()
else:
rows = (await s.execute(
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id LIMIT 20")
)).mappings().all()
return [
Candidate(
id=str(r["id"]),
name=r.get("name") or str(r["id"]),
extra={
"location": r.get("location"),
"phone": r.get("phone"),
"contact_person": r.get("contact_person"),
"category_name": r.get("category_name"),
},
)
for r in rows
]
except Exception:
return []
async def _db_materials(q: str | None = None) -> list[Candidate]:
try:
async with SessionLocal() as s:
if q:
rows = (await s.execute(
text("SELECT id, name, topic, canonical_unit_id, status FROM materials WHERE name ILIKE :q LIMIT 20"),
{"q": f"%{q}%"}
)).mappings().all()
else:
rows = (await s.execute(
text("SELECT id, name, topic, canonical_unit_id, status FROM materials LIMIT 20")
)).mappings().all()
return [
Candidate(
id=str(r["id"]),
name=r.get("name") or str(r["id"]),
extra=dict(r),
)
for r in rows
]
except Exception:
return []
class Resolver: class Resolver:
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand).""" """Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
@@ -72,45 +176,60 @@ class Resolver:
async def units(self) -> list[Candidate]: async def units(self) -> list[Candidate]:
async def fetch(): async def fetch():
rows = await self._api.list_units() try:
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]), rows = await self._api.list_units()
extra={"code": u.get("code")}) for u in rows] return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
extra={"code": u.get("code")}) for u in rows]
except Exception:
return await _db_units()
return await self._cached("units", fetch) return await self._cached("units", fetch)
async def categories(self) -> list[Candidate]: async def categories(self) -> list[Candidate]:
async def fetch(): async def fetch():
rows = await self._api.list_categories() try:
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows] rows = await self._api.list_categories()
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
except Exception:
return await _db_categories()
return await self._cached("categories", fetch) return await self._cached("categories", fetch)
async def projects(self) -> list[Candidate]: async def projects(self) -> list[Candidate]:
async def fetch(): async def fetch():
rows = await self._api.list_projects() try:
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows] rows = await self._api.list_projects()
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
except Exception:
return await _db_projects()
return await self._cached("projects", fetch) return await self._cached("projects", fetch)
async def suppliers(self, q: str | None = None) -> list[Candidate]: async def suppliers(self, q: str | None = None) -> list[Candidate]:
rows = await self._api.list_suppliers(q=q, page_size=20) try:
items = [ rows = await self._api.list_suppliers(q=q, page_size=20)
Candidate( items = [
id=str(s["id"]), Candidate(
name=s.get("name") or str(s["id"]), id=str(s["id"]),
extra={ name=s.get("name") or str(s["id"]),
"location": s.get("location"), extra={
"phone": s.get("phone"), "location": s.get("location"),
"contact_person": s.get("contact_person"), "phone": s.get("phone"),
"category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None, "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 )
] for s in rows
]
except Exception:
items = await _db_suppliers(q=q)
return _rank(q, items) if q else items return _rank(q, items) if q else items
async def materials(self, q: str | None = None) -> list[Candidate]: async def materials(self, q: str | None = None) -> list[Candidate]:
rows = await self._api.list_materials(q=q, page_size=20) try:
extra = {str(m["id"]): m for m in rows} rows = await self._api.list_materials(q=q, page_size=20)
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {})) extra = {str(m["id"]): m for m in rows}
for m in rows] items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
for m in rows]
except Exception:
items = await _db_materials(q=q)
return _rank(q, items) if q else items return _rank(q, items) if q else items
# ------------------------------------------------------------ resolutions # ------------------------------------------------------------ resolutions
@@ -159,5 +278,5 @@ class Resolver:
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution: async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
try: try:
return await resolver.resolve(kind, name) return await resolver.resolve(kind, name)
except ApiError: except Exception:
return Resolution("none", []) return Resolution("none", [])
+49 -49
View File
@@ -15,7 +15,7 @@ WARNING_MARK = "⚠️"
def esc(value) -> str: def esc(value) -> str:
return html.escape(str(value if value is not None else "—")) return str(value if value is not None and value != "" else "—")
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]: def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
@@ -87,7 +87,7 @@ def fa_date(value, use_fa_digits: bool = True) -> str:
WELCOME = ( WELCOME = (
"سلام! 👋 من دستیار تدارکات هستم.\n\n" "سلام! 👋 من دستیار تدارکات هستم.\n\n"
"می‌توانی با پیام متنی یا <b>صوتی</b> فعالیتی تعریف کنی — مثلاً:\n" "می‌توانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n"
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n" "«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
"من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان " "من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان "
"می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n" "می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n"
@@ -100,22 +100,22 @@ WELCOME = (
) )
HELP = ( HELP = (
"<b>چه کارهایی می‌توانم بکنم؟</b>\n\n" "چه کارهایی می‌توانم بکنم؟\n\n"
"🧾 <b>ثبت استعلام</b> — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n" "🧾 ثبت استعلام — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n"
"🏢 <b>افزودن تأمین‌کننده</b> — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n" "🏢 افزودن تأمین‌کننده — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
"📦 <b>افزودن کالا</b> — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n" "📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
"📊 <b>پرس‌وجو</b> — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n" "📊 پرس‌وجو — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n"
"✔️ <b>پذیرش استعلام</b> — «استعلام سازان نو را قبول کن»\n" "✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n"
"🚚 <b>اعلام رسیدن</b> — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n" "🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
"هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن." "هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن."
) )
LINK_START = ( LINK_START = (
"برای اتصال حساب Bale به حساب ERP، <b>نام کاربری Casdoor</b> خود را بفرست " "برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست "
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel" "(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
) )
LINK_ASK_PASSWORD = "ممنون. حالا <b>رمز عبور</b> را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو" LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو"
LINK_OK = "✅ اتصال انجام شد: <b>{name}</b> ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین." LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین."
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست." LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود." ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود."
@@ -162,12 +162,12 @@ def missing_block(missing: list[dict]) -> str:
mark = "❌" mark = "❌"
else: else:
mark = "⚠️" mark = "⚠️"
lines.append(f"{mark} {esc(m.get('label'))}") lines.append(f"{mark} {m.get('label')}")
return "\n".join(lines) return "\n".join(lines)
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str: def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
lines = ["📋 <b>پیش‌نویس استعلام</b>"] lines = ["📋 پیش‌نویس استعلام"]
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing) 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) has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
@@ -185,30 +185,30 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
lines.append("━━━━━━━━━━━━━━\n") lines.append("━━━━━━━━━━━━━━\n")
# 🏢 تأمین‌کننده # 🏢 تأمین‌کننده
lines.append("🏢 <b>تأمین‌کننده</b>") lines.append("🏢 تأمین‌کننده")
sup_name = payload.get("supplier_name") sup_name = payload.get("supplier_name")
is_official = payload.get("is_official", True) is_official = payload.get("is_official", True)
official_badge = " (رسمی)" if is_official else " (غیررسمی)" official_badge = " (رسمی)" if is_official else " (غیررسمی)"
if payload.get("supplier_id"): if payload.get("supplier_id"):
lines.append(f"<b>{esc(sup_name)}</b>{official_badge} ✅") lines.append(f"{sup_name}{official_badge} ✅")
if payload.get("supplier_location"): if payload.get("supplier_location"):
lines.append(f"📍 {esc(payload['supplier_location'])}") lines.append(f"📍 {payload['supplier_location']}")
contact_parts = [] contact_parts = []
if payload.get("supplier_phone"): if payload.get("supplier_phone"):
contact_parts.append(fa_digits(payload["supplier_phone"])) contact_parts.append(fa_digits(payload["supplier_phone"]))
if payload.get("supplier_contact_person"): if payload.get("supplier_contact_person"):
contact_parts.append(esc(payload["supplier_contact_person"])) contact_parts.append(str(payload["supplier_contact_person"]))
if contact_parts: if contact_parts:
lines.append(f"📞 {' — '.join(contact_parts)}") lines.append(f"📞 {' — '.join(contact_parts)}")
elif sup_name: elif sup_name:
lines.append(f"<b>{esc(sup_name)}</b>{official_badge} ✨ <i>(تأمین‌کننده جدید)</i>") lines.append(f"{sup_name}{official_badge} ✨ (تأمین‌کننده جدید)")
else: else:
lines.append("— <i>(مشخص نشده)</i>") lines.append("— (مشخص نشده)")
cat_name = payload.get("supplier_category_name") cat_name = payload.get("supplier_category_name")
if cat_name: if cat_name:
lines.append(f"\n🏷️ موضوع: {esc(cat_name)}") lines.append(f"\n🏷️ موضوع: {cat_name}")
# 📦 اقلام # 📦 اقلام
items = payload.get("items") or [] items = payload.get("items") or []
@@ -216,25 +216,25 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
has_any_price = False has_any_price = False
if items: if items:
lines.append(f"\n📦 <b>اقلام ({fa_num(len(items))})</b>") lines.append(f"\n📦 اقلام ({fa_num(len(items))})")
default_proj = payload.get("project_name") default_proj = payload.get("project_name")
for i, it in enumerate(items, start=1): for i, it in enumerate(items, start=1):
name = it.get("material_name") or f"قلم {i}" name = it.get("material_name") or f"قلم {i}"
title_parts = [f"<b>{esc(name)}</b>"] title_parts = [str(name)]
if it.get("brand_name"): if it.get("brand_name"):
title_parts.append(esc(it["brand_name"])) title_parts.append(str(it["brand_name"]))
if it.get("specs"): if it.get("specs"):
title_parts.append(esc("، ".join(it["specs"]))) title_parts.append("، ".join(str(s) for s in it["specs"]))
if not it.get("material_id"): if not it.get("material_id"):
title_parts.append("✨ <i>(کالای جدید)</i>") title_parts.append("✨ (کالای جدید)")
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}") lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
# Project # Project
item_proj = it.get("project_name") or default_proj item_proj = it.get("project_name") or default_proj
if item_proj: if item_proj:
lines.append(f" 🏗️ {esc(item_proj)}") lines.append(f" 🏗️ {item_proj}")
else: else:
lines.append(" 🏗️ <i>پروژه مشخص نشده</i>") lines.append(" 🏗️ پروژه مشخص نشده")
# Qty, Unit, Price, Total # Qty, Unit, Price, Total
qty = it.get("quantity") qty = it.get("quantity")
@@ -243,7 +243,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
calc_line = " " calc_line = " "
if qty is not None: if qty is not None:
calc_line += f"{fa_num(qty)} {esc(unit or '')}".strip() calc_line += f"{fa_num(qty)} {unit or ''}".strip()
else: else:
calc_line += "مقدار نامشخص" calc_line += "مقدار نامشخص"
@@ -254,7 +254,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
try: try:
line_sum = float(qty) * float(unit_price) line_sum = float(qty) * float(unit_price)
total_amount += line_sum total_amount += line_sum
calc_line += f" = <b>{fa_num(line_sum)} تومان</b>" calc_line += f" = {fa_num(line_sum)} تومان"
except (ValueError, TypeError): except (ValueError, TypeError):
calc_line += " تومان" calc_line += " تومان"
else: else:
@@ -265,21 +265,21 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
lines.append(calc_line) lines.append(calc_line)
if has_any_price and total_amount > 0: if has_any_price and total_amount > 0:
lines.append(f"\n💰 <b>مجموع مبلغ: {fa_num(total_amount)} تومان</b>") lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
elif not has_any_price: elif not has_any_price:
lines.append("\n💰 مجموع مبلغ: <i>محاسبه‌نشده (قیمت اقلام وارد نشده)</i>") lines.append("\n💰 مجموع مبلغ: محاسبه‌نشده (قیمت اقلام وارد نشده)")
else: else:
lines.append("\n📦 اقلام: <i>هنوز چیزی ثبت نشده ❌</i>") lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
# 💳 شرایط پرداخت # 💳 شرایط پرداخت
sheets = payload.get("termsheets") or [] sheets = payload.get("termsheets") or []
if sheets: if sheets:
lines.append(f"\n💳 <b>شرایط پرداخت ({fa_num(len(sheets))})</b>") lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})")
for s_idx, t in enumerate(sheets, start=1): for s_idx, t in enumerate(sheets, start=1):
lbl = t.get("label") or f"گزینه {s_idx}" lbl = t.get("label") or f"گزینه {s_idx}"
milestones = t.get("milestones") or t.get("schedule_steps") or [] milestones = t.get("milestones") or t.get("schedule_steps") or []
if milestones: if milestones:
lines.append(f"▫️ {esc(lbl)} — {fa_num(len(milestones))} مرحله") lines.append(f"▫️ {lbl} — {fa_num(len(milestones))} مرحله")
step_sum = 0.0 step_sum = 0.0
for m in milestones: for m in milestones:
due = fa_date(m.get("due_date")) due = fa_date(m.get("due_date"))
@@ -290,7 +290,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
step_sum += float(amt) step_sum += float(amt)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
note = f" ({esc(m['note'])})" if m.get("note") else "" note = f" ({m['note']})" if m.get("note") else ""
lines.append(f" • {due} — {amt_str}{note}") lines.append(f" • {due} — {amt_str}{note}")
if total_amount > 0 and step_sum > 0: if total_amount > 0 and step_sum > 0:
@@ -305,9 +305,9 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
disc = "" disc = ""
if t.get("discount_value"): if t.get("discount_value"):
disc = f" (تخفیف {fa_num(t['discount_value'])})" disc = f" (تخفیف {fa_num(t['discount_value'])})"
lines.append(f"▫️ {esc(lbl)}{disc}") lines.append(f"▫️ {lbl}{disc}")
elif items: elif items:
lines.append("\n💳 شرایط پرداخت: <i>نقدی / توافقی (پیش‌فرض)</i>") lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیش‌فرض)")
# Metadata # Metadata
meta_lines = [] meta_lines = []
@@ -316,9 +316,9 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
if payload.get("expiry_date"): if payload.get("expiry_date"):
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}") meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
if payload.get("proforma_file"): if payload.get("proforma_file"):
meta_lines.append(f"📎 پیش‌فاکتور: {esc(payload['proforma_file'])}") meta_lines.append(f"📎 پیش‌فاکتور: {payload['proforma_file']}")
if payload.get("notes"): if payload.get("notes"):
meta_lines.append(f"📝 توضیحات: {esc(payload['notes'])}") meta_lines.append(f"📝 توضیحات: {payload['notes']}")
if meta_lines: if meta_lines:
lines.append("") lines.append("")
@@ -326,7 +326,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد) # 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
if has_blocking or has_warnings: if has_blocking or has_warnings:
lines.append("\n📋 <b>وضعیت فیلدها:</b>") lines.append("\n📋 وضعیت فیلدها:")
lines.append(missing_block(missing)) lines.append(missing_block(missing))
# Footer # Footer
@@ -345,7 +345,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
def supplier_review_text(payload: dict, missing: list[dict]) -> str: def supplier_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["🏢 <b>پیش‌نویس تأمین‌کننده جدید</b>", f"نام: <b>{esc(payload.get('name'))}</b>"] lines = ["🏢 پیش‌نویس تأمین‌کننده جدید", f"نام: {esc(payload.get('name'))}"]
lines.append(f"دسته: {esc(payload.get('category_name'))}") lines.append(f"دسته: {esc(payload.get('category_name'))}")
if payload.get("phone"): if payload.get("phone"):
lines.append(f"تلفن: {esc(payload['phone'])}") lines.append(f"تلفن: {esc(payload['phone'])}")
@@ -353,23 +353,23 @@ def supplier_review_text(payload: dict, missing: list[dict]) -> str:
lines.append(f"موقعیت: {esc(payload['location'])}") lines.append(f"موقعیت: {esc(payload['location'])}")
if payload.get("contact_person"): if payload.get("contact_person"):
lines.append(f"رابط: {esc(payload['contact_person'])}") lines.append(f"رابط: {esc(payload['contact_person'])}")
lines.append("\n<b>وضعیت فیلدها:</b>") lines.append("\nوضعیت فیلدها:")
lines.append(missing_block(missing)) lines.append(missing_block(missing))
return "\n".join(lines) return "\n".join(lines)
def material_review_text(payload: dict, missing: list[dict]) -> str: def material_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["📦 <b>پیش‌نویس کالای جدید</b>", f"نام: <b>{esc(payload.get('name'))}</b>"] lines = ["📦 پیش‌نویس کالای جدید", f"نام: {esc(payload.get('name'))}"]
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}") lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
if payload.get("topic"): if payload.get("topic"):
lines.append(f"تاپیک: {esc(payload['topic'])}") lines.append(f"تاپیک: {esc(payload['topic'])}")
lines.append("\n<b>وضعیت فیلدها:</b>") lines.append("\nوضعیت فیلدها:")
lines.append(missing_block(missing)) lines.append(missing_block(missing))
return "\n".join(lines) return "\n".join(lines)
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str: def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
lines = [f"✅ <b>ثبت شد</b> — استعلام #{esc(created.get('id'))}"] lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name") sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
if sup: if sup:
lines.append(f"تأمین‌کننده: {esc(sup)}") lines.append(f"تأمین‌کننده: {esc(sup)}")
@@ -382,7 +382,7 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
if payload.get("project_id"): if payload.get("project_id"):
lines.append("پروژه: پیوست شد") lines.append("پروژه: پیوست شد")
if remaining: if remaining:
lines.append("\n<b>خالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):</b>") lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):")
lines += [f"⚠️ {esc(r)}" for r in remaining] lines += [f"⚠️ {esc(r)}" for r in remaining]
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.") lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
return "\n".join(lines) return "\n".join(lines)
@@ -390,13 +390,13 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
def supplier_committed_report(created: dict) -> str: def supplier_committed_report(created: dict) -> str:
return ( return (
f"✅ <b>تأمین‌کننده ثبت شد</b> — {esc(created.get('name'))}\n" f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n"
f"دسته: {esc((created.get('category') or {}).get('name'))}" f"دسته: {esc((created.get('category') or {}).get('name'))}"
) )
def material_committed_report(created: dict) -> str: def material_committed_report(created: dict) -> str:
return f"✅ <b>کالا ثبت شد</b> — {esc(created.get('name'))}" return f"✅ کالا ثبت شد — {esc(created.get('name'))}"
def quote_line(quote: dict) -> str: def quote_line(quote: dict) -> str: