"""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 "—", ))