diff --git a/bot/app/api/client.py b/bot/app/api/client.py index 9dee7e2..674883f 100644 --- a/bot/app/api/client.py +++ b/bot/app/api/client.py @@ -94,6 +94,9 @@ class ErpClient: async def list_projects(self, page_size: int = 100) -> list[dict]: return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or [] + async def create_project(self, payload: dict, actor_id: str | None = None) -> dict: + return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).get("data") or {} + async def list_categories(self, page_size: int = 100) -> list[dict]: return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or [] diff --git a/bot/app/defaults.py b/bot/app/defaults.py new file mode 100644 index 0000000..b7c6529 --- /dev/null +++ b/bot/app/defaults.py @@ -0,0 +1,252 @@ +"""Centralized market conventions & domain defaults for procurement bot. + +This module isolates all procurement business heuristics and defaults (equal +installments calculation, end-of-solar-month due dates, validity rules per +material, project assignment logic, official vs unofficial defaults). + +drafts.py and nlu.py consume this module directly. +""" +from datetime import date, datetime, timedelta, timezone + +# --------------------------------------------------------------------------- +# Jalali Calendar math (Pure Python, zero-dependency) +# --------------------------------------------------------------------------- + +JALALI_MONTH_NAMES = [ + "", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", + "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند" +] + + +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 j2g(jy: int, jm: int, jd: int) -> tuple[int, int, int]: + jy2 = jy + 1595 + days = -355668 + (365 * jy2) + ((jy2 // 33) * 8) + (((jy2 % 33) + 3) // 4) + jd + if jm < 7: + days += (jm - 1) * 31 + else: + days += ((jm - 7) * 30) + 186 + gy = 400 * (days // 146097) + days %= 146097 + if days > 36524: + days -= 1 + gy += 100 * (days // 36524) + days %= 36524 + if days >= 365: + days += 1 + gy += 4 * (days // 1461) + days %= 1461 + if days > 365: + gy += (days - 1) // 365 + days = (days - 1) % 365 + gd = days + 1 + sal_a = [0, 31, 29 if ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + gm = 0 + while gm < 13 and gd > sal_a[gm]: + gd -= sal_a[gm] + gm += 1 + return gy, gm, gd + + +def is_jalali_leap(jy: int) -> bool: + return (((jy + 38) * 31) % 128) <= 31 + + +def jalali_month_days(jy: int, jm: int) -> int: + if 1 <= jm <= 6: + return 31 + elif 7 <= jm <= 11: + return 30 + elif jm == 12: + return 30 if is_jalali_leap(jy) else 29 + return 30 + + +def today_jalali(ref_date: date | None = None) -> tuple[int, int, int]: + d = ref_date or date.today() + return g2j(d.year, d.month, d.day) + + +def jalali_to_iso(jy: int, jm: int, jd: int) -> str: + gy, gm, gd = j2g(jy, jm, jd) + return f"{gy:04d}-{gm:02d}-{gd:02d}" + + +def jalali_format(jy: int, jm: int, jd: int) -> str: + return f"{jy:04d}/{jm:02d}/{jd:02d}" + + +def jalali_month_name(jm: int) -> str: + return JALALI_MONTH_NAMES[jm] if 1 <= jm <= 12 else "" + + +# --------------------------------------------------------------------------- +# Market Conventions & Constants +# --------------------------------------------------------------------------- + +DEFAULT_IS_OFFICIAL = True + +# Standard validity days in volatile markets (materials) +DEFAULT_VALIDITY_DAYS_MAP: dict[str, int] = { + "آهن": 1, + "میلگرد": 1, + "تیرآهن": 1, + "ورق": 1, + "پروفیل": 2, + "سیمان": 3, + "گچ": 7, + "لوله": 7, + "اتصالات": 7, + "شیرآلات": 7, + "شن": 7, + "ماسه": 7, +} +FALLBACK_VALIDITY_DAYS: int = 7 + + +def compute_validity_date(materials: list[str] | None = None, explicit_days: int | None = None, + ref_date: date | None = None) -> tuple[str, str]: + """Compute (iso_date, jalali_date_str) based on material or explicit days.""" + base = ref_date or date.today() + if explicit_days is not None and explicit_days > 0: + days = explicit_days + else: + days = FALLBACK_VALIDITY_DAYS + if materials: + for m in materials: + m_str = (m or "").lower() + for k, v in DEFAULT_VALIDITY_DAYS_MAP.items(): + if k in m_str: + days = min(days, v) + target = base + timedelta(days=days) + jy, jm, jd = g2j(target.year, target.month, target.day) + return f"{target.year:04d}-{target.month:02d}-{target.day:02d}", jalali_format(jy, jm, jd) + + +# --------------------------------------------------------------------------- +# Payment Terms Generators (Heuristics) +# --------------------------------------------------------------------------- + +def build_equal_installments( + total_amount: float, + count: int, + ref_date: date | None = None, +) -> list[dict]: + """Build equal installments milestones. + + - 1st milestone: today (or ref_date) + - 2nd..nth milestones: end of subsequent Jalali months + Amounts: total_amount / count, rounded to thousands, with remainder added to last milestone. + """ + if count < 1: + count = 1 + base = ref_date or date.today() + curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day) + + base_per_installment = int(total_amount // count) if total_amount > 0 else 0 + remainder = int(total_amount - (base_per_installment * count)) if total_amount > 0 else 0 + + milestones: list[dict] = [] + + # Milestone 1: today + m1_iso = f"{base.year:04d}-{base.month:02d}-{base.day:02d}" + m1_jalali = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}" + milestones.append({ + "due_date": m1_iso, + "due_date_jalali": m1_jalali, + "amount": base_per_installment, + "note": f"قسط اول (امروز - {curr_jd} {jalali_month_name(curr_jm)})", + }) + + # Milestone 2..N: end of consecutive Jalali months + target_jy = curr_jy + target_jm = curr_jm + + for i in range(2, count + 1): + target_jm += 1 + if target_jm > 12: + target_jm = 1 + target_jy += 1 + end_day = jalali_month_days(target_jy, target_jm) + iso_str = jalali_to_iso(target_jy, target_jm, end_day) + jalali_str = f"{target_jy}/{target_jm:02d}/{end_day:02d}" + amt = base_per_installment + (remainder if i == count else 0) + m_name = jalali_month_name(target_jm) + milestones.append({ + "due_date": iso_str, + "due_date_jalali": jalali_str, + "amount": amt, + "note": f"قسط {i} (پایان {m_name})", + }) + + return milestones + + +def build_cash_today_termsheet( + total_amount: float, + discount_percent: float = 0.0, + ref_date: date | None = None, +) -> dict: + """Build cash today payment termsheet.""" + base = ref_date or date.today() + curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day) + iso_date = f"{base.year:04d}-{base.month:02d}-{base.day:02d}" + jalali_str = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}" + + discount_val = 0.0 + payable = total_amount + if discount_percent > 0 and total_amount > 0: + discount_val = round((total_amount * discount_percent) / 100.0) + payable = total_amount - discount_val + + disc_label = f" (تخفیف {int(discount_percent)}٪)" if discount_percent > 0 else "" + return { + "label": f"پرداخت یکباره امروز{disc_label}", + "discount_type": "percent" if discount_percent > 0 else "none", + "discount_value": discount_percent if discount_percent > 0 else 0.0, + "milestones": [ + { + "due_date": iso_date, + "due_date_jalali": jalali_str, + "amount": payable, + "note": "تسویه نقدی یکباره (امروز)", + } + ], + "description": f"مبلغ فاکتور: {int(total_amount):,} | تخفیف: {int(discount_val):,} | خالص پرداختی: {int(payable):,}", + } + + +def propose_project_distribution(project_names: list[str], items: list[dict]) -> list[tuple[str, str]]: + """Propose assignment of items to projects when multiple projects exist. + + Returns list of (item_material_name, assigned_project_name). + """ + clean_projects = [p.strip() for p in project_names if p and p.strip()] + if not clean_projects or not items: + return [] + proposals: list[tuple[str, str]] = [] + p_len = len(clean_projects) + for idx, it in enumerate(items): + assigned_p = clean_projects[idx % p_len] + mat_name = it.get("material_name") or f"قلم {idx + 1}" + proposals.append((mat_name, assigned_p)) + return proposals diff --git a/bot/app/drafts.py b/bot/app/drafts.py index 85b3821..f336386 100644 --- a/bot/app/drafts.py +++ b/bot/app/drafts.py @@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import cast, func, select, String, update +from app import defaults from app.db import SessionLocal from app.models import BotDraft from app.nlu import normalize_text @@ -23,6 +24,7 @@ from app.nlu import normalize_text KIND_QUOTE = "quote" KIND_SUPPLIER = "supplier" KIND_MATERIAL = "material" +KIND_PROJECT = "project" # --------------------------------------------------------------------------- @@ -70,6 +72,22 @@ async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None: return (await session.execute(stmt)).scalar_one_or_none() +async def get_by_ambiguity_message_id(chat_id: int, message_id: int) -> tuple[BotDraft | None, dict | None]: + """Find the active draft and specific ambiguity dict associated with message_id.""" + async with SessionLocal() as session: + stmt = ( + select(BotDraft) + .where(BotDraft.chat_id == chat_id, BotDraft.status == "active") + .order_by(BotDraft.updated_at.desc()) + ) + drafts = (await session.execute(stmt)).scalars().all() + for d in drafts: + for amb in (d.payload or {}).get("ambiguities") or []: + if amb.get("message_id") == message_id: + return d, amb + return None, None + + async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None: """Resolve the short id used in callback_data (first 8 hex of the UUID).""" async with SessionLocal() as session: @@ -154,9 +172,10 @@ def new_quote_payload() -> dict: return { "supplier_name": None, "supplier_id": None, "project_name": None, "project_id": None, "project_names": [], "project_ids": [], - "is_official": True, "notes": None, "expiry_date": None, + "is_official": defaults.DEFAULT_IS_OFFICIAL, "notes": None, "expiry_date": None, "items": [], "termsheets": [], "options": {}, "proforma_file": None, + "ambiguities": [], } @@ -206,11 +225,15 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool: if not payload.get("project_names"): payload["project_names"] = [payload["project_name"]] changed = True - set_field("is_official", patch.get("is_official")) + set_field("is_official", patch.get("is_official", defaults.DEFAULT_IS_OFFICIAL)) if patch.get("notes"): payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip() changed = True - if patch.get("expiry_date"): + if patch.get("expiry_days"): + m_names = [it.get("material_name") for it in payload.get("items") or [] if it.get("material_name")] + iso_val, _ = defaults.compute_validity_date(materials=m_names, explicit_days=int(patch["expiry_days"])) + set_field("expiry_date", iso_val) + elif patch.get("expiry_date"): set_field("expiry_date", patch["expiry_date"]) for raw in patch.get("items") or []: @@ -219,10 +242,8 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool: if name: best, best_ratio = None, 0.0 for item in payload["items"]: - # If both items have distinct specs, they are different line items if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]): continue - # If both items have distinct brands, they are different line items if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]): continue ratio = _similarity(name, item.get("material_name")) @@ -249,34 +270,130 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool: if spec_str and not target.get("notes"): target["notes"] = spec_str changed = True - for key in ("unit_name", "brand_name", "notes"): + for key in ("unit_name", "brand_name", "project_name", "notes"): if raw.get(key): val = str(raw[key]).strip() if target.get(key) != val: target[key] = val changed = True + # Calculate total amount of items + total_amount = sum( + (float(it.get("quantity") or 0) * float(it.get("unit_price") or 0)) + for it in payload.get("items") or [] + ) + for raw in patch.get("payment_terms") or []: + p_type = raw.get("type") or "" label = (raw.get("label") or "شرایط پرداخت").strip() - milestones = [] - for m in raw.get("milestones") or []: - if m.get("due_date") or m.get("amount"): - milestones.append({ - "due_date": m.get("due_date"), - "amount": float(m.get("amount") or 0), - "note": m.get("note"), - }) - entry = { - "label": label, - "discount_type": raw.get("discount_type") or "none", - "discount_value": float(raw.get("discount_value") or 0), - "milestones": milestones, - "description": (raw.get("description") or "").strip() or None, - } - if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]): - payload["termsheets"].append(entry) + + # 1. Cash today with discount + if p_type == "cash_today" or (raw.get("is_today") and not raw.get("installments_count")): + disc_pct = float(raw.get("discount_percent") or raw.get("discount_value") or 0.0) + entry = defaults.build_cash_today_termsheet(total_amount, discount_percent=disc_pct) + if label and "یکباره" not in label and "نقد" not in label: + entry["label"] = label + existing = [t for t in payload["termsheets"] if "نقدی" in t.get("label", "") or "امروز" in t.get("label", "")] + if existing: + existing[0].update(entry) + else: + payload["termsheets"].append(entry) changed = True + # 2. Equal installments -> Generate Ambiguity sub-message and record termsheet + elif p_type == "installments" or raw.get("installments_count"): + count = int(raw.get("installments_count") or 3) + milestones = defaults.build_equal_installments(total_amount, count=count) + lines = [f"• {m['note']}: {int(m['amount']):,} تومان" for m in milestones] + amb_id = f"terms_{count}_split" + + if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]): + payload["termsheets"].append({ + "label": label or f"{count} قسط در طول زمان", + "discount_type": "none", + "discount_value": 0, + "milestones": [], + "description": None, + }) + + ambs = payload.setdefault("ambiguities", []) + existing_amb = next((a for a in ambs if a.get("id") == amb_id), None) + proposed = { + "payment_terms": [{ + "label": label or f"{count} قسط در طول زمان", + "milestones": milestones, + "discount_type": "none", + "discount_value": 0, + }] + } + if existing_amb: + existing_amb["details"] = "\n".join(lines) + existing_amb["proposed_patch"] = proposed + else: + ambs.append({ + "id": amb_id, + "kind": "payment_schedule", + "title": f"زمان‌بندی {count} قسط پرداخت", + "details": "\n".join(lines), + "status": "pending", + "proposed_patch": proposed, + }) + changed = True + + # 3. Explicit milestones from user or general label + else: + milestones = [] + for m in raw.get("milestones") or []: + if m.get("due_date") or m.get("amount"): + milestones.append({ + "due_date": m.get("due_date"), + "amount": float(m.get("amount") or 0), + "note": m.get("note"), + }) + entry = { + "label": label, + "discount_type": raw.get("discount_type") or "none", + "discount_value": float(raw.get("discount_value") or 0), + "milestones": milestones, + "description": (raw.get("description") or "").strip() or None, + } + existing = [t for t in payload["termsheets"] if normalize_text(t.get("label", "")) == normalize_text(label)] + if existing: + if milestones: + existing[0]["milestones"] = milestones + else: + payload["termsheets"].append(entry) + changed = True + + # 4. Multi-project ambiguity generation + proj_names = payload.get("project_names") or [] + items = payload.get("items") or [] + if len(proj_names) > 1 and len(items) > 1: + unassigned = any(not it.get("project_name") for it in items) + if unassigned: + proposals = defaults.propose_project_distribution(proj_names, items) + if proposals: + lines = [f"• {mat} 👈 پروژه {prj}" for mat, prj in proposals] + amb_id = "proj_split" + ambs = payload.setdefault("ambiguities", []) + existing_amb = next((a for a in ambs if a.get("id") == amb_id), None) + proposed = { + "items": [{"material_name": mat, "project_name": prj} for mat, prj in proposals] + } + if existing_amb: + existing_amb["details"] = "\n".join(lines) + existing_amb["proposed_patch"] = proposed + else: + ambs.append({ + "id": amb_id, + "kind": "project_assignment", + "title": f"تخصیص اقلام به پروژه‌های {' و '.join(proj_names)}", + "details": "\n".join(lines), + "status": "pending", + "proposed_patch": proposed, + }) + changed = True + return changed @@ -292,7 +409,13 @@ def compute_quote_missing(payload: dict) -> list[dict]: add("supplier", "تأمین‌کننده", sup_ok, True) # In DB: project_id is nullable (optional) - add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False) + has_proj = bool( + payload.get("project_id") + or payload.get("project_ids") + or payload.get("project_name") + or payload.get("project_names") + ) + add("project", "پروژه", has_proj, False) # In DB: expiry_date is nullable (optional) add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False) @@ -478,5 +601,28 @@ def material_missing(payload: dict) -> list[dict]: ] +def new_project_payload() -> dict: + return {"name": None, "description": None, "options": {}} + + +def merge_project_patch(payload: dict, patch: dict) -> bool: + src = patch.get("project") or patch + changed = False + if src.get("name") and payload.get("name") != str(src["name"]).strip(): + payload["name"] = str(src["name"]).strip() + changed = True + if src.get("description") and payload.get("description") != str(src["description"]).strip(): + payload["description"] = str(src["description"]).strip() + changed = True + return changed + + +def project_missing(payload: dict) -> list[dict]: + return [ + {"key": "name", "label": "نام پروژه", "ok": bool(payload.get("name")), "blocking": True}, + {"key": "description", "label": "توضیحات پروژه", "ok": bool(payload.get("description")), "blocking": False}, + ] + + def short_id() -> str: return uuid.uuid4().hex[:8] diff --git a/bot/app/handlers/__init__.py b/bot/app/handlers/__init__.py index 1d74591..6107ec1 100644 --- a/bot/app/handlers/__init__.py +++ b/bot/app/handlers/__init__.py @@ -18,7 +18,7 @@ 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 +from . import commands, callbacks, quote, supplier, material, project, queries, decisions logger = logging.getLogger("bot.handlers") @@ -98,6 +98,12 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: # BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft # ========================================================================= if reply_to_msg_id is not None: + # Check if replying to an ambiguity message (Requirement 2.b) + amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id) + if amb_draft is not None and target_amb is not None: + await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id) + return + target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) if target_draft is None: await ctx.send( @@ -221,6 +227,8 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: await supplier.commit(ctx, chat_id, user, active) elif active.kind == draft_store.KIND_MATERIAL: await material.commit(ctx, chat_id, user, active) + elif active.kind == draft_store.KIND_PROJECT: + await project.commit(ctx, chat_id, user, active) elif intent == "discard_draft": active = target_draft or await draft_store.get_active(chat_id) if active: @@ -242,10 +250,14 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: await supplier.render(ctx, chat_id, active) elif active.kind == draft_store.KIND_MATERIAL: await material.render(ctx, chat_id, active) + elif active.kind == draft_store.KIND_PROJECT: + await project.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 == "add_project": + await project.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": @@ -323,3 +335,63 @@ def _draft_summary(draft) -> str | None: else: summary = json.dumps(payload, ensure_ascii=False)[:500] return summary + + +async def _handle_ambiguity_user_reply( + ctx: BotContext, chat_id: int, user: LinkedUser, + draft, amb: dict, message: dict, incoming_msg_id: int | None +) -> None: + """Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b).""" + text = (message.get("text") or message.get("caption") or "").strip() + if not text and (message.get("voice") or message.get("audio")): + try: + audio_media = message.get("voice") or message.get("audio") + audio_bytes = await ctx.bale.download_file(audio_media["file_id"]) + text = await transcribe(settings, audio_bytes) + except Exception: + text = "" + + if text: + draft_summary = _draft_summary(draft) + result = await understand(settings, text, draft_summary) + patch = result.get("patch") or {} + + # Apply user correction specifically to the target field + if amb.get("kind") == "payment_schedule": + if patch.get("payment_terms"): + draft_store.merge_quote_patch(draft.payload, patch) + else: + draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {}) + if not draft.payload.get("notes"): + draft.payload["notes"] = f"نحوه پرداخت: {text}" + else: + draft.payload["notes"] += f"\nنحوه پرداخت: {text}" + elif amb.get("kind") == "project_assignment": + if patch.get("items") or patch.get("project_name") or patch.get("project_names"): + draft_store.merge_quote_patch(draft.payload, patch) + else: + draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {}) + else: + draft_store.merge_quote_patch(draft.payload, patch) + + amb["status"] = "applied" + await quote.resolve_payload(ctx, draft.payload) + await draft_store.save_payload(draft, mark_bale_synced=True) + + # In-place edit main review card (Requirement 2.b) + await quote.render(ctx, chat_id, draft, fresh=False) + + # Delete the ambiguity message (Requirement 2.b) + if amb.get("message_id"): + try: + await ctx.delete(chat_id, amb["message_id"]) + except Exception: + pass + + # Delete user's message to keep chat clean (Requirement 2.b) + if incoming_msg_id: + try: + await ctx.delete(chat_id, incoming_msg_id) + except Exception: + pass + diff --git a/bot/app/handlers/callbacks.py b/bot/app/handlers/callbacks.py index a1620d0..03d3521 100644 --- a/bot/app/handlers/callbacks.py +++ b/bot/app/handlers/callbacks.py @@ -9,7 +9,7 @@ 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 +from . import quote, supplier, material, project, decisions logger = logging.getLogger("bot.callbacks") @@ -57,6 +57,45 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None: await decisions.handle_arrive_callback(ctx, chat_id, user, parts) return + # ---- ambiguity sub-messages (Requirement 2.a) ------------------------- + if parts[0] == "amb" and len(parts) >= 4: + action, prefix, amb_id = parts[1], parts[2], parts[3] + draft = await draft_store.get_by_prefix(prefix, chat_id) + if draft is None: + await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") + return + + payload = draft.payload or {} + ambiguities = payload.get("ambiguities") or [] + target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None) + if not target_amb: + return + + if action == "ok": + proposed = target_amb.get("proposed_patch") or {} + draft_store.merge_quote_patch(payload, proposed) + target_amb["status"] = "applied" + await quote.resolve_payload(ctx, payload) + await draft_store.save_payload(draft, mark_bale_synced=True) + # In-place edit main review card + await quote.render(ctx, chat_id, draft, fresh=False) + # Delete ONLY this ambiguity message + if target_amb.get("message_id"): + try: + await ctx.delete(chat_id, target_amb["message_id"]) + except Exception: + pass + elif action == "no": + target_amb["status"] = "rejected" + await draft_store.save_payload(draft, mark_bale_synced=True) + # Delete ONLY this ambiguity message + if target_amb.get("message_id"): + try: + await ctx.delete(chat_id, target_amb["message_id"]) + except Exception: + pass + 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) @@ -68,8 +107,13 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None: # ---- 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] + if op in ("q", "s", "m", "p") and arg in ("ok", "ok2", "no"): + kind = { + "q": draft_store.KIND_QUOTE, + "s": draft_store.KIND_SUPPLIER, + "m": draft_store.KIND_MATERIAL, + "p": draft_store.KIND_PROJECT, + }[op] draft = await draft_store.get_by_prefix(prefix, chat_id) if draft is None or draft.kind != kind: await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") @@ -89,8 +133,10 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None: 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: + elif kind == draft_store.KIND_MATERIAL: await material.commit(ctx, chat_id, user, draft) + elif kind == draft_store.KIND_PROJECT: + await project.commit(ctx, chat_id, user, draft) async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None: diff --git a/bot/app/handlers/project.py b/bot/app/handlers/project.py new file mode 100644 index 0000000..e6e147b --- /dev/null +++ b/bot/app/handlers/project.py @@ -0,0 +1,67 @@ +"""Add-project flow: name + optional description.""" +import logging + +from app import drafts as draft_store +from app.api.client import ApiError +from app.drafts import ( + KIND_PROJECT, merge_project_patch, new_project_payload, project_missing, +) +from app.keyboards import simple_review_keyboard +from app.texts import ERR_GENERIC, esc, project_committed_report, project_review_text + +logger = logging.getLogger("bot.project") + + +async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None: + draft = await draft_store.get_active(chat_id, KIND_PROJECT) + if draft is not None: + payload = draft.payload + merge_project_patch(payload, patch) + else: + payload = new_project_payload() + merge_project_patch(payload, patch) + draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source) + await draft_store.save_payload(draft, missing={"fields": project_missing(payload)}) + await render(ctx, chat_id, draft, fresh=draft.review_message_id is None) + + +def _keyboard(payload: dict, draft_id: str) -> dict: + return simple_review_keyboard(draft_id, KIND_PROJECT, []) + + +async def render(ctx, chat_id, draft, fresh: bool = True) -> None: + payload = draft.payload + missing = project_missing(payload) + text = project_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 + missing = {m["key"]: m["ok"] for m in project_missing(payload)} + if not missing.get("name"): + await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.") + return + body = {"name": payload["name"]} + if payload.get("description"): + body["description"] = payload["description"] + try: + created = await ctx.api.create_project(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_project failed") + await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور")) + return + await draft_store.set_status(draft.id, "committed") + ctx.resolver.invalidate("projects") + await ctx.send(chat_id, project_committed_report(created)) diff --git a/bot/app/handlers/quote.py b/bot/app/handlers/quote.py index 378cbdf..d5e3b2c 100644 --- a/bot/app/handlers/quote.py +++ b/bot/app/handlers/quote.py @@ -26,6 +26,17 @@ async def handle_entities( draft = target_draft prev_review_msg_id = draft.review_message_id draft.review_message_id = None + + # Clean up old ambiguity sub-messages (Requirement 2.c) + old_ambs = (draft.payload or {}).get("ambiguities") or [] + for a in old_ambs: + if a.get("message_id"): + try: + await ctx.delete(chat_id, a["message_id"]) + except Exception: + pass + draft.payload["ambiguities"] = [] + payload = draft.payload merge_quote_patch(payload, patch) draft.source_message_id = incoming_msg_id @@ -70,11 +81,16 @@ async def resolve_payload(ctx, payload: dict) -> None: else: options.pop("sup", None) + resolved_projects = payload.setdefault("resolved_projects", {}) + 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 + c = res.candidates[0] + payload["project_id"] = c.id + payload["project_name"] = c.name + resolved_projects[c.name] = c.id + resolved_projects[payload["project_name"]] = c.id if payload["project_id"] not in payload.get("project_ids", []): payload.setdefault("project_ids", []).append(payload["project_id"]) options.pop("prj", None) @@ -86,7 +102,10 @@ async def resolve_payload(ctx, payload: dict) -> 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 + c = res.candidates[0] + p_id = c.id + resolved_projects[c.name] = p_id + resolved_projects[p_name] = p_id if p_id not in payload.get("project_ids", []): payload.setdefault("project_ids", []).append(p_id) if not payload.get("project_id"): @@ -231,6 +250,9 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non source_message_id=reply_to or draft.source_message_id, mark_bale_synced=True, ) + + # Send pending ambiguity sub-messages as replies to the main review card + await send_pending_ambiguities(ctx, chat_id, draft) else: if not draft.review_message_id: return @@ -242,6 +264,36 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non await draft_store.set_bale_updated_at(draft.id) +async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None: + from app.keyboards import ambiguity_keyboard + from app.texts import ambiguity_message_text + + if not draft.review_message_id: + return + + prefix = str(draft.id)[:8] + payload = draft.payload or {} + ambiguities = payload.get("ambiguities") or [] + updated = False + + for amb in ambiguities: + if amb.get("status") == "pending" and not amb.get("message_id"): + msg_text = ambiguity_message_text(amb.get("title", ""), amb.get("details", "")) + kb = ambiguity_keyboard(prefix, amb.get("id", "")) + sent = await ctx.send( + chat_id, + msg_text, + reply_markup=kb, + reply_to=draft.review_message_id, + ) + if sent and sent.get("message_id"): + amb["message_id"] = sent["message_id"] + updated = True + + if updated: + await draft_store.save_payload(draft, mark_bale_synced=True) + + # --------------------------------------------------------------------------- # commit # --------------------------------------------------------------------------- @@ -250,6 +302,8 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None payload = draft.payload await resolve_payload(ctx, payload) + newly_created: dict = {} + # 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") @@ -267,11 +321,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None 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"] + newly_created["supplier"] = payload["supplier_name"] ctx.resolver.invalidate("suppliers") except Exception as e: logger.warning("Could not auto-create supplier: %s", e) # 2. Sequential pre-creation for new materials + created_mats = [] 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: @@ -280,9 +336,60 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None "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"]) + created_mats.append(item["material_name"]) ctx.resolver.invalidate("materials") except Exception as e: logger.warning("Could not auto-create material: %s", e) + if created_mats: + newly_created["materials"] = created_mats + + # 3. Sequential pre-creation for new projects + created_prjs = [] + all_p_names = [] + if payload.get("project_names"): + for p in payload["project_names"]: + p_str = str(p).strip() + if p_str and p_str not in all_p_names: + all_p_names.append(p_str) + elif payload.get("project_name"): + p_str = str(payload["project_name"]).strip() + if p_str and p_str not in all_p_names: + all_p_names.append(p_str) + + for it in payload.get("items") or []: + it_p = it.get("project_name") + if it_p: + it_p_str = str(it_p).strip() + if it_p_str and it_p_str not in all_p_names: + all_p_names.append(it_p_str) + + resolved_map = payload.setdefault("resolved_projects", {}) + for p_name in all_p_names: + p_id = resolved_map.get(p_name) + if not p_id: + res = await safe_resolve(ctx.resolver, "project", p_name) + if res.resolved: + p_id = res.candidates[0].id + resolved_map[p_name] = p_id + else: + try: + created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id) + p_id = created_prj.get("id") + if p_id: + resolved_map[p_name] = p_id + created_prjs.append(p_name) + ctx.resolver.invalidate("projects") + except Exception as e: + logger.warning("Could not auto-create project %s: %s", p_name, e) + + if p_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 + + if created_prjs: + newly_created["projects"] = created_prjs ready, blockers = quote_ready(payload) body, problems = build_quote_request(payload, only_resolved_items=only_resolved) @@ -346,4 +453,4 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None 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)) + await ctx.send(chat_id, quote_committed_report(created, payload, remaining, newly_created=newly_created)) diff --git a/bot/app/keyboards.py b/bot/app/keyboards.py index eaad568..3182668 100644 --- a/bot/app/keyboards.py +++ b/bot/app/keyboards.py @@ -22,7 +22,7 @@ def _btn(text: str, data: str) -> dict: # qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive) def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]: - op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q") + op = {"quote": "q", "supplier": "s", "material": "m", "project": "p"}.get(kind, "q") rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]] if has_partial_option: rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")]) @@ -72,3 +72,12 @@ def arrive_keyboard() -> dict: def cancel_keyboard() -> dict: return _kb([[_btn("❌ انصراف", "ac:no")]]) + + +def ambiguity_keyboard(prefix: str, amb_id: str) -> dict: + """Inline buttons for ambiguity sub-message (≤64 bytes callback_data).""" + return _kb([ + [_btn("✅ تأیید و اعمال در پیش‌نویس", f"amb:ok:{prefix}:{amb_id}")], + [_btn("❌ تایید نمی‌شود", f"amb:no:{prefix}:{amb_id}")], + ]) + diff --git a/bot/app/nlu.py b/bot/app/nlu.py index b5d1646..774edf4 100644 --- a/bot/app/nlu.py +++ b/bot/app/nlu.py @@ -40,6 +40,7 @@ INTENTS = [ "show_draft", # «وضعیت پیش‌نویس؟» "add_supplier", "add_material", + "add_project", "status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ... "accept_quote", # پذیرش یک استعلام "arrive_items", # اعلام رسیدن اقلام @@ -70,13 +71,24 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی - اگر سؤال درباره وضعیت است → intent="status_query". - اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام. - اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمین‌کننده/استعلام و در patch.arrivals مقادیر. -- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد. -- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده. +- برای پروژه‌ها: + * اگر کاربر یک یا چند پروژه را ذکر کرد (مانند «برای منشانه و دفتر» یا «پروژه آسمان و دفتر مرکزی»)، همه نام‌های پروژه‌ها را دقیقاً و بدون جا انداختن یا اشتباه گرفتن با برند در آرایه project_names قرار بده: ["منشانه", "دفتر"]. + * این پروژه‌ها ممکن است پروژه‌های جدید باشند؛ هرگز آن‌ها را با نام‌های دیگر جایگزین نکن یا نادیده نگیر. + * نام پروژه‌ها را هرگز به عنوان برند کالا در نظر نگیر. + * اگر هر کالا به پروژه خاصی تعلق داشت در item.project_name بنویس، وگرنه آن را خالی بگذار تا سیستم تفکیک کند. +- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد»، «سیمان» یا «گچ و خاک»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز، وزن بسته مانند ۵۰ کیلویی یا گرید) در آرایه specs قرار گیرد. +- برای مهلت اعتبار یا پیش‌فاکتور: اگر کاربر گفت مثلاً «پیش‌فاکتور تا ۲۰ روز دیگر آماده/معتبر است»، تعداد روز را در expiry_days: 20 بگذار. +- برای شرایط پرداخت: ساختار مفهومی را در payment_terms استخراج کن و نیازی به محاسبه تقویمی تاریخ‌ها نداری: + * label: متن عنوان شرایط (مثلاً «سه قسط در طول زمان» یا «پرداخت یکباره امروز»). + * type: یکی از "installments" (اقساطی)، "cash_today" (نقدی امروز)، "deferred_check" (چک)، "other". + * installments_count: تعداد اقساط اگر ذکر شد (مثلاً ۳). + * is_today: آیا تسویه یکباره امروز یا قسط اول امروز است (true/false). + * discount_percent: درصد تخفیف نقدی اگر ذکر شد (مثلاً ۱۰). شکل patch برای intent="register_quote"/"update_draft": -{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD", - "items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}], - "payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]} +{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD", + "items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "project_name": str, "notes": str}], + "payment_terms": [{"label": str, "type": "installments|cash_today|deferred_check|other", "installments_count": int, "is_today": bool, "discount_percent": number, "discount_type": "none|percent|fixed", "discount_value": number, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number, "note": str}], "notes": str}]} شکل patch برای intent="add_supplier": {"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}} @@ -84,6 +96,9 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی شکل patch برای intent="add_material": {"material": {"name": str, "unit_name": str, "topic": str}} +شکل patch برای intent="add_project": +{"project": {"name": str, "description": str}} + شکل patch برای intent="status_query": {"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}} diff --git a/bot/app/resolve.py b/bot/app/resolve.py index de19bbd..1edadde 100644 --- a/bot/app/resolve.py +++ b/bot/app/resolve.py @@ -37,14 +37,18 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate] scored = [] for item in items: name = normalize_text(item.name) - ratio = difflib.SequenceMatcher(None, q, name).ratio() if q == name: ratio = 1.0 - elif q in name: - ratio += 0.35 - scored.append((ratio, item)) + elif q in name and len(q) >= 3: + ratio = 0.85 + 0.15 * (len(q) / len(name)) + elif name in q and len(name) >= 3: + ratio = 0.85 + 0.15 * (len(name) / len(q)) + else: + ratio = difflib.SequenceMatcher(None, q, name).ratio() + if ratio >= 0.72: + scored.append((ratio, item)) scored.sort(key=lambda pair: (-pair[0], pair[1].name)) - return [item for ratio, item in scored[:limit] if ratio >= 0.40] + return [item for ratio, item in scored[:limit]] async def _db_units() -> list[Candidate]: @@ -259,6 +263,8 @@ class Resolver: top = candidates[0] top_norm = normalize_text(top.name) ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio() + if (norm_name in top_norm or top_norm in norm_name) and len(norm_name) >= 3: + ratio = max(ratio, 0.90) if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95): return Resolution("one", [top]) return Resolution("many", candidates) diff --git a/bot/app/texts.py b/bot/app/texts.py index c8aa7f8..d02168f 100644 --- a/bot/app/texts.py +++ b/bot/app/texts.py @@ -210,6 +210,20 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non if cat_name: lines.append(f"\n🏷️ موضوع: {cat_name}") + # 🏗️ پروژه(ها) + p_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else []) + resolved_projects = payload.get("resolved_projects") or {} + if p_names: + if len(p_names) == 1: + p = p_names[0] + badge = " ✅" if (p in resolved_projects or payload.get("project_id")) else " ✨ (پروژه جدید)" + lines.append(f"\n🏗️ پروژه: {p}{badge}") + else: + lines.append(f"\n🏗️ پروژه‌ها ({fa_num(len(p_names))} مورد):") + for p in p_names: + badge = " ✅" if p in resolved_projects else " ✨ (پروژه جدید)" + lines.append(f"▫️ {p}{badge}") + # 📦 اقلام items = payload.get("items") or [] total_amount = 0.0 @@ -232,7 +246,10 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non # Project item_proj = it.get("project_name") or default_proj if item_proj: - lines.append(f" 🏗️ {item_proj}") + p_badge = "" + if item_proj not in resolved_projects and not (payload.get("project_id") and len(p_names) <= 1): + p_badge = " ✨ (پروژه جدید)" + lines.append(f" 🏗️ {item_proj}{p_badge}") else: lines.append(" 🏗️ پروژه مشخص نشده") @@ -368,7 +385,7 @@ def material_review_text(payload: dict, missing: list[dict]) -> str: return "\n".join(lines) -def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str: +def quote_committed_report(created: dict, payload: dict, remaining: list[str], newly_created: dict | None = None) -> str: lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"] sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name") if sup: @@ -379,8 +396,21 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) - sheets = created.get("termsheets") or [] if sheets: lines.append(f"شرایط پرداخت: {len(sheets)}") - if payload.get("project_id"): + if payload.get("project_id") or payload.get("project_ids"): lines.append("پروژه: پیوست شد") + + if newly_created: + new_lines = [] + if newly_created.get("supplier"): + new_lines.append(f"🏢 تأمین‌کننده جدید ثبت شد: {esc(newly_created['supplier'])}") + if newly_created.get("projects"): + new_lines.append(f"🏗️ پروژه(های) جدید ثبت شد: {esc('، '.join(newly_created['projects']))}") + if newly_created.get("materials"): + new_lines.append(f"📦 کالای جدید ثبت شد: {esc('، '.join(newly_created['materials']))}") + if new_lines: + lines.append("\n✨ موارد جدید ایجادشده:") + lines += new_lines + if remaining: lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):") lines += [f"⚠️ {esc(r)}" for r in remaining] @@ -388,6 +418,19 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) - return "\n".join(lines) +def project_review_text(payload: dict, missing: list[dict]) -> str: + lines = ["🏗️ پیش‌نویس پروژه جدید", f"نام: {esc(payload.get('name'))}"] + if payload.get("description"): + lines.append(f"توضیحات: {esc(payload['description'])}") + lines.append("\nوضعیت فیلدها:") + lines.append(missing_block(missing)) + return "\n".join(lines) + + +def project_committed_report(created: dict) -> str: + return f"✅ پروژه ثبت شد — {esc(created.get('name'))}" + + def supplier_committed_report(created: dict) -> str: return ( f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n" @@ -430,3 +473,12 @@ def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) lines.append(extra_note) lines += [f"• {quote_line(q)}" for q in quotes[:10]] return "\n".join(lines) + + +def ambiguity_message_text(title: str, details: str) -> str: + return ( + f"🤔 آیا نظر شما این بود؟\n" + f"موضوع: {title}\n\n" + f"{details}" + ) + diff --git a/bot/tools/smoke_test.py b/bot/tools/smoke_test.py index 941c5e6..a5dd5ff 100644 --- a/bot/tools/smoke_test.py +++ b/bot/tools/smoke_test.py @@ -59,6 +59,9 @@ class FakeBale: "reply_markup": reply_markup}) return {"message_id": message_id} + async def delete_message(self, chat_id, message_id): + return True + async def answer_callback_query(self, callback_query_id, text=None): self.answered.append(callback_query_id) @@ -82,6 +85,7 @@ class FakeApi: self.created_quotes: list[dict] = [] self.created_suppliers: list[dict] = [] self.created_materials: list[dict] = [] + self.created_projects: list[dict] = [] self.accept_payloads: list[dict] = [] self.arrive_payloads: list[dict] = [] self.login_calls: list[dict] = [] @@ -119,6 +123,10 @@ class FakeApi: async def list_projects(self, page_size=100): return [{"id": "prj-1", "name": "برج آسمان"}] + async def create_project(self, payload, actor_id=None): + self.created_projects.append(payload) + return {"id": f"prj-{len(self.created_projects)}", "name": payload["name"]} + async def list_categories(self, page_size=100): return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"}, {"id": "cat-2", "name": "لوله و اتصالات"}] @@ -180,10 +188,11 @@ def _install_fake_stores() -> None: import app.drafts as drafts import app.runtime as runtime - async def create(chat_id, kind, payload, source=None): + async def create(chat_id, kind, payload, source=None, source_message_id=None): draft_id = _uuid.uuid4() draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload, missing=None, status="active", source=source, + source_message_id=source_message_id, review_message_id=None) MEM["drafts"][str(draft_id)] = draft return draft @@ -193,16 +202,33 @@ def _install_fake_stores() -> None: if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)] return max(found, key=lambda d: d.id) if found else None + async def get_by_message_id(chat_id, message_id): + for draft in MEM["drafts"].values(): + if draft.chat_id == chat_id and draft.status == "active": + if draft.review_message_id == message_id or getattr(draft, "source_message_id", None) == message_id: + return draft + return None + + async def get_by_ambiguity_message_id(chat_id, message_id): + for draft in MEM["drafts"].values(): + if draft.chat_id == chat_id and draft.status == "active": + for amb in (draft.payload or {}).get("ambiguities") or []: + if amb.get("message_id") == message_id: + return draft, amb + return None, None + async def get_by_prefix(prefix, chat_id=None): for draft in MEM["drafts"].values(): if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id): return draft return None - async def save_payload(draft, missing=None, review_message_id=None): + async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False): draft.missing = missing if review_message_id: draft.review_message_id = review_message_id + if source_message_id: + draft.source_message_id = source_message_id async def set_status(draft_id, status): if draft_id in MEM["drafts"]: @@ -212,6 +238,7 @@ def _install_fake_stores() -> None: return 0 drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix + drafts.get_by_message_id, drafts.get_by_ambiguity_message_id = get_by_message_id, get_by_ambiguity_message_id drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale async def link_get(self, chat_id): @@ -245,6 +272,10 @@ async def fake_transcribe(settings, audio, mime_type=None, filename=None): "پرداخت سه فاز") +async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None): + return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}] + + # --------------------------------------------------------------------------- # driver # --------------------------------------------------------------------------- @@ -262,11 +293,15 @@ def build_ctx(): ctx._log = types.MethodType(fake_log, ctx) H.understand = fake_understand H.transcribe = fake_transcribe + H.transcribe_multi = fake_transcribe_multi return ctx, bale, api -def text_update(update_id, text): - return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}} +def text_update(update_id, text, reply_to=None): + msg = {"message_id": update_id, "chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}} + if reply_to: + msg["reply_to_message"] = {"message_id": reply_to} + return {"update_id": update_id, "message": msg} def voice_update(update_id): @@ -291,16 +326,18 @@ async def main() -> int: payload = new_quote_payload() merge_quote_patch(payload, { - "supplier_name": "سازان نو", "items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}], "payment_terms": [{"label": "۳ فاز"}], }) - merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]}) + missing = compute_quote_missing(payload) + check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing)) + merge_quote_patch(payload, { + "supplier_name": "سازان نو", + "items": [{"material_name": "میلگرد A3", "quantity": 600}], + }) check("merge: no dup item", len(payload["items"]) == 1) check("merge: qty updated", payload["items"][0]["quantity"] == 600) check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز") - missing = compute_quote_missing(payload) - check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing)) body, problems = build_quote_request(payload) check("build: blocked without supplier", body is None) @@ -343,7 +380,7 @@ async def main() -> int: # refine price via follow-up text NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": { "items": [{"material_name": "میلگرد", "unit_price": 47000}]}}) - await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد")) + await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id)) draft = await _active("quote") check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000) @@ -417,14 +454,48 @@ async def main() -> int: check("material: posted", api.created_materials and api.created_materials[0]["canonical_unit_id"] == 3) + # ---- new projects in quote + auto-creation on commit -------------------- + NLU_QUEUE.append({ + "intent": "register_quote", + "confidence": 0.95, + "patch": { + "supplier_name": "سازان نو", + "project_names": ["منشانه", "دفتر"], + "items": [ + {"material_name": "میلگرد A3 سایز ۱۴", "quantity": 100, "unit_price": 50000, "project_name": "منشانه"}, + {"material_name": "شیر فلکه چهارطرفه", "quantity": 20, "unit_price": 100000, "project_name": "دفتر"}, + ], + }, + }) + await H.handle_update(ctx, text_update(18, "استعلام برای منشانه و دفتر")) + q_draft = await _active("quote") + check("multi-project: draft created", q_draft is not None) + check("multi-project: no bogus match to برج آسمان", + "برج آسمان" not in bale.last_text() and "prj" not in (q_draft.payload.get("options") or {})) + check("multi-project: new project badge shown", "✨ (پروژه جدید)" in bale.last_text()) + await H.handle_update(ctx, callback_update(19, f"q:ok:{q_draft.id[:8]}")) + created_prj_names = [p["name"] for p in api.created_projects] + check("multi-project: auto-created in ERP", "منشانه" in created_prj_names and "دفتر" in created_prj_names) + check("multi-project: commit report mentions projects", "پروژه(های) جدید ثبت شد" in bale.last_text()) + + # ---- add project standalone -------------------------------------------- + NLU_QUEUE.append({"intent": "add_project", "confidence": 0.9, + "patch": {"project": {"name": "پروژه امید", "description": "پروژه فاز سوم"}}}) + await H.handle_update(ctx, text_update(20, "پروژه جدید امید")) + p_draft = await _active("project") + check("project: draft created", p_draft is not None) + await H.handle_update(ctx, callback_update(21, f"p:ok:{p_draft.id[:8]}")) + check("project: posted to ERP", any(p["name"] == "پروژه امید" for p in api.created_projects)) + check("project: report sent", "پروژه ثبت شد" in bale.last_text()) + # ---- cancel / unknown -------------------------------------------------- NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}}) - await H.handle_update(ctx, text_update(18, "استعلام از سازان نو")) - await H.handle_update(ctx, text_update(19, "/cancel")) + await H.handle_update(ctx, text_update(22, "استعلام از سازان نو")) + await H.handle_update(ctx, text_update(23, "/cancel")) check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text()) NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}}) - await H.handle_update(ctx, text_update(20, "اسپاگتی بپز")) + await H.handle_update(ctx, text_update(24, "اسپاگتی بپز")) check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text()) print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")