refactor(bot)!: rename 'ai procurement' -> 'bot/' so the compose build context is committed

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.
This commit is contained in:
2026-10-02 12:52:25 +03:30
parent 07643dbdf5
commit 638076dbaa
43 changed files with 150 additions and 1 deletions
+325
View File
@@ -0,0 +1,325 @@
"""Update dispatch — the heart of the bot.
Pipeline per update:
callback_query → callbacks router (link-gated)
message text starting "/" → command router
voice/audio → download → STT → treated as text (source=voice)
text → pending-flow interception (linking / accept-arrive confirmations)
→ auth gate (link or admin)
→ NLU → intent router
"""
import logging
from app import drafts as draft_store
from app.config import settings
from app.nlu import understand
from app.runtime import BotContext, LinkedUser
from app.storage import save_proforma_file
from app.stt import transcribe, transcribe_multi
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
from . import commands, callbacks, quote, supplier, material, queries, decisions
logger = logging.getLogger("bot.handlers")
async def handle_update(ctx: BotContext, update: dict) -> None:
if update.get("callback_query"):
await callbacks.handle_callback(ctx, update["callback_query"])
return
message = update.get("message") or update.get("edited_message")
if not message:
return
chat_id = message["chat"]["id"]
try:
await handle_message(ctx, chat_id, message)
except Exception:
logger.exception("message handling failed for chat %s", chat_id)
await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.")
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
text = (message.get("text") or message.get("caption") or "").strip()
source = "text"
voice_transcripts: list[dict] | None = None
message_id = message.get("message_id")
if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text)
return
# Pending multi-step interactions take the message first.
flow = ctx.pending.get(chat_id)
if flow:
handled = await _route_to_flow(ctx, chat_id, flow, text)
if handled:
return
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through
user = await _gate(ctx, chat_id, message)
if user is None:
return # gate either started linking or is mid-linking
reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
def _is_audio(msg: dict) -> bool:
if msg.get("voice") or msg.get("audio"):
return True
doc = msg.get("document")
if doc and (doc.get("mime_type") or "").lower().startswith("audio/"):
return True
return False
async def _extract_voice() -> list[dict] | None:
nonlocal source
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
audio_media = message.get("voice") or message.get("audio") or message.get("document")
file_id = audio_media["file_id"]
try:
audio_bytes = await ctx.bale.download_file(file_id)
transcripts = await transcribe_multi(
settings,
audio_bytes,
mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"),
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id)
return None
if not transcripts:
await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id)
return None
return transcripts
# =========================================================================
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
# =========================================================================
if reply_to_msg_id is not None:
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None:
await ctx.send(
chat_id,
"⚠️ پیش‌نویس فعال مربوط به این پیام یافت نشد (ممکن است ثبت نهایی، منقضی یا لغو شده باشد).\nبرای ثبت استعلام جدید، پیام خود را بدون ریپلای ارسال فرمایید.",
reply_to=message_id,
)
return
# Check for proforma attachment (document or photo)
proforma_attached = False
saved_meta = None
doc = message.get("document")
photos = message.get("photo")
if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"):
doc_file_id = doc["file_id"]
doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf"
doc_mime = doc.get("mime_type") or "application/pdf"
elif photos:
largest = photos[-1] if isinstance(photos, list) else photos
doc_file_id = largest["file_id"]
doc_file_name = f"proforma_{message_id}.jpg"
doc_mime = "image/jpeg"
else:
doc_file_id = None
doc_file_name = None
doc_mime = None
if doc_file_id:
await ctx.bale.send_chat_action(chat_id, "upload_document")
try:
content = await ctx.bale.download_file(doc_file_id)
saved_meta = await save_proforma_file(
target_draft.id,
doc_file_name,
content,
mime_type=doc_mime,
storage_dir=settings.ATTACHMENTS_DIR,
)
payload = dict(target_draft.payload or {})
payload["proforma_file"] = saved_meta["file_name"]
payload["proforma"] = saved_meta
target_draft.payload = payload
await draft_store.save_payload(target_draft, mark_bale_synced=True)
proforma_attached = True
except Exception as exc:
logger.exception("Failed to persist proforma")
await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیش‌فاکتور: {esc(str(exc)[:100])}", reply_to=message_id)
return
if _is_audio(message):
voice_transcripts = await _extract_voice()
if not voice_transcripts:
return
text = voice_transcripts[0]["text"]
if proforma_attached and not text and not voice_transcripts:
await quote.render(ctx, chat_id, target_draft, fresh=False)
await ctx.send(
chat_id,
f"📎 پیش‌فاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅",
reply_to=message_id,
)
return
if not proforma_attached and not text and not voice_transcripts:
await ctx.send(
chat_id,
"⚠️ برای ویرایش پیش‌نویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیش‌فاکتور را بفرستید.",
reply_to=message_id,
)
return
# =========================================================================
# BRANCH 2: NEW QUOTE MODE — Messages without reply
# =========================================================================
else:
target_draft = None
if _is_audio(message):
voice_transcripts = await _extract_voice()
if not voice_transcripts:
return
text = voice_transcripts[0]["text"]
elif text:
source = "text"
else:
await ctx.send(
chat_id,
"⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.",
reply_to=message_id,
)
return
draft_summary = _draft_summary(target_draft) if target_draft else None
input_payload = voice_transcripts if voice_transcripts else text
result = await understand(settings, input_payload, draft_summary)
intent, patch = result.get("intent"), result.get("patch") or {}
consensus_text = result.get("consensus_text") or text
if source == "voice":
await ctx._log(chat_id, "in", "voice", consensus_text)
logger.info("chat %s intent=%s conf=%s consensus=%r (is_edit=%s)",
chat_id, intent, result.get("confidence"), consensus_text[:40], target_draft is not None)
if intent in ("register_quote", "update_draft"):
await quote.handle_entities(
ctx, chat_id, user, patch, source,
incoming_msg_id=message_id,
target_draft=target_draft,
)
elif intent == "commit_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
elif active.kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, active)
elif intent == "discard_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
if active.review_message_id:
try:
await ctx.delete(chat_id, active.review_message_id)
except Exception:
pass
ctx.pending.pop(chat_id, None)
await ctx.send(chat_id, CANCELLED, reply_to=message_id)
elif intent == "show_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
elif active.kind == draft_store.KIND_QUOTE:
await quote.render(ctx, chat_id, active, fresh=False)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.render(ctx, chat_id, active)
elif intent == "add_supplier":
await supplier.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "add_material":
await material.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "status_query":
await queries.handle_query(ctx, chat_id, user, patch)
elif intent == "accept_quote":
await decisions.start_accept(ctx, chat_id, user, patch)
elif intent == "arrive_items":
await decisions.start_arrive(ctx, chat_id, user, patch)
else:
await ctx.send(chat_id, UNKNOWN_HINT)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
"""Link or admin gate. Returns None when the message was consumed by linking."""
from . import links
link = await ctx.links.get(chat_id)
if link and link.user_id:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
if chat_id in ctx.admin_chat_ids:
return LinkedUser(chat_id, None, None, None, is_admin=True)
flow = ctx.pending.get(chat_id)
if flow and flow.kind == "link_password":
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
return None
if flow and flow.kind == "link_username":
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
return None
await ctx.send(chat_id, LINK_START)
ctx.pending.set(chat_id, "link_username", {})
return None
async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool:
from . import links, decisions
if flow.kind == "link_username":
await links.finish_username_step(ctx, chat_id, flow, text)
return True
if flow.kind == "link_password":
await links.finish_link(ctx, chat_id, flow, text=text, password=text)
return True
if flow.kind == "quote_pick":
await decisions.handle_quote_pick_text(ctx, chat_id, flow, text)
return True
if flow.kind in ("accept", "arrive_qty", "arrive_confirm"):
return await decisions.handle_flow_text(ctx, chat_id, flow, text)
return False
def _draft_summary(draft) -> str | None:
if draft is None:
return None
import json
payload = draft.payload or {}
if draft.kind == draft_store.KIND_QUOTE:
items = ", ".join(
f"{it.get('material_name') or '?'}"
+ (f" {it.get('quantity')}" if it.get("quantity") else "")
+ (f" {it.get('unit_name')}" if it.get("unit_name") else "")
for it in payload.get("items") or []
)
summary = (
f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; "
f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}"
)
elif draft.kind == draft_store.KIND_SUPPLIER:
summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}"
elif draft.kind == draft_store.KIND_MATERIAL:
summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}"
else:
summary = json.dumps(payload, ensure_ascii=False)[:500]
return summary
+107
View File
@@ -0,0 +1,107 @@
"""Inline-button routing (callback_query).
Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx}
/ ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows.
"""
import logging
from app import drafts as draft_store
from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc
from . import quote, supplier, material, decisions
logger = logging.getLogger("bot.callbacks")
async def handle_callback(ctx, cq: dict) -> None:
chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id")
data = cq.get("data") or ""
if chat_id is None:
return
try:
await _dispatch(ctx, chat_id, data, cq)
except Exception:
logger.exception("callback failed: %s", data)
await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.")
finally:
await ctx.bale.answer_callback_query(cq.get("id"), None)
async def _gate(ctx, chat_id: int) -> LinkedUser | None:
link = await ctx.links.get(chat_id)
if link and link.user_id:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
if chat_id in ctx.admin_chat_ids:
return LinkedUser(chat_id, None, None, None, is_admin=True)
await ctx.send(chat_id, UNLINKED)
return None
async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
user = await _gate(ctx, chat_id)
if user is None:
return
parts = data.split(":")
# ---- pending-flow buttons (accept / arrive / quote pick) ---------------
if parts[0] == "qc" and len(parts) == 2:
flow = ctx.pending.get(chat_id)
if flow and flow.kind == "quote_pick":
await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1]))
return
if parts[0] == "ac":
await decisions.handle_accept_callback(ctx, chat_id, user, parts)
return
if parts[0] == "ar":
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
return
# ---- pick a candidate for a draft field -------------------------------
if parts[0] == "pk" and len(parts) == 4:
draft = await draft_store.get_by_prefix(parts[1], chat_id)
if draft is None:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3]))
return
# ---- draft commit / cancel --------------------------------------------
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
draft = await draft_store.get_by_prefix(prefix, chat_id)
if draft is None or draft.kind != kind:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
if arg == "no":
await draft_store.set_status(draft.id, "cancelled")
ctx.pending.pop(chat_id, None)
if draft.review_message_id:
try:
await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیش‌نویس لغو شد.", reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
except Exception:
pass
await ctx.send(chat_id, CANCELLED)
return
if kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
elif kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, draft)
else:
await material.commit(ctx, chat_id, user, draft)
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
options = (draft.payload.get("options") or {}).get(field) or []
if idx < 0 or idx >= len(options):
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
choice = options[idx]
if draft.kind == draft_store.KIND_QUOTE:
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_SUPPLIER:
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_MATERIAL:
await material.apply_pick(ctx, chat_id, user, draft, field, choice)
+82
View File
@@ -0,0 +1,82 @@
"""/commands — /start /help /drafts /cancel /whoami /unlink."""
import logging
from app import drafts as draft_store
from app.runtime import ROLE_FA
from app.texts import CANCELLED, DRAFTS_EMPTY, DRAFTS_LIST_HEADER, HELP, UNLINKED, WELCOME, esc
logger = logging.getLogger("bot.commands")
KIND_FA = {"quote": "استعلام", "supplier": "تأمین‌کننده", "material": "کالا"}
STATUS_FA = {"active": "باز", "committed": "ثبت‌شده", "cancelled": "لغوشده", "expired": "منقضی"}
async def handle_command(ctx, chat_id: int, text: str) -> None:
command = text.split()[0].split("@")[0].lower()
if command == "/start":
await _start(ctx, chat_id)
elif command in ("/help", "/راهنما"):
await ctx.send(chat_id, HELP)
elif command == "/drafts":
await _drafts(ctx, chat_id)
elif command in ("/cancel", "/لغو"):
await _cancel(ctx, chat_id)
elif command == "/whoami":
await _whoami(ctx, chat_id)
elif command == "/unlink":
await ctx.links.delete(chat_id)
await ctx.send(chat_id, "اتصال قطع شد. برای اتصال دوباره /start بزن.")
else:
await ctx.send(chat_id, HELP)
async def _start(ctx, chat_id: int) -> None:
await ctx.send(chat_id, WELCOME)
link = await ctx.links.get(chat_id)
if not link or not link.user_id:
if chat_id not in ctx.admin_chat_ids:
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
ctx.pending.set(chat_id, "link_username", {})
async def _drafts(ctx, chat_id: int) -> None:
from sqlalchemy import select
from app.db import SessionLocal
from app.models import BotDraft
async with SessionLocal() as session:
rows = (await session.execute(
select(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
.order_by(BotDraft.updated_at.desc())
)).scalars().all()
if not rows:
await ctx.send(chat_id, DRAFTS_EMPTY)
return
lines = [DRAFTS_LIST_HEADER]
for d in rows:
p = d.payload or {}
title = p.get("supplier_name") or p.get("name") or "بدون عنوان"
lines.append(f"• {KIND_FA.get(d.kind, d.kind)}: {esc(title)}")
await ctx.send(chat_id, "\n".join(lines))
async def _cancel(ctx, chat_id: int) -> None:
ctx.pending.pop(chat_id)
active = await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
await ctx.send(chat_id, CANCELLED)
async def _whoami(ctx, chat_id: int) -> None:
link = await ctx.links.get(chat_id)
if chat_id in ctx.admin_chat_ids:
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
return
if not link or not link.user_id:
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}")
+389
View File
@@ -0,0 +1,389 @@
"""Accept / arrive flows — always with an explicit confirmation step."""
import logging
import re
from app.api.client import ApiError
from app.keyboards import accept_keyboard, arrive_keyboard, quote_pick_keyboard
from app.nlu import normalize_digits
from app.texts import (
ACCEPT_DONE_TS, ACCEPT_REPORT, ARRIVE_REPORT, CANCELLED, ERR_GENERIC,
QUOTE_NOT_FOUND, STATUS_FA, esc,
)
logger = logging.getLogger("bot.decisions")
EXPAND = "supplier,line_items,termsheets"
CONFIRM_WORDS = {"تایید", "تأیید", "ثبت", "ثبت کن", "اوکی", "اوکیه", "بله", "yes", "ok"}
CANCEL_WORDS = {"لغو", "بی خیال", "بیخیال", "انصراف", "نه", "cancel"}
def _norm(text: str) -> str:
return (text or "").strip().lower().replace("\u200c", " ")
# ---------------------------------------------------------------------------
# shared: pick a quote by supplier text
# ---------------------------------------------------------------------------
async def start_accept(ctx, chat_id, user, patch: dict) -> None:
await _start_for_status(ctx, chat_id, user, patch, mode="accept", status="offered")
async def start_arrive(ctx, chat_id, user, patch: dict) -> None:
await _start_for_status(ctx, chat_id, user, patch, mode="arrive", status="accepted")
async def _start_for_status(ctx, chat_id, user, patch: dict, mode: str, status: str) -> None:
target = (patch.get("target") or {})
q = target.get("supplier_name") or target.get("project_name") or target.get("quote_id")
try:
quotes, _ = await ctx.api.list_quotes(status=status, q=q, expand=EXPAND, page_size=10)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
if not quotes:
await ctx.send(chat_id, QUOTE_NOT_FOUND.format(q=esc(q or "?")))
return
if len(quotes) == 1:
if mode == "accept":
await begin_accept(ctx, chat_id, user, quotes[0])
else:
await begin_arrive(ctx, chat_id, user, quotes[0])
return
pairs = [(_quote_label(qq), str(qq["id"])) for qq in quotes]
flow = ctx.pending.set(chat_id, "quote_pick", {"mode": mode, "ids": [pid for _, pid in pairs]})
flow.data["user_id"] = user.actor_id
message = await ctx.send(chat_id, "چند مورد پیدا شد؛ کدام را می‌خواهی؟", reply_markup=quote_pick_keyboard(pairs))
flow.data["message_id"] = (message or {}).get("message_id")
def _quote_label(quote: dict) -> str:
sup = (quote.get("supplier") or {}).get("name") or "—"
return f"#{str(quote.get('id'))[:8]} {sup}"
async def apply_quote_pick(ctx, chat_id, user, flow, idx: int) -> None:
ids = flow.data.get("ids") or []
ctx.pending.pop(chat_id)
if idx < 0 or idx >= len(ids):
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
try:
quote = await ctx.api.get_quote(ids[idx], expand=EXPAND, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
if flow.data.get("mode") == "arrive":
await begin_arrive(ctx, chat_id, user, quote)
else:
await begin_accept(ctx, chat_id, user, quote)
async def handle_quote_pick_text(ctx, chat_id, flow, text: str) -> None:
if _norm(text) in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
numbers = re.findall(r"\d+", normalize_digits(text))
if numbers:
await apply_quote_pick(ctx, chat_id, _flow_user(flow), flow, int(numbers[0]) - 1)
else:
await ctx.send(chat_id, "شماره مورد را بفرست یا از دکمه‌ها استفاده کن.")
def _flow_user(flow):
from app.runtime import LinkedUser
return LinkedUser(chat_id=None, user_id=flow.data.get("user_id"), display_name=None,
role=None, is_admin=False)
# ---------------------------------------------------------------------------
# accept
# ---------------------------------------------------------------------------
def _accept_summary(quote: dict, chosen: int) -> str:
lines = [
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 "?"
unit = ((li.get("unit") or {}).get("name_fa")) or ""
price = li.get("unit_price")
parts = [f"• {esc(mat)} — مقدار {esc(li.get('quantity'))} {unit}"]
if price:
parts.append(f"قیمت {esc(price)}")
lines.append(" — ".join(parts))
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 ""
lines.append(f"{mark} [{i + 1}] {esc(ts.get('label'))}{discount} — {len(ts.get('schedule_steps') or [])} مرحله")
lines.append("")
lines.append("شرایط را انتخاب کن و پذیرش را تأیید کن.")
return "\n".join(lines)
async def begin_accept(ctx, chat_id, user, quote: dict) -> None:
termsheets = quote.get("termsheets") or []
if not termsheets:
await ctx.send(chat_id, "این استعلام شرایط پرداخت ندارد؛ اول در برنامه ثبت کن.")
return
if not quote.get("line_items"):
await ctx.send(chat_id, "این استعلام قلم ندارد.")
return
flow = ctx.pending.set(chat_id, "accept", {
"quote_id": str(quote["id"]),
"quote_snapshot": {
"id": quote.get("id"),
"supplier": quote.get("supplier"),
"line_items": quote.get("line_items") or [],
},
"termsheets": termsheets,
"chosen": 0,
"user_id": user.actor_id,
})
text = _accept_summary(quote, 0)
message = await ctx.send(chat_id, text, reply_markup=accept_keyboard([ts.get("label") or "?" for ts in termsheets]))
flow.data["message_id"] = (message or {}).get("message_id")
async def handle_accept_callback(ctx, chat_id, user, parts) -> None:
flow = ctx.pending.get(chat_id)
if not flow or flow.kind != "accept":
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
return
op = parts[1] if len(parts) > 1 else ""
if op == "no":
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
if op == "ts" and len(parts) == 3:
idx = int(parts[2])
if 0 <= idx < len(flow.data["termsheets"]):
flow.data["chosen"] = idx
await ctx.edit(chat_id, flow.data.get("message_id"),
_accept_summary(flow.data["quote_snapshot"], idx),
reply_markup=accept_keyboard([ts.get("label") or "?" for ts in flow.data["termsheets"]]))
return
if op == "ok":
await _commit_accept(ctx, chat_id, user, flow)
async def _commit_accept(ctx, chat_id, user, flow) -> None:
data = flow.data
quote_id = data["quote_id"]
termsheets = data["termsheets"]
ts_index = data.get("chosen", 0)
if not 0 <= ts_index < len(termsheets):
await ctx.send(chat_id, "شرایط پرداخت انتخاب نشده است.")
return
ts = termsheets[ts_index]
steps = ts.get("schedule_steps") or []
if not steps:
await ctx.send(chat_id, "ترم‌شیت انتخابی مرحله پرداخت ندارد؛ در برنامه تکمیلش کن.")
return
try:
quote = await ctx.api.get_quote(quote_id, expand=EXPAND, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
decisions_payload = []
problems = []
for li in quote.get("line_items") or []:
decision = li.get("decision") or {}
if decision.get("status") == "rejected":
continue
price = li.get("unit_price")
if not price:
problems.append(f"قیمت قلم «{(li.get('material') or {}).get('name') or '?'}» ثبت نشده")
continue
li_projects = li.get("projects") or []
project_id = li_projects[0]["id"] if len(li_projects) == 1 else quote.get("project_id")
decisions_payload.append({
"line_item_id": li["id"],
"status": "chosen",
"accepted_termsheet_index": 0,
"adjusted_unit_price": price,
"project_id": project_id,
})
if problems:
await ctx.send(chat_id, "پذیرش ممکن نیست:\n" + "\n".join(f"❌ {esc(p)}" for p in problems))
return
if not decisions_payload:
await ctx.send(chat_id, "قلم قابل پذیرشی نیست (همه رد شده‌اند؟).")
return
payload = {
"accepted_termsheets": [{
"id": ts["id"],
"label": ts.get("label"),
"discount_type": ts.get("discount_type") or "none",
"discount_value": ts.get("discount_value") or 0,
"schedule_steps": steps,
}],
"item_decisions": decisions_payload,
}
try:
result = await ctx.api.accept_quote(quote_id, payload, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
ctx.pending.pop(chat_id)
chosen_label = ts.get("label") or ""
await ctx.send(chat_id, ACCEPT_REPORT.format(
id=esc(str(result.get("id"))[:8]),
status=STATUS_FA.get(result.get("status"), result.get("status")),
count=len(decisions_payload),
) + "\n" + ACCEPT_DONE_TS.format(label=esc(chosen_label)))
async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
if flow.kind == "accept":
norm = _norm(text)
if norm in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return True
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
await _commit_accept(ctx, chat_id, _flow_user(flow), flow)
return True
await ctx.send(chat_id, "برای پذیرش از دکمه‌ها استفاده کن (یا /cancel).")
return True
if flow.kind == "arrive_qty":
await _handle_arrive_quantities(ctx, chat_id, flow, text)
return True
if flow.kind == "arrive_confirm":
norm = _norm(text)
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
await _commit_arrive(ctx, chat_id, _flow_user(flow), flow)
return True
if norm in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return True
await ctx.send(chat_id, "با دکمه‌ها تأیید یا لغو کن (یا /cancel).")
return True
return False
# ---------------------------------------------------------------------------
# arrive (partial deliveries)
# ---------------------------------------------------------------------------
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
lines = ["🚚 اعلام رسیدن اقلام", ""]
for li in items:
mat = (li.get("material") or {}).get("name") or "?"
ordered = li.get("quantity") or 0
received = ((li.get("receipt") or {}).get("received_qty")) or 0
unit = ((li.get("unit") or {}).get("name_fa")) or ""
got = ""
if quantities and li["id"] in quantities:
got = f" → الان: {esc(quantities[li['id']])} {unit}"
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
return "\n".join(lines)
async def begin_arrive(ctx, chat_id, user, quote: dict) -> None:
items = [li for li in quote.get("line_items") or [] if (li.get("decision") or {}).get("status") == "chosen"]
if not items:
await ctx.send(chat_id, "قلم پذیرفته‌شده‌ای برای تحویل نیست.")
return
flow = ctx.pending.set(chat_id, "arrive_qty", {
"quote_id": str(quote["id"]),
"items": [{"id": li["id"], "name": (li.get("material") or {}).get("name") or "?",
"ordered": li.get("quantity"), "unit": ((li.get("unit") or {}).get("name_fa")) or "",
"received": ((li.get("receipt") or {}).get("received_qty")) or 0} for li in items],
"user_id": user.actor_id,
})
text = (
f"استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}\n"
+ _arrive_summary(quote.get("line_items") or [])
+ "\n\nمقادیر رسیده را به ترتیب اقلام بفرست (مثلاً: «500 و 200»)، یا «تحویل کامل همه» را بزن."
)
message = await ctx.send(chat_id, text, reply_markup=arrive_keyboard())
flow.data["message_id"] = (message or {}).get("message_id")
async def _handle_arrive_quantities(ctx, chat_id, flow, text: str) -> None:
numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", normalize_digits(text))]
items = flow.data["items"]
if not numbers:
await ctx.send(chat_id, "مقادیر را عددی بفرست، مثل «500 و 200»، یا از دکمه‌ها استفاده کن.")
return
if len(numbers) == 1 and len(items) > 1:
await ctx.send(chat_id, f"{len(items)} قلم است؛ برای هر قلم یک عدد بفرست (به ترتیب).")
return
if len(numbers) != len(items):
await ctx.send(chat_id, "تعداد اعداد با تعداد اقلام نمی‌خواند؛ دوباره بفرست.")
return
quantities = {item["id"]: qty for item, qty in zip(items, numbers)}
flow.kind = "arrive_confirm"
flow.data["quantities"] = quantities
await ctx.edit(chat_id, flow.data.get("message_id"),
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
+ "\n\nتأیید می‌کنی؟",
reply_markup=arrive_keyboard())
def _items_with_details(items: list[dict]) -> list[dict]:
return [{
"id": it["id"],
"material": {"name": it["name"]},
"quantity": it["ordered"],
"unit": {"name_fa": it["unit"]},
"receipt": {"received_qty": it["received"]},
} for it in items]
async def handle_arrive_callback(ctx, chat_id, user, parts) -> None:
flow = ctx.pending.get(chat_id)
if not flow or not flow.kind.startswith("arrive"):
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
return
op = parts[1] if len(parts) > 1 else ""
if op == "no":
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
if op == "full":
quantities = {item["id"]: max(float(item["ordered"] or 0) - float(item["received"] or 0), 0) for item in flow.data["items"]}
if any(v <= 0 for v in quantities.values()):
await ctx.send(chat_id, "برای بعضی اقلام چیزی برای تحویل نمانده؛ مقادیر را دستی بفرست.")
return
flow.kind = "arrive_confirm"
flow.data["quantities"] = quantities
await ctx.edit(chat_id, flow.data.get("message_id"),
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
+ "\n\nتأیید می‌کنی؟",
reply_markup=arrive_keyboard())
return
if op == "ok":
if flow.kind != "arrive_confirm" or not flow.data.get("quantities"):
await ctx.send(chat_id, "اول مقادیر رسیده را بفرست یا «تحویل کامل همه» را بزن.")
return
await _commit_arrive(ctx, chat_id, user, flow)
async def _commit_arrive(ctx, chat_id, user, flow) -> None:
quantities: dict = flow.data["quantities"]
payload = {"items": [{"line_item_id": li_id, "received_qty": qty} for li_id, qty in quantities.items()]}
try:
result = await ctx.api.arrive_items(flow.data["quote_id"], payload, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
ctx.pending.pop(chat_id)
lines = []
for item in result.get("items") or []:
lines.append(f"• قلم {esc(str(item.get('line_item_id'))[:8])} — {esc(item.get('received_qty'))} ثبت شد")
await ctx.send(chat_id, ARRIVE_REPORT.format(
id=esc(str(result.get("id"))[:8]),
status=STATUS_FA.get(result.get("status"), result.get("status")),
lines="\n".join(lines) or "—",
))
+46
View File
@@ -0,0 +1,46 @@
"""Casdoor linking: chat ↔ ERP user (validated through the real login endpoint)."""
import logging
from app.api.client import ApiError
from app.runtime import ROLE_FA
from app.texts import LINK_ASK_PASSWORD, LINK_FAIL, LINK_OK, esc
logger = logging.getLogger("bot.links")
async def finish_username_step(ctx, chat_id: int, flow, text: str) -> None:
username = (text or "").strip()
if not username or username.startswith("/"):
ctx.pending.pop(chat_id)
return
flow.kind = "link_password"
flow.data["username"] = username
await ctx.send(chat_id, LINK_ASK_PASSWORD)
async def finish_link(ctx, chat_id: int, flow, text: str | None, password: str) -> None:
username = flow.data.get("username") or ""
ctx.pending.pop(chat_id)
if not password or password.startswith("/"):
return
try:
data = await ctx.api.login(username, password)
except ApiError as exc:
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(exc.message)))
return
except Exception as exc: # network etc.
logger.exception("login failed")
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(str(exc)[:200])))
return
user = data.get("user") or {}
display = " ".join(x for x in [user.get("first_name"), user.get("last_name")] if x) or username
await ctx.links.upsert(
chat_id=chat_id,
user_id=user.get("id"),
username=username,
display_name=display,
role=user.get("role"),
)
role_fa = ROLE_FA.get(user.get("role") or "", user.get("role") or "بدون نقش")
await ctx.send(chat_id, LINK_OK.format(name=esc(display), role=role_fa))
+98
View File
@@ -0,0 +1,98 @@
"""Add-material flow: name + canonical unit (required)."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
logger = logging.getLogger("bot.material")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
if draft is not None:
payload = draft.payload
merge_material_patch(payload, patch)
else:
payload = new_material_payload()
merge_material_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
if res.resolved:
payload["canonical_unit_id"] = res.candidates[0].id
payload["unit_name"] = res.candidates[0].name
options.pop("unit", None)
elif res.candidates:
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("unit", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
if field == "unit":
draft.payload["canonical_unit_id"] = choice["id"]
draft.payload["unit_name"] = choice["name"]
draft.payload.setdefault("options", {}).pop("unit", None)
await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)})
await render(ctx, chat_id, draft, fresh=False)
def _keyboard(payload: dict, draft_id: str) -> dict:
rows: list[list[dict]] = []
options = (payload.get("options") or {}).get("unit") or []
if options:
rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]])
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = material_missing(payload)
text = material_review_text(payload, missing)
keyboard = _keyboard(payload, draft.id)
if fresh:
message = await ctx.send(chat_id, text, reply_markup=keyboard)
draft.review_message_id = (message or {}).get("message_id")
await draft_store.save_payload(draft, missing={"fields": missing},
review_message_id=draft.review_message_id)
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
async def commit(ctx, chat_id, user, draft) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام کالا", missing.get("name")),
("unit", "واحد اصلی", missing.get("unit")),
] if not ok]
if problems:
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" +
"\n".join(f"❌ {esc(p)}" for p in problems))
return
body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])}
if payload.get("topic"):
body["topic"] = payload["topic"]
try:
created = await ctx.api.create_material(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("materials")
await ctx.send(chat_id, material_committed_report(created))
+44
View File
@@ -0,0 +1,44 @@
"""Status queries: «استعلام‌های در انتظار؟»، «کدام‌ها منقضی می‌شوند؟» …"""
import logging
from datetime import date, timedelta
from app.api.client import ApiError
from app.texts import ERR_GENERIC, status_report, esc
logger = logging.getLogger("bot.queries")
EXPAND = "supplier,line_items"
async def handle_query(ctx, chat_id, user, patch: dict) -> None:
query = patch.get("query") or {}
kind = query.get("kind") or "pending"
supplier_name = query.get("supplier_name")
q = supplier_name or query.get("project_name")
try:
if kind == "expiring":
quotes, _ = await ctx.api.list_quotes(status="offered", expand=EXPAND,
q=q, sort="-quote_date", page_size=100)
deadline = date.today() + timedelta(days=query.get("days") or 7)
soon = []
for quote in quotes:
expiry = (quote.get("expiry_date") or "")[:10]
if expiry and expiry <= deadline.isoformat():
soon.append(quote)
soon.sort(key=lambda x: x.get("expiry_date") or "9999")
await ctx.send(chat_id, status_report("expiring", soon[:10]))
return
if kind in ("accepted", "arrived", "pending", "offered"):
status = "offered" if kind == "pending" else kind
quotes, _ = await ctx.api.list_quotes(status=status, expand=EXPAND, q=q,
sort="-quote_date", page_size=10)
await ctx.send(chat_id, status_report("pending" if kind == "offered" else kind, quotes))
return
# recent / anything else
quotes, meta = await ctx.api.list_quotes(expand=EXPAND, q=q, sort="-quote_date", page_size=5)
await ctx.send(chat_id, status_report("recent", quotes))
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
+349
View File
@@ -0,0 +1,349 @@
"""Register-quote flow: entities → draft → resolve → review (+missing) → commit."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
new_quote_payload, quote_ready,
)
from app.keyboards import quote_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
logger = logging.getLogger("bot.quote")
FIELD_FA = {"sup": "تأمین‌کننده", "prj": "پروژه"}
async def handle_entities(
ctx, chat_id, user, patch: dict, source: str | None,
incoming_msg_id: int | None = None,
target_draft = None,
) -> None:
prev_review_msg_id = None
if target_draft is not None:
draft = target_draft
prev_review_msg_id = draft.review_message_id
draft.review_message_id = None
payload = draft.payload
merge_quote_patch(payload, patch)
draft.source_message_id = incoming_msg_id
else:
payload = new_quote_payload()
merge_quote_patch(payload, patch)
draft = await draft_store.create(
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
)
await resolve_payload(ctx, payload)
if prev_review_msg_id:
try:
await ctx.delete(chat_id, prev_review_msg_id)
except Exception as e:
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
# ---------------------------------------------------------------------------
# resolution
# ---------------------------------------------------------------------------
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("supplier_id") and payload.get("supplier_name"):
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
if res.resolved:
c = res.candidates[0]
payload["supplier_id"] = c.id
payload["supplier_name"] = c.name
extra = c.extra or {}
for k in ("location", "phone", "contact_person", "category_name"):
if extra.get(k):
payload[f"supplier_{k}"] = extra[k]
options.pop("sup", None)
elif res.candidates:
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("sup", None)
if not payload.get("project_id") and payload.get("project_name"):
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
if res.resolved:
payload["project_id"] = res.candidates[0].id
payload["project_name"] = res.candidates[0].name
if payload["project_id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(payload["project_id"])
options.pop("prj", None)
elif res.candidates:
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("prj", None)
for p_name in payload.get("project_names") or []:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
p_id = res.candidates[0].id
if p_id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(p_id)
if not payload.get("project_id"):
payload["project_id"] = p_id
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name"):
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
if res.resolved:
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
options.pop(f"mat{i}", None)
elif res.candidates:
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop(f"mat{i}", None)
if not item.get("unit_id"):
await _resolve_unit(ctx, item)
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
item["material_id"] = material_id
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
async def _resolve_unit(ctx, item: dict) -> None:
if item.get("unit_id"):
return
if item.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
if res.resolved:
item["unit_id"] = res.candidates[0].id
item["unit_explicit"] = True
item["unit_name"] = res.candidates[0].name
return
canonical = ((item.get("material_row") or {}).get("canonical_unit_id"))
if canonical:
item["unit_id"] = str(canonical)
item["unit_explicit"] = False
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
payload = draft.payload
options = payload.setdefault("options", {})
options.pop(field, None)
if field == "sup":
payload["supplier_id"] = choice["id"]
payload["supplier_name"] = choice["name"]
elif field == "prj":
payload["project_id"] = choice["id"]
payload["project_name"] = choice["name"]
elif field.startswith("mat"):
index = int(field[3:]) - 1
items = payload.get("items") or []
if 0 <= index < len(items):
items[index]["material_name"] = choice["name"]
_set_material(items[index], choice["id"], choice["name"])
await _resolve_unit(ctx, items[index])
elif field.startswith("unit"):
index = int(field[4:]) - 1
items = payload.get("items") or []
if 0 <= index < len(items):
items[index]["unit_id"] = choice["id"]
items[index]["unit_explicit"] = True
items[index]["unit_name"] = choice["name"]
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
await render(ctx, chat_id, draft, fresh=False)
# ---------------------------------------------------------------------------
# rendering
# ---------------------------------------------------------------------------
def _pick_rows(payload: dict) -> list[list[str]]:
"""Field labels shown as pick-button rows (via keyboards.pick_buttons)."""
rows: list[list[str]] = []
options = payload.get("options") or {}
if options.get("sup"):
rows.append(["sup"] + [o["name"] for o in options["sup"]])
if options.get("prj"):
rows.append(["prj"] + [o["name"] for o in options["prj"]])
for i, item in enumerate(payload.get("items") or [], start=1):
if options.get(f"mat{i}"):
rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]])
if options.get(f"unit{i}"):
rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]])
return rows
def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict:
from app.keyboards import pick_buttons
prefix = str(draft_id)[:8]
pick_rows: list[list[dict]] = []
for row in _pick_rows(payload):
field, names = row[0], row[1:]
pick_rows += pick_buttons(prefix, field, names[:6])
ready_items = sum(
1 for it in payload.get("items") or []
if it.get("material_id") and it.get("quantity") and it.get("unit_id")
)
has_partial = 0 < ready_items < len(payload.get("items") or [])
return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url)
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
reply_to: int | None = None) -> None:
from app.config import settings
from app.security import create_draft_token
payload = draft.payload
missing = compute_quote_missing(payload)
token = create_draft_token(
draft.id,
chat_id=chat_id,
secret=settings.DRAFT_TOKEN_SECRET,
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
)
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
text = quote_review_text(payload, missing, status_note=note)
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
if fresh:
message = await ctx.send(chat_id, text, reply_markup=keyboard, reply_to=reply_to)
new_review_id = (message or {}).get("message_id")
if new_review_id:
draft.review_message_id = new_review_id
await draft_store.save_payload(
draft,
missing={"fields": missing},
review_message_id=new_review_id,
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
else:
await draft_store.save_payload(
draft,
missing={"fields": missing},
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
else:
if not draft.review_message_id:
return
success = await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
if success:
await draft_store.save_payload(draft, missing={"fields": missing}, mark_bale_synced=True)
else:
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
await draft_store.set_bale_updated_at(draft.id)
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
# 1. Sequential pre-creation for new supplier if user wants to commit
if not payload.get("supplier_id") and payload.get("supplier_name"):
cat_id = payload.get("supplier_category_id")
if not cat_id:
cats = await ctx.resolver.categories()
if cats:
cat_id = cats[0].id
if cat_id:
try:
sup_body = {
"name": payload["supplier_name"],
"category_id": cat_id,
}
if payload.get("supplier_phone"):
sup_body["phone"] = payload["supplier_phone"]
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
payload["supplier_id"] = created_sup["id"]
ctx.resolver.invalidate("suppliers")
except Exception as e:
logger.warning("Could not auto-create supplier: %s", e)
# 2. Sequential pre-creation for new materials
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
try:
created_mat = await ctx.api.create_material({
"name": item["material_name"],
"canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
ctx.resolver.invalidate("materials")
except Exception as e:
logger.warning("Could not auto-create material: %s", e)
ready, blockers = quote_ready(payload)
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
if body is None:
lines = ["ثبت ممکن نیست؛ این‌ها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems]
await ctx.send(chat_id, "\n".join(lines))
return
if not ready and not only_resolved:
lines = ["برای ثبت کامل، اول این‌ها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers]
lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."]
await ctx.send(chat_id, "\n".join(lines))
return
try:
created = await ctx.api.create_quote(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
except Exception:
logger.exception("create_quote failed")
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه"))
return
await draft_store.set_status(draft.id, "committed")
ctx.pending.pop(chat_id, None)
ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or ""
# If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"):
try:
from pathlib import Path
p_path = Path(proforma_info["saved_path"])
if p_path.exists():
file_bytes = p_path.read_bytes()
await ctx.api.upload_quote_file(
quote_id,
"proforma",
proforma_info.get("file_name") or "proforma.pdf",
file_bytes,
mime_type=proforma_info.get("mime_type") or "application/pdf",
actor_id=user.actor_id,
)
logger.info("Successfully uploaded proforma to quote %s", quote_id)
except Exception as e:
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
if draft.review_message_id:
try:
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
except Exception:
pass
remaining = [
m["label"] for m in compute_quote_missing(payload)
if not m["blocking"] and not m["ok"]
]
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
if dropped > 0:
remaining.append(f"{dropped} قلم ناشناس حذف شد")
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
+103
View File
@@ -0,0 +1,103 @@
"""Add-supplier flow: name + one of the 8 fixed Persian categories (required)."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
logger = logging.getLogger("bot.supplier")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
if draft is not None:
payload = draft.payload
merge_supplier_patch(payload, patch)
else:
payload = new_supplier_payload()
merge_supplier_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("category_id") and payload.get("category_name"):
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
if res.resolved:
payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name
options.pop("cat", None)
elif res.candidates:
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("cat", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
if field == "cat":
draft.payload["category_id"] = choice["id"]
draft.payload["category_name"] = choice["name"]
draft.payload.setdefault("options", {}).pop("cat", None)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(draft.payload)})
await render(ctx, chat_id, draft, fresh=False)
def _keyboard(payload: dict, draft_id: str) -> dict:
rows: list[list[dict]] = []
options = (payload.get("options") or {}).get("cat") or []
if options:
rows += pick_buttons(draft_id, "cat", [o["name"] for o in options[:6]])
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = supplier_missing(payload)
text = supplier_review_text(payload, missing)
keyboard = _keyboard(payload, draft.id)
if fresh:
message = await ctx.send(chat_id, text, reply_markup=keyboard)
draft.review_message_id = (message or {}).get("message_id")
await draft_store.save_payload(draft, missing={"fields": missing},
review_message_id=draft.review_message_id)
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
async def commit(ctx, chat_id, user, draft) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام تأمین‌کننده", missing.get("name")),
("category", "دسته‌بندی", missing.get("category")),
] if not ok]
if problems:
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" +
"\n".join(f"❌ {esc(p)}" for p in problems))
return
body = {
"name": payload["name"],
"category_id": payload["category_id"],
"phone": payload.get("phone"),
"location": payload.get("location"),
"contact_person": payload.get("contact_person"),
}
body = {k: v for k, v in body.items() if v}
try:
created = await ctx.api.create_supplier(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("suppliers")
await ctx.send(chat_id, supplier_committed_report(created))