Files
ERP/ai procurement/app/drafts.py
T

444 lines
18 KiB
Python

"""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) -> BotDraft:
async with SessionLocal() as session:
# one active draft per (chat, kind)
await session.execute(
update(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.kind == kind, BotDraft.status == "active")
.values(status="superseded")
)
draft = BotDraft(chat_id=chat_id, kind=kind, payload=payload, source=source)
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_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) -> None:
async with SessionLocal() as session:
await session.execute(
update(BotDraft)
.where(BotDraft.id == draft.id)
.values(payload=draft.payload, missing=missing, review_message_id=review_message_id or draft.review_message_id)
)
await session.commit()
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 — the user's explicit ask («یادآوری چیزهای خالی»)."""
out: list[dict] = []
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
sup_ok = bool(payload.get("supplier_id"))
add("supplier", "تأمین‌کننده", sup_ok, True)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
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}"
if not item.get("material_id"):
add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True)
if not item.get("quantity"):
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
if not (item.get("unit_id") or 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]