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:
@@ -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 "—",
|
||||
))
|
||||
Reference in New Issue
Block a user