diff --git a/ai procurement/app/bale/client.py b/ai procurement/app/bale/client.py index 3262b64..c924239 100644 --- a/ai procurement/app/bale/client.py +++ b/ai procurement/app/bale/client.py @@ -67,28 +67,19 @@ class BaleClient: async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None, 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: payload[REPLY_MARKUP] = reply_markup if reply_to: payload["reply_to_message_id"] = reply_to - try: - 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) + return await self._call("sendMessage", payload) async def edit_message_text(self, chat_id: int, message_id: int, text: str, 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: payload[REPLY_MARKUP] = reply_markup - try: - return await self._call("editMessageText", payload) - except BaleError: - payload.pop("parse_mode", None) - return await self._call("editMessageText", payload) + return await self._call("editMessageText", payload) async def delete_message(self, chat_id: int, message_id: int) -> bool: payload: dict = {"chat_id": chat_id, "message_id": message_id} diff --git a/ai procurement/app/drafts.py b/ai procurement/app/drafts.py index cbad879..85b3821 100644 --- a/ai procurement/app/drafts.py +++ b/ai procurement/app/drafts.py @@ -281,29 +281,37 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool: 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] = [] def add(key: str, label: str, ok: bool, blocking: bool) -> None: 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) + + # In DB: project_id is nullable (optional) 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) items = payload.get("items") or [] add("items", "قلم (کالا/مقدار)", bool(items), True) for i, item in enumerate(items, start=1): name = item.get("material_name") or f"قلم {i}" - if not item.get("material_id"): - add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True) + has_mat = bool(item.get("material_id") or item.get("material_name")) + if not has_mat: + add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True) + if not item.get("quantity"): 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) if not item.get("unit_price"): 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"): diff --git a/ai procurement/app/handlers/commands.py b/ai procurement/app/handlers/commands.py index 47d3912..4278942 100644 --- a/ai procurement/app/handlers/commands.py +++ b/ai procurement/app/handlers/commands.py @@ -79,4 +79,4 @@ async def _whoami(ctx, chat_id: int) -> None: await ctx.send(chat_id, UNLINKED) return role = ROLE_FA.get(link.role or "", link.role or "بدون نقش") - await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}") + await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}") diff --git a/ai procurement/app/handlers/decisions.py b/ai procurement/app/handlers/decisions.py index d8c1d6d..591bd2e 100644 --- a/ai procurement/app/handlers/decisions.py +++ b/ai procurement/app/handlers/decisions.py @@ -103,9 +103,9 @@ def _flow_user(flow): def _accept_summary(quote: dict, chosen: int) -> str: lines = [ - f"✔️ پذیرش استعلام #{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 '—')}", "", - "اقلام:", + "اقلام:", ] for li in quote.get("line_items") 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)}") lines.append(" — ".join(parts)) lines.append("") - lines.append("شرایط پرداخت:") + lines.append("شرایط پرداخت:") for i, ts in enumerate(quote.get("termsheets") or []): mark = "👉" if i == chosen 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: - lines = ["🚚 اعلام رسیدن اقلام", ""] + lines = ["🚚 اعلام رسیدن اقلام", ""] for li in items: mat = (li.get("material") or {}).get("name") or "?" 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 "" got = "" if quantities and li["id"] in quantities: - got = f" → الان: {esc(quantities[li['id']])} {unit}" + got = f" → الان: {esc(quantities[li['id']])} {unit}" lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}") return "\n".join(lines) diff --git a/ai procurement/app/resolve.py b/ai procurement/app/resolve.py index cde72f0..de19bbd 100644 --- a/ai procurement/app/resolve.py +++ b/ai procurement/app/resolve.py @@ -7,7 +7,9 @@ import difflib import time from dataclasses import dataclass, field +from sqlalchemy import text from app.api.client import ApiError, ErpClient +from app.db import SessionLocal 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] +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: """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 fetch(): - rows = await self._api.list_units() - 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] + try: + rows = await self._api.list_units() + 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) async def categories(self) -> list[Candidate]: async def fetch(): - 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] + try: + 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) async def projects(self) -> list[Candidate]: async def fetch(): - rows = await self._api.list_projects() - return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows] + try: + 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) 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"]), - 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 - ] + try: + 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"]), + 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 + ] + except Exception: + items = await _db_suppliers(q=q) return _rank(q, items) if q else items async def materials(self, q: str | None = None) -> list[Candidate]: - rows = await self._api.list_materials(q=q, page_size=20) - extra = {str(m["id"]): m 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] + try: + rows = await self._api.list_materials(q=q, page_size=20) + extra = {str(m["id"]): m 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 # ------------------------------------------------------------ resolutions @@ -159,5 +278,5 @@ class Resolver: async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution: try: return await resolver.resolve(kind, name) - except ApiError: + except Exception: return Resolution("none", []) diff --git a/ai procurement/app/texts.py b/ai procurement/app/texts.py index 8afc7b4..c8aa7f8 100644 --- a/ai procurement/app/texts.py +++ b/ai procurement/app/texts.py @@ -15,7 +15,7 @@ WARNING_MARK = "⚠️" 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]: @@ -87,7 +87,7 @@ def fa_date(value, use_fa_digits: bool = True) -> str: WELCOME = ( "سلام! 👋 من دستیار تدارکات هستم.\n\n" - "می‌توانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n" + "می‌توانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n" "«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n" "من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان " "می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n" @@ -100,22 +100,22 @@ WELCOME = ( ) HELP = ( - "چه کارهایی می‌توانم بکنم؟\n\n" - "🧾 ثبت استعلام — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n" - "🏢 افزودن تأمین‌کننده — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n" - "📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n" - "📊 پرس‌وجو — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n" - "✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n" - "🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n" + "چه کارهایی می‌توانم بکنم؟\n\n" + "🧾 ثبت استعلام — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n" + "🏢 افزودن تأمین‌کننده — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n" + "📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n" + "📊 پرس‌وجو — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n" + "✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n" + "🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n" "هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن." ) LINK_START = ( - "برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست " + "برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست " "(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel" ) -LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو" -LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین." +LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو" +LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین." LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست." ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود." @@ -162,12 +162,12 @@ def missing_block(missing: list[dict]) -> str: mark = "❌" else: mark = "⚠️" - lines.append(f"{mark} {esc(m.get('label'))}") + lines.append(f"{mark} {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) @@ -185,30 +185,30 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non lines.append("━━━━━━━━━━━━━━\n") # 🏢 تأمین‌کننده - lines.append("🏢 تأمین‌کننده") + 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"): - lines.append(f"{esc(sup_name)}{official_badge} ✅") + lines.append(f"{sup_name}{official_badge} ✅") if payload.get("supplier_location"): - lines.append(f"📍 {esc(payload['supplier_location'])}") + lines.append(f"📍 {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"])) + contact_parts.append(str(payload["supplier_contact_person"])) if contact_parts: lines.append(f"📞 {' — '.join(contact_parts)}") elif sup_name: - lines.append(f"{esc(sup_name)}{official_badge} ✨ (تأمین‌کننده جدید)") + lines.append(f"{sup_name}{official_badge} ✨ (تأمین‌کننده جدید)") else: - lines.append("— (مشخص نشده)") + lines.append("— (مشخص نشده)") cat_name = payload.get("supplier_category_name") if cat_name: - lines.append(f"\n🏷️ موضوع: {esc(cat_name)}") + lines.append(f"\n🏷️ موضوع: {cat_name}") # 📦 اقلام 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 if items: - lines.append(f"\n📦 اقلام ({fa_num(len(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)}"] + title_parts = [str(name)] if it.get("brand_name"): - title_parts.append(esc(it["brand_name"])) + title_parts.append(str(it["brand_name"])) 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"): - title_parts.append("✨ (کالای جدید)") + 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)}") + lines.append(f" 🏗️ {item_proj}") else: - lines.append(" 🏗️ پروژه مشخص نشده") + lines.append(" 🏗️ پروژه مشخص نشده") # Qty, Unit, Price, Total qty = it.get("quantity") @@ -243,7 +243,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non calc_line = " " 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: calc_line += "مقدار نامشخص" @@ -254,7 +254,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non try: line_sum = float(qty) * float(unit_price) total_amount += line_sum - calc_line += f" = {fa_num(line_sum)} تومان" + calc_line += f" = {fa_num(line_sum)} تومان" except (ValueError, TypeError): calc_line += " تومان" else: @@ -265,21 +265,21 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non lines.append(calc_line) if has_any_price and total_amount > 0: - lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان") + lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان") elif not has_any_price: - lines.append("\n💰 مجموع مبلغ: محاسبه‌نشده (قیمت اقلام وارد نشده)") + lines.append("\n💰 مجموع مبلغ: محاسبه‌نشده (قیمت اقلام وارد نشده)") else: - lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌") + lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌") # 💳 شرایط پرداخت sheets = payload.get("termsheets") or [] if sheets: - lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})") + 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))} مرحله") + lines.append(f"▫️ {lbl} — {fa_num(len(milestones))} مرحله") step_sum = 0.0 for m in milestones: 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) except (ValueError, TypeError): 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}") 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 = "" if t.get("discount_value"): disc = f" (تخفیف {fa_num(t['discount_value'])})" - lines.append(f"▫️ {esc(lbl)}{disc}") + lines.append(f"▫️ {lbl}{disc}") elif items: - lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیش‌فرض)") + lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیش‌فرض)") # Metadata meta_lines = [] @@ -316,9 +316,9 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non 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'])}") + meta_lines.append(f"📎 پیش‌فاکتور: {payload['proforma_file']}") if payload.get("notes"): - meta_lines.append(f"📝 توضیحات: {esc(payload['notes'])}") + meta_lines.append(f"📝 توضیحات: {payload['notes']}") if meta_lines: 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: - lines.append("\n📋 وضعیت فیلدها:") + lines.append("\n📋 وضعیت فیلدها:") lines.append(missing_block(missing)) # 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: - lines = ["🏢 پیش‌نویس تأمین‌کننده جدید", f"نام: {esc(payload.get('name'))}"] + lines = ["🏢 پیش‌نویس تأمین‌کننده جدید", f"نام: {esc(payload.get('name'))}"] lines.append(f"دسته: {esc(payload.get('category_name'))}") if payload.get("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'])}") if payload.get("contact_person"): lines.append(f"رابط: {esc(payload['contact_person'])}") - lines.append("\nوضعیت فیلدها:") + lines.append("\nوضعیت فیلدها:") lines.append(missing_block(missing)) return "\n".join(lines) def material_review_text(payload: dict, missing: list[dict]) -> str: - lines = ["📦 پیش‌نویس کالای جدید", f"نام: {esc(payload.get('name'))}"] + lines = ["📦 پیش‌نویس کالای جدید", f"نام: {esc(payload.get('name'))}"] lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}") if payload.get("topic"): lines.append(f"تاپیک: {esc(payload['topic'])}") - lines.append("\nوضعیت فیلدها:") + lines.append("\nوضعیت فیلدها:") lines.append(missing_block(missing)) return "\n".join(lines) def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str: - lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"] + lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"] sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name") if 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"): lines.append("پروژه: پیوست شد") if remaining: - lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):") + lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):") lines += [f"⚠️ {esc(r)}" for r in remaining] lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.") 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: return ( - f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n" + f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n" f"دسته: {esc((created.get('category') or {}).get('name'))}" ) def material_committed_report(created: dict) -> str: - return f"✅ کالا ثبت شد — {esc(created.get('name'))}" + return f"✅ کالا ثبت شد — {esc(created.get('name'))}" def quote_line(quote: dict) -> str: