"""Draft store + payload/missing-field logic. A draft's ``payload`` is a talking-friendly JSON the bot keeps refining. When the user approves, ``build_quote_payload`` converts it into the exact ``POST /quotes`` request body (temp_ids cross-referencing line items, termsheets and prices). Resolution state lives inside the payload: payload["supplier_id"], payload["project_id"], item["material_id"], item["unit_id"], item["unit_explicit"], payload["options"][field] = candidate list shown as pick-buttons. """ import difflib import uuid from datetime import datetime, timedelta, timezone from sqlalchemy import cast, func, select, String, update from app.db import SessionLocal from app.models import BotDraft from app.nlu import normalize_text KIND_QUOTE = "quote" KIND_SUPPLIER = "supplier" KIND_MATERIAL = "material" # --------------------------------------------------------------------------- # Store # --------------------------------------------------------------------------- async def create(chat_id: int, kind: str, payload: dict, source: str | None = None, source_message_id: int | None = None) -> BotDraft: async with SessionLocal() as session: draft = BotDraft( chat_id=chat_id, kind=kind, payload=payload, source=source, source_message_id=source_message_id, ) session.add(draft) await session.commit() await session.refresh(draft) return draft async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None: async with SessionLocal() as session: stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active") if kind: stmt = stmt.where(BotDraft.kind == kind) stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1) return (await session.execute(stmt)).scalar_one_or_none() async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None: """Find the active draft associated with a given message_id (review card or user source message).""" async with SessionLocal() as session: stmt = ( select(BotDraft) .where( BotDraft.chat_id == chat_id, BotDraft.status == "active", (BotDraft.review_message_id == message_id) | (BotDraft.source_message_id == message_id), ) .order_by(BotDraft.updated_at.desc()) .limit(1) ) return (await session.execute(stmt)).scalar_one_or_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: stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%")) if chat_id is not None: stmt = stmt.where(BotDraft.chat_id == chat_id) return (await session.execute(stmt)).scalar_one_or_none() async def save_payload(draft: BotDraft, missing: dict | None = None, review_message_id: int | None = None, source_message_id: int | None = None, mark_bale_synced: bool = False) -> None: async with SessionLocal() as session: effective_review_msg_id = review_message_id if review_message_id is not None else draft.review_message_id values: dict = { "payload": draft.payload, "missing": missing, "review_message_id": effective_review_msg_id, } if source_message_id is not None: values["source_message_id"] = source_message_id draft.source_message_id = source_message_id if mark_bale_synced: values["last_bale_update_at"] = func.now() await session.execute( update(BotDraft) .where(BotDraft.id == draft.id) .values(**values) ) await session.commit() draft.review_message_id = effective_review_msg_id async def set_status(draft_id: str, status: str) -> None: async with SessionLocal() as session: await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status)) await session.commit() async def set_bale_updated_at(draft_id) -> None: async with SessionLocal() as session: await session.execute( update(BotDraft) .where(BotDraft.id == draft_id) .values(last_bale_update_at=func.now()) ) await session.commit() async def get_pending_bale_updates() -> list[BotDraft]: async with SessionLocal() as session: stmt = ( select(BotDraft) .where( BotDraft.review_message_id.is_not(None), (BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at), ) .order_by(BotDraft.updated_at.asc()) .limit(10) ) return list((await session.execute(stmt)).scalars().all()) async def expire_stale(ttl_hours: int) -> int: cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours) async with SessionLocal() as session: result = await session.execute( update(BotDraft) .where(BotDraft.status == "active", BotDraft.updated_at < cutoff) .values(status="expired") ) await session.commit() return result.rowcount or 0 # --------------------------------------------------------------------------- # Quote payload # --------------------------------------------------------------------------- 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, "items": [], "termsheets": [], "options": {}, "proforma_file": None, } def new_item(material_name: str | None = None) -> dict: return { "material_name": material_name, "material_id": None, "quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False, "unit_price": None, "brand_name": None, "specs": [], "notes": None, } def _similarity(a: str | None, b: str | None) -> float: na, nb = normalize_text(a or ""), normalize_text(b or "") if not na or not nb: return 0.0 ratio = difflib.SequenceMatcher(None, na, nb).ratio() if na in nb or nb in na: ratio += 0.3 return ratio def merge_quote_patch(payload: dict, patch: dict) -> bool: """Merge an NLU patch into the payload. Returns True if anything changed.""" changed = False def set_field(key: str, value) -> None: nonlocal changed if value is None: return if payload.get(key) != value: payload[key] = value changed = True if patch.get("supplier_name"): payload["supplier_name"] = str(patch["supplier_name"]).strip() payload["supplier_id"] = None # needs re-resolution changed = True if patch.get("project_names"): payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p] if payload["project_names"] and not payload.get("project_name"): payload["project_name"] = payload["project_names"][0] payload["project_id"] = None changed = True elif patch.get("project_name"): payload["project_name"] = str(patch["project_name"]).strip() payload["project_id"] = None if not payload.get("project_names"): payload["project_names"] = [payload["project_name"]] changed = True set_field("is_official", patch.get("is_official")) if patch.get("notes"): payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip() changed = True if patch.get("expiry_date"): set_field("expiry_date", patch["expiry_date"]) for raw in patch.get("items") or []: name = (raw.get("material_name") or "").strip() or None target = None 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")) if ratio > best_ratio: best, best_ratio = item, ratio if best is not None and best_ratio >= 0.55: target = best if target is None: target = new_item(name) payload["items"].append(target) changed = True for key in ("quantity", "unit_price"): if raw.get(key) is not None: try: value = float(raw[key]) except (TypeError, ValueError): continue if target.get(key) != value: target[key] = value changed = True if raw.get("specs"): target["specs"] = [str(s).strip() for s in raw["specs"] if s] spec_str = "، ".join(target["specs"]) if spec_str and not target.get("notes"): target["notes"] = spec_str changed = True for key in ("unit_name", "brand_name", "notes"): if raw.get(key): val = str(raw[key]).strip() if target.get(key) != val: target[key] = val changed = True for raw in patch.get("payment_terms") 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) changed = True return changed def compute_quote_missing(payload: dict) -> list[dict]: """What is still empty — strictly aligned with DB schema requirements.""" out: list[dict] = [] def add(key: str, label: str, ok: bool, blocking: bool) -> None: out.append({"key": key, "label": label, "ok": ok, "blocking": blocking}) # In DB: supplier_id is NOT NULL in quotes table. sup_ok = bool(payload.get("supplier_id") or payload.get("supplier_name")) 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) # In DB: expiry_date is nullable (optional) add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False) items = payload.get("items") or [] add("items", "قلم (کالا/مقدار)", bool(items), True) for i, item in enumerate(items, start=1): name = item.get("material_name") or f"قلم {i}" has_mat = bool(item.get("material_id") or item.get("material_name")) if not has_mat: add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True) if not item.get("quantity"): add(f"item:{i}:qty", f"مقدار «{name}»", False, True) if not item.get("unit_id") and not item.get("unit_name"): add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True) if not item.get("unit_price"): add(f"item:{i}:price", f"قیمت واحد برای «{name}»", False, False) if items and not payload.get("termsheets"): add("terms", "شرایط پرداخت", False, False) if not payload.get("notes"): add("notes", "توضیحات", False, False) return out def quote_ready(payload: dict) -> tuple[bool, list[str]]: """Blocking check before commit; returns (ok, blocking_labels).""" missing = compute_quote_missing(payload) return all(not (m["blocking"] and not m["ok"]) for m in missing), [ m["label"] for m in missing if m["blocking"] and not m["ok"] ] def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]: """Convert the payload into a QuoteCreate body. Returns (body, problems).""" problems: list[str] = [] if not payload.get("supplier_id"): return None, ["تأمین‌کننده مشخص نیست"] items = payload.get("items") or [] line_items: list[dict] = [] for i, item in enumerate(items, start=1): if not item.get("material_id"): if only_resolved_items: continue problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد") continue if not item.get("quantity"): problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست") continue unit_id = item.get("unit_id") if not unit_id: problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست") continue notes_parts = [] if item.get("brand_name"): notes_parts.append(f"برند: {item['brand_name']}") if item.get("specs"): notes_parts.append("، ".join(item["specs"])) if item.get("notes"): if item["notes"] not in notes_parts: notes_parts.append(item["notes"]) notes_str = " | ".join(notes_parts) if notes_parts else None line_items.append({ "temp_id": f"t{i}", "material_id": item["material_id"], "unit_id": int(unit_id), "quantity": item["quantity"], "notes": notes_str, }) termsheets: list[dict] = [] item_termsheets: list[dict] = [] priced = [it for it in items if it.get("unit_price") and it.get("material_id")] if priced: raw_sheets = payload.get("termsheets") or [] if not raw_sheets: raw_sheets = [{"label": "پیشنهاد قیمت"}] for j, ts in enumerate(raw_sheets, start=1): steps = [] for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1): steps.append({ "step_no": s_idx, "due_date": m.get("due_date"), "amount": float(m.get("amount") or 0), "note": m.get("note"), }) termsheets.append({ "temp_id": f"p{j}", "label": ts.get("label") or f"شرایط {j}", "discount_type": ts.get("discount_type") or "none", "discount_value": float(ts.get("discount_value") or 0), "schedule_steps": steps, }) first = termsheets[0]["temp_id"] for i, item in enumerate(items, start=1): if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"): item_termsheets.append({ "line_item_temp_id": f"t{i}", "termsheet_temp_id": first, "unit_price": item["unit_price"], }) body: dict = { "supplier_id": payload["supplier_id"], "is_official": bool(payload.get("is_official", True)), "line_items": line_items, "termsheets": termsheets, "item_termsheets": item_termsheets, } if payload.get("project_ids"): body["project_ids"] = payload["project_ids"] elif payload.get("project_id"): body["project_ids"] = [payload["project_id"]] if payload.get("expiry_date"): body["expiry_date"] = payload["expiry_date"] if payload.get("notes"): body["notes"] = payload["notes"] if not line_items and problems: return None, problems return body, problems # --------------------------------------------------------------------------- # Supplier / material payloads # --------------------------------------------------------------------------- def new_supplier_payload() -> dict: return {"name": None, "category_id": None, "category_name": None, "phone": None, "location": None, "contact_person": None, "options": {}} def merge_supplier_patch(payload: dict, patch: dict) -> bool: src = patch.get("supplier") or patch changed = False for key in ("name", "phone", "location", "contact_person"): if src.get(key) and payload.get(key) != str(src[key]).strip(): payload[key] = str(src[key]).strip() changed = True if src.get("category_name"): payload["category_name"] = str(src["category_name"]).strip() payload["category_id"] = None changed = True return changed def supplier_missing(payload: dict) -> list[dict]: return [ {"key": "name", "label": "نام تأمین‌کننده", "ok": bool(payload.get("name")), "blocking": True}, {"key": "category", "label": "دسته‌بندی", "ok": bool(payload.get("category_id")), "blocking": True}, {"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False}, {"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False}, ] def new_material_payload() -> dict: return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}} def merge_material_patch(payload: dict, patch: dict) -> bool: src = patch.get("material") 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("unit_name"): payload["unit_name"] = str(src["unit_name"]).strip() payload["canonical_unit_id"] = None changed = True if src.get("topic") and payload.get("topic") != str(src["topic"]).strip(): payload["topic"] = str(src["topic"]).strip() changed = True return changed def material_missing(payload: dict) -> list[dict]: return [ {"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True}, {"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True}, {"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False}, ] def short_id() -> str: return uuid.uuid4().hex[:8]