fix(bot): strip html tags for Bale, fallback to direct DB catalogs, align validation strictly with DB schema
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user