feat(bot): centralize defaults, refine ambiguity lifecycle, improve entity resolution and auto-create new projects
This commit is contained in:
+170
-24
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user