The bot service in docker-compose.yml builds from ./bot, but the agent code lived in 'ai procurement/' and ./bot was only a Windows junction on reza.g's machine (and was gitignored) — exactly as the team reported: 'bot/ was never committed — prod-v7's bot is un-buildable from the repo.' - junction removed; 'ai procurement/' renamed to 'bot/' via git mv (all 41 files keep their history as pure renames) - 'bot/' dropped from .gitignore (voice/ stays ignored — runtime voice notes; bot/.env stays ignored by bot/.gitignore) - compose needs no change: context: ./bot and ./bot/app now resolve to committed files - bot/INTEGRATION-REPORT.md added: full story (problem, root cause, git state, security note on the committed BALE_BOT_TOKEN / AI_API_KEY compose defaults, and usage instructions for the team) Verified: 41 renames staged with zero secret/cache files; voice/ and bot/.env still ignored.
433 lines
19 KiB
Python
433 lines
19 KiB
Python
"""Persian message templates (the ERP's user-facing layer is Persian)."""
|
||
import html
|
||
from datetime import date
|
||
|
||
STATUS_FA = {
|
||
"offered": "🔵 در انتظار بررسی",
|
||
"accepted": "🟢 پذیرفتهشده",
|
||
"arrived": "✅ رسیده",
|
||
"rejected": "🔴 ردشده",
|
||
"expired": "⚪️ منقضی",
|
||
}
|
||
|
||
MISSING_MARKS = {True: "✅", False: "❌"}
|
||
WARNING_MARK = "⚠️"
|
||
|
||
|
||
def esc(value) -> str:
|
||
return str(value if value is not None and value != "" else "—")
|
||
|
||
|
||
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
|
||
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
|
||
gy2 = gy if gm > 2 else gy - 1
|
||
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
|
||
jy = -1595 + (33 * (days // 12053))
|
||
days %= 12053
|
||
jy += 4 * (days // 1461)
|
||
days %= 1461
|
||
if days > 365:
|
||
jy += (days - 1) // 365
|
||
days = (days - 1) % 365
|
||
if days < 186:
|
||
jm = 1 + (days // 31)
|
||
jd = 1 + (days % 31)
|
||
else:
|
||
jm = 7 + ((days - 186) // 30)
|
||
jd = 1 + ((days - 186) % 30)
|
||
return jy, jm, jd
|
||
|
||
|
||
def fa_num(val, use_fa_digits: bool = True) -> str:
|
||
"""Format integer/float with thousands separator and Persian digits."""
|
||
if val is None or val == "":
|
||
return "—"
|
||
try:
|
||
num = float(val)
|
||
if num.is_integer():
|
||
s = f"{int(num):,}"
|
||
else:
|
||
s = f"{num:,.2f}".rstrip("0").rstrip(".")
|
||
if use_fa_digits:
|
||
trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬")
|
||
return s.translate(trans)
|
||
return s
|
||
except (ValueError, TypeError):
|
||
return str(val)
|
||
|
||
|
||
def fa_digits(val) -> str:
|
||
"""Convert ASCII digits to Persian digits without thousands separators."""
|
||
if not val:
|
||
return ""
|
||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||
return str(val).translate(trans)
|
||
|
||
|
||
def fa_date(value, use_fa_digits: bool = True) -> str:
|
||
if not value:
|
||
return "—"
|
||
s = str(value)[:10]
|
||
try:
|
||
parts = [int(p) for p in s.replace("/", "-").split("-")]
|
||
if len(parts) == 3 and parts[0] > 1900:
|
||
jy, jm, jd = g2j(parts[0], parts[1], parts[2])
|
||
res = f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||
if use_fa_digits:
|
||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||
return res.translate(trans)
|
||
return res
|
||
except Exception:
|
||
pass
|
||
if use_fa_digits:
|
||
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||
return s.translate(trans)
|
||
return s
|
||
|
||
|
||
WELCOME = (
|
||
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
|
||
"میتوانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n"
|
||
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
|
||
"من هر چه را فهمیدم بهصورت پیشنویس آماده میکنم، چیزی که فهمیدم و چیزهای خالی را نشان "
|
||
"میدهم و فقط با تأیید تو در سیستم ثبت میکنم.\n\n"
|
||
"دستورها:\n"
|
||
"/drafts — پیشنویسهای باز\n"
|
||
"/cancel — لغو پیشنویس/عملیات جاری\n"
|
||
"/whoami — اتصال من\n"
|
||
"/unlink — قطع اتصال\n"
|
||
"/help — راهنما"
|
||
)
|
||
|
||
HELP = (
|
||
"چه کارهایی میتوانم بکنم؟\n\n"
|
||
"🧾 ثبت استعلام — تأمینکننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمیآورم.\n"
|
||
"🏢 افزودن تأمینکننده — «تأمینکننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
|
||
"📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
|
||
"📊 پرسوجو — «استعلامهای در انتظار؟»، «کدامها بهزودی منقضی میشوند؟»\n"
|
||
"✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n"
|
||
"🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
|
||
"هر ثبت فقط با دکمه ✅ انجام میشود. با /cancel هر چیز نیمهکاره را لغو کن."
|
||
)
|
||
|
||
LINK_START = (
|
||
"برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست "
|
||
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
|
||
)
|
||
LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمیشود). /cancel برای لغو"
|
||
LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا میتوانی فعالیتها را بفرستی. /help را ببین."
|
||
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
|
||
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبتها بدون انتساب کاربر انجام میشود."
|
||
|
||
DRAFTS_LIST_HEADER = "📋 پیشنویسهای باز:"
|
||
DRAFTS_EMPTY = "پیشنویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
|
||
NOTHING_TO_COMMIT = "پیشنویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
|
||
CANCELLED = "لغو شد. ✔️"
|
||
UNKNOWN_HINT = (
|
||
"متوجه منظورت نشد 🤔\n"
|
||
"میتوانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
|
||
)
|
||
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
|
||
AI_NOT_CONFIGURED = (
|
||
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
|
||
"با مدیر سیستم تماس بگیر."
|
||
)
|
||
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
|
||
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
|
||
ERR_GENERIC = "خطا: {detail}"
|
||
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را میخواهی؟"
|
||
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
|
||
ACCEPT_REPORT = (
|
||
"✅ پذیرش انجام شد\n"
|
||
"استعلام #{id}\n"
|
||
"وضعیت: {status}\n"
|
||
"اقلام تصمیمگیریشده: {count}"
|
||
)
|
||
ARRIVE_REPORT = (
|
||
"🚚 ثبت رسید انجام شد\n"
|
||
"استعلام #{id} — وضعیت: {status}\n{lines}"
|
||
)
|
||
ACCEPT_DONE_TS = "شرایط انتخابشده: {label}"
|
||
|
||
|
||
def missing_block(missing: list[dict]) -> str:
|
||
"""Checklist of missing vs. captured fields."""
|
||
if not missing:
|
||
return "همه فیلدها کامل است ✅"
|
||
lines = []
|
||
for m in missing:
|
||
if m.get("ok"):
|
||
mark = "✅"
|
||
elif m.get("blocking"):
|
||
mark = "❌"
|
||
else:
|
||
mark = "⚠️"
|
||
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 = ["📋 پیشنویس استعلام"]
|
||
|
||
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing)
|
||
has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
|
||
|
||
if not has_blocking and not has_warnings:
|
||
lines.append("✅ همهی اطلاعات کامل است، آماده ثبت.")
|
||
elif has_blocking:
|
||
lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.")
|
||
else:
|
||
lines.append("⚠️ پیشنویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.")
|
||
|
||
if status_note:
|
||
lines.append(status_note)
|
||
|
||
lines.append("━━━━━━━━━━━━━━\n")
|
||
|
||
# 🏢 تأمینکننده
|
||
lines.append("🏢 تأمینکننده")
|
||
sup_name = payload.get("supplier_name")
|
||
is_official = payload.get("is_official", True)
|
||
official_badge = " (رسمی)" if is_official else " (غیررسمی)"
|
||
|
||
if payload.get("supplier_id"):
|
||
lines.append(f"{sup_name}{official_badge} ✅")
|
||
if payload.get("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(str(payload["supplier_contact_person"]))
|
||
if contact_parts:
|
||
lines.append(f"📞 {' — '.join(contact_parts)}")
|
||
elif sup_name:
|
||
lines.append(f"{sup_name}{official_badge} ✨ (تأمینکننده جدید)")
|
||
else:
|
||
lines.append("— (مشخص نشده)")
|
||
|
||
cat_name = payload.get("supplier_category_name")
|
||
if cat_name:
|
||
lines.append(f"\n🏷️ موضوع: {cat_name}")
|
||
|
||
# 📦 اقلام
|
||
items = payload.get("items") or []
|
||
total_amount = 0.0
|
||
has_any_price = False
|
||
|
||
if items:
|
||
lines.append(f"\n📦 اقلام ({fa_num(len(items))})")
|
||
default_proj = payload.get("project_name")
|
||
for i, it in enumerate(items, start=1):
|
||
name = it.get("material_name") or f"قلم {i}"
|
||
title_parts = [str(name)]
|
||
if it.get("brand_name"):
|
||
title_parts.append(str(it["brand_name"]))
|
||
if it.get("specs"):
|
||
title_parts.append("، ".join(str(s) for s in it["specs"]))
|
||
if not it.get("material_id"):
|
||
title_parts.append("✨ (کالای جدید)")
|
||
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
|
||
|
||
# Project
|
||
item_proj = it.get("project_name") or default_proj
|
||
if item_proj:
|
||
lines.append(f" 🏗️ {item_proj}")
|
||
else:
|
||
lines.append(" 🏗️ پروژه مشخص نشده")
|
||
|
||
# Qty, Unit, Price, Total
|
||
qty = it.get("quantity")
|
||
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
|
||
unit_price = it.get("unit_price")
|
||
|
||
calc_line = " "
|
||
if qty is not None:
|
||
calc_line += f"{fa_num(qty)} {unit or ''}".strip()
|
||
else:
|
||
calc_line += "مقدار نامشخص"
|
||
|
||
if unit_price:
|
||
has_any_price = True
|
||
calc_line += f" × {fa_num(unit_price)}"
|
||
if qty is not None:
|
||
try:
|
||
line_sum = float(qty) * float(unit_price)
|
||
total_amount += line_sum
|
||
calc_line += f" = {fa_num(line_sum)} تومان"
|
||
except (ValueError, TypeError):
|
||
calc_line += " تومان"
|
||
else:
|
||
calc_line += " تومان"
|
||
else:
|
||
calc_line += " × — = — تومان"
|
||
|
||
lines.append(calc_line)
|
||
|
||
if has_any_price and total_amount > 0:
|
||
lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
|
||
elif not has_any_price:
|
||
lines.append("\n💰 مجموع مبلغ: محاسبهنشده (قیمت اقلام وارد نشده)")
|
||
else:
|
||
lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
|
||
|
||
# 💳 شرایط پرداخت
|
||
sheets = payload.get("termsheets") or []
|
||
if sheets:
|
||
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"▫️ {lbl} — {fa_num(len(milestones))} مرحله")
|
||
step_sum = 0.0
|
||
for m in milestones:
|
||
due = fa_date(m.get("due_date"))
|
||
amt = m.get("amount")
|
||
amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی"
|
||
if amt:
|
||
try:
|
||
step_sum += float(amt)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
note = f" ({m['note']})" if m.get("note") else ""
|
||
lines.append(f" • {due} — {amt_str}{note}")
|
||
|
||
if total_amount > 0 and step_sum > 0:
|
||
diff = abs(total_amount - step_sum)
|
||
if diff < 1.0:
|
||
lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است")
|
||
else:
|
||
lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان")
|
||
if len(items) > 0:
|
||
lines.append(f" شامل: هر {fa_num(len(items))} قلم")
|
||
else:
|
||
disc = ""
|
||
if t.get("discount_value"):
|
||
disc = f" (تخفیف {fa_num(t['discount_value'])})"
|
||
lines.append(f"▫️ {lbl}{disc}")
|
||
elif items:
|
||
lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیشفرض)")
|
||
|
||
# Metadata
|
||
meta_lines = []
|
||
if payload.get("quote_date"):
|
||
meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}")
|
||
if payload.get("expiry_date"):
|
||
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
|
||
if payload.get("proforma_file"):
|
||
meta_lines.append(f"📎 پیشفاکتور: {payload['proforma_file']}")
|
||
if payload.get("notes"):
|
||
meta_lines.append(f"📝 توضیحات: {payload['notes']}")
|
||
|
||
if meta_lines:
|
||
lines.append("")
|
||
lines.extend(meta_lines)
|
||
|
||
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
|
||
if has_blocking or has_warnings:
|
||
lines.append("\n📋 وضعیت فیلدها:")
|
||
lines.append(missing_block(missing))
|
||
|
||
# Footer
|
||
lines.append("\n━━━━━━━━━━━━━━")
|
||
if has_blocking:
|
||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||
elif has_warnings:
|
||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||
lines.append("میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||
else:
|
||
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
|
||
lines = ["🏢 پیشنویس تأمینکننده جدید", f"نام: {esc(payload.get('name'))}"]
|
||
lines.append(f"دسته: {esc(payload.get('category_name'))}")
|
||
if payload.get("phone"):
|
||
lines.append(f"تلفن: {esc(payload['phone'])}")
|
||
if payload.get("location"):
|
||
lines.append(f"موقعیت: {esc(payload['location'])}")
|
||
if payload.get("contact_person"):
|
||
lines.append(f"رابط: {esc(payload['contact_person'])}")
|
||
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.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
|
||
if payload.get("topic"):
|
||
lines.append(f"تاپیک: {esc(payload['topic'])}")
|
||
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'))}"]
|
||
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
||
if sup:
|
||
lines.append(f"تأمینکننده: {esc(sup)}")
|
||
items = created.get("line_items") or []
|
||
if items:
|
||
lines.append(f"اقلام: {len(items)}")
|
||
sheets = created.get("termsheets") or []
|
||
if sheets:
|
||
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||
if payload.get("project_id"):
|
||
lines.append("پروژه: پیوست شد")
|
||
if remaining:
|
||
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
||
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def supplier_committed_report(created: dict) -> str:
|
||
return (
|
||
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'))}"
|
||
|
||
|
||
def quote_line(quote: dict) -> str:
|
||
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
|
||
items = quote.get("line_items") or []
|
||
item_names = []
|
||
for it in items:
|
||
mat = (it.get("material") or {}).get("name")
|
||
if mat:
|
||
qty = it.get("quantity")
|
||
item_names.append(f"{mat} ({qty})" if qty else mat)
|
||
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
|
||
return (
|
||
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
|
||
f"{fa_date(quote.get('quote_date'))}{tail}"
|
||
)
|
||
|
||
|
||
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
|
||
titles = {
|
||
"pending": "🔵 استعلامهای در انتظار بررسی",
|
||
"accepted": "🟢 پذیرفتهشده (در انتظار تحویل)",
|
||
"arrived": "✅ رسیده",
|
||
"expiring": "⏳ بهزودی منقضی میشوند (۷ روز آینده)",
|
||
"recent": "🕘 آخرین استعلامها",
|
||
}
|
||
if not quotes:
|
||
return f"{titles.get(kind, 'نتیجهای')}\nموردی نیست."
|
||
lines = [titles.get(kind, "استعلامها")]
|
||
if extra_note:
|
||
lines.append(extra_note)
|
||
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||
return "\n".join(lines)
|