"""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 import defaults 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" KIND_PROJECT = "project" # --------------------------------------------------------------------------- # 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_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: 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": defaults.DEFAULT_IS_OFFICIAL, "notes": None, "expiry_date": None, "items": [], "termsheets": [], "options": {}, "proforma_file": None, "ambiguities": [], } 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", 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_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 []: 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 raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]): continue 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", "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() # 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 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) 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) 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 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]