feat(bot): centralize defaults, refine ambiguity lifecycle, improve entity resolution and auto-create new projects

This commit is contained in:
2026-10-08 22:33:15 +03:30
parent 638076dbaa
commit 77c0cacbb6
12 changed files with 905 additions and 59 deletions
+170 -24
View File
@@ -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]