feat(bot): centralize defaults, refine ambiguity lifecycle, improve entity resolution and auto-create new projects
This commit is contained in:
@@ -94,6 +94,9 @@ class ErpClient:
|
||||
async def list_projects(self, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
|
||||
|
||||
async def create_project(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def list_categories(self, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Centralized market conventions & domain defaults for procurement bot.
|
||||
|
||||
This module isolates all procurement business heuristics and defaults (equal
|
||||
installments calculation, end-of-solar-month due dates, validity rules per
|
||||
material, project assignment logic, official vs unofficial defaults).
|
||||
|
||||
drafts.py and nlu.py consume this module directly.
|
||||
"""
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jalali Calendar math (Pure Python, zero-dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JALALI_MONTH_NAMES = [
|
||||
"", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"
|
||||
]
|
||||
|
||||
|
||||
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
|
||||
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
|
||||
gy2 = gy if gm > 2 else gy - 1
|
||||
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
|
||||
jy = -1595 + (33 * (days // 12053))
|
||||
days %= 12053
|
||||
jy += 4 * (days // 1461)
|
||||
days %= 1461
|
||||
if days > 365:
|
||||
jy += (days - 1) // 365
|
||||
days = (days - 1) % 365
|
||||
if days < 186:
|
||||
jm = 1 + (days // 31)
|
||||
jd = 1 + (days % 31)
|
||||
else:
|
||||
jm = 7 + ((days - 186) // 30)
|
||||
jd = 1 + ((days - 186) % 30)
|
||||
return jy, jm, jd
|
||||
|
||||
|
||||
def j2g(jy: int, jm: int, jd: int) -> tuple[int, int, int]:
|
||||
jy2 = jy + 1595
|
||||
days = -355668 + (365 * jy2) + ((jy2 // 33) * 8) + (((jy2 % 33) + 3) // 4) + jd
|
||||
if jm < 7:
|
||||
days += (jm - 1) * 31
|
||||
else:
|
||||
days += ((jm - 7) * 30) + 186
|
||||
gy = 400 * (days // 146097)
|
||||
days %= 146097
|
||||
if days > 36524:
|
||||
days -= 1
|
||||
gy += 100 * (days // 36524)
|
||||
days %= 36524
|
||||
if days >= 365:
|
||||
days += 1
|
||||
gy += 4 * (days // 1461)
|
||||
days %= 1461
|
||||
if days > 365:
|
||||
gy += (days - 1) // 365
|
||||
days = (days - 1) % 365
|
||||
gd = days + 1
|
||||
sal_a = [0, 31, 29 if ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
gm = 0
|
||||
while gm < 13 and gd > sal_a[gm]:
|
||||
gd -= sal_a[gm]
|
||||
gm += 1
|
||||
return gy, gm, gd
|
||||
|
||||
|
||||
def is_jalali_leap(jy: int) -> bool:
|
||||
return (((jy + 38) * 31) % 128) <= 31
|
||||
|
||||
|
||||
def jalali_month_days(jy: int, jm: int) -> int:
|
||||
if 1 <= jm <= 6:
|
||||
return 31
|
||||
elif 7 <= jm <= 11:
|
||||
return 30
|
||||
elif jm == 12:
|
||||
return 30 if is_jalali_leap(jy) else 29
|
||||
return 30
|
||||
|
||||
|
||||
def today_jalali(ref_date: date | None = None) -> tuple[int, int, int]:
|
||||
d = ref_date or date.today()
|
||||
return g2j(d.year, d.month, d.day)
|
||||
|
||||
|
||||
def jalali_to_iso(jy: int, jm: int, jd: int) -> str:
|
||||
gy, gm, gd = j2g(jy, jm, jd)
|
||||
return f"{gy:04d}-{gm:02d}-{gd:02d}"
|
||||
|
||||
|
||||
def jalali_format(jy: int, jm: int, jd: int) -> str:
|
||||
return f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||||
|
||||
|
||||
def jalali_month_name(jm: int) -> str:
|
||||
return JALALI_MONTH_NAMES[jm] if 1 <= jm <= 12 else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Market Conventions & Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_IS_OFFICIAL = True
|
||||
|
||||
# Standard validity days in volatile markets (materials)
|
||||
DEFAULT_VALIDITY_DAYS_MAP: dict[str, int] = {
|
||||
"آهن": 1,
|
||||
"میلگرد": 1,
|
||||
"تیرآهن": 1,
|
||||
"ورق": 1,
|
||||
"پروفیل": 2,
|
||||
"سیمان": 3,
|
||||
"گچ": 7,
|
||||
"لوله": 7,
|
||||
"اتصالات": 7,
|
||||
"شیرآلات": 7,
|
||||
"شن": 7,
|
||||
"ماسه": 7,
|
||||
}
|
||||
FALLBACK_VALIDITY_DAYS: int = 7
|
||||
|
||||
|
||||
def compute_validity_date(materials: list[str] | None = None, explicit_days: int | None = None,
|
||||
ref_date: date | None = None) -> tuple[str, str]:
|
||||
"""Compute (iso_date, jalali_date_str) based on material or explicit days."""
|
||||
base = ref_date or date.today()
|
||||
if explicit_days is not None and explicit_days > 0:
|
||||
days = explicit_days
|
||||
else:
|
||||
days = FALLBACK_VALIDITY_DAYS
|
||||
if materials:
|
||||
for m in materials:
|
||||
m_str = (m or "").lower()
|
||||
for k, v in DEFAULT_VALIDITY_DAYS_MAP.items():
|
||||
if k in m_str:
|
||||
days = min(days, v)
|
||||
target = base + timedelta(days=days)
|
||||
jy, jm, jd = g2j(target.year, target.month, target.day)
|
||||
return f"{target.year:04d}-{target.month:02d}-{target.day:02d}", jalali_format(jy, jm, jd)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payment Terms Generators (Heuristics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_equal_installments(
|
||||
total_amount: float,
|
||||
count: int,
|
||||
ref_date: date | None = None,
|
||||
) -> list[dict]:
|
||||
"""Build equal installments milestones.
|
||||
|
||||
- 1st milestone: today (or ref_date)
|
||||
- 2nd..nth milestones: end of subsequent Jalali months
|
||||
Amounts: total_amount / count, rounded to thousands, with remainder added to last milestone.
|
||||
"""
|
||||
if count < 1:
|
||||
count = 1
|
||||
base = ref_date or date.today()
|
||||
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
|
||||
|
||||
base_per_installment = int(total_amount // count) if total_amount > 0 else 0
|
||||
remainder = int(total_amount - (base_per_installment * count)) if total_amount > 0 else 0
|
||||
|
||||
milestones: list[dict] = []
|
||||
|
||||
# Milestone 1: today
|
||||
m1_iso = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
|
||||
m1_jalali = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
|
||||
milestones.append({
|
||||
"due_date": m1_iso,
|
||||
"due_date_jalali": m1_jalali,
|
||||
"amount": base_per_installment,
|
||||
"note": f"قسط اول (امروز - {curr_jd} {jalali_month_name(curr_jm)})",
|
||||
})
|
||||
|
||||
# Milestone 2..N: end of consecutive Jalali months
|
||||
target_jy = curr_jy
|
||||
target_jm = curr_jm
|
||||
|
||||
for i in range(2, count + 1):
|
||||
target_jm += 1
|
||||
if target_jm > 12:
|
||||
target_jm = 1
|
||||
target_jy += 1
|
||||
end_day = jalali_month_days(target_jy, target_jm)
|
||||
iso_str = jalali_to_iso(target_jy, target_jm, end_day)
|
||||
jalali_str = f"{target_jy}/{target_jm:02d}/{end_day:02d}"
|
||||
amt = base_per_installment + (remainder if i == count else 0)
|
||||
m_name = jalali_month_name(target_jm)
|
||||
milestones.append({
|
||||
"due_date": iso_str,
|
||||
"due_date_jalali": jalali_str,
|
||||
"amount": amt,
|
||||
"note": f"قسط {i} (پایان {m_name})",
|
||||
})
|
||||
|
||||
return milestones
|
||||
|
||||
|
||||
def build_cash_today_termsheet(
|
||||
total_amount: float,
|
||||
discount_percent: float = 0.0,
|
||||
ref_date: date | None = None,
|
||||
) -> dict:
|
||||
"""Build cash today payment termsheet."""
|
||||
base = ref_date or date.today()
|
||||
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
|
||||
iso_date = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
|
||||
jalali_str = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
|
||||
|
||||
discount_val = 0.0
|
||||
payable = total_amount
|
||||
if discount_percent > 0 and total_amount > 0:
|
||||
discount_val = round((total_amount * discount_percent) / 100.0)
|
||||
payable = total_amount - discount_val
|
||||
|
||||
disc_label = f" (تخفیف {int(discount_percent)}٪)" if discount_percent > 0 else ""
|
||||
return {
|
||||
"label": f"پرداخت یکباره امروز{disc_label}",
|
||||
"discount_type": "percent" if discount_percent > 0 else "none",
|
||||
"discount_value": discount_percent if discount_percent > 0 else 0.0,
|
||||
"milestones": [
|
||||
{
|
||||
"due_date": iso_date,
|
||||
"due_date_jalali": jalali_str,
|
||||
"amount": payable,
|
||||
"note": "تسویه نقدی یکباره (امروز)",
|
||||
}
|
||||
],
|
||||
"description": f"مبلغ فاکتور: {int(total_amount):,} | تخفیف: {int(discount_val):,} | خالص پرداختی: {int(payable):,}",
|
||||
}
|
||||
|
||||
|
||||
def propose_project_distribution(project_names: list[str], items: list[dict]) -> list[tuple[str, str]]:
|
||||
"""Propose assignment of items to projects when multiple projects exist.
|
||||
|
||||
Returns list of (item_material_name, assigned_project_name).
|
||||
"""
|
||||
clean_projects = [p.strip() for p in project_names if p and p.strip()]
|
||||
if not clean_projects or not items:
|
||||
return []
|
||||
proposals: list[tuple[str, str]] = []
|
||||
p_len = len(clean_projects)
|
||||
for idx, it in enumerate(items):
|
||||
assigned_p = clean_projects[idx % p_len]
|
||||
mat_name = it.get("material_name") or f"قلم {idx + 1}"
|
||||
proposals.append((mat_name, assigned_p))
|
||||
return proposals
|
||||
+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]
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.storage import save_proforma_file
|
||||
from app.stt import transcribe, transcribe_multi
|
||||
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
||||
|
||||
from . import commands, callbacks, quote, supplier, material, queries, decisions
|
||||
from . import commands, callbacks, quote, supplier, material, project, queries, decisions
|
||||
|
||||
logger = logging.getLogger("bot.handlers")
|
||||
|
||||
@@ -98,6 +98,12 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
|
||||
# =========================================================================
|
||||
if reply_to_msg_id is not None:
|
||||
# Check if replying to an ambiguity message (Requirement 2.b)
|
||||
amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id)
|
||||
if amb_draft is not None and target_amb is not None:
|
||||
await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id)
|
||||
return
|
||||
|
||||
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
|
||||
if target_draft is None:
|
||||
await ctx.send(
|
||||
@@ -221,6 +227,8 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
await supplier.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.commit(ctx, chat_id, user, active)
|
||||
elif intent == "discard_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active:
|
||||
@@ -242,10 +250,14 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
await supplier.render(ctx, chat_id, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.render(ctx, chat_id, active)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.render(ctx, chat_id, active)
|
||||
elif intent == "add_supplier":
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "add_material":
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "add_project":
|
||||
await project.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "status_query":
|
||||
await queries.handle_query(ctx, chat_id, user, patch)
|
||||
elif intent == "accept_quote":
|
||||
@@ -323,3 +335,63 @@ def _draft_summary(draft) -> str | None:
|
||||
else:
|
||||
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
||||
return summary
|
||||
|
||||
|
||||
async def _handle_ambiguity_user_reply(
|
||||
ctx: BotContext, chat_id: int, user: LinkedUser,
|
||||
draft, amb: dict, message: dict, incoming_msg_id: int | None
|
||||
) -> None:
|
||||
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b)."""
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
if not text and (message.get("voice") or message.get("audio")):
|
||||
try:
|
||||
audio_media = message.get("voice") or message.get("audio")
|
||||
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
|
||||
text = await transcribe(settings, audio_bytes)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if text:
|
||||
draft_summary = _draft_summary(draft)
|
||||
result = await understand(settings, text, draft_summary)
|
||||
patch = result.get("patch") or {}
|
||||
|
||||
# Apply user correction specifically to the target field
|
||||
if amb.get("kind") == "payment_schedule":
|
||||
if patch.get("payment_terms"):
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
else:
|
||||
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
|
||||
if not draft.payload.get("notes"):
|
||||
draft.payload["notes"] = f"نحوه پرداخت: {text}"
|
||||
else:
|
||||
draft.payload["notes"] += f"\nنحوه پرداخت: {text}"
|
||||
elif amb.get("kind") == "project_assignment":
|
||||
if patch.get("items") or patch.get("project_name") or patch.get("project_names"):
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
else:
|
||||
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
|
||||
else:
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
|
||||
amb["status"] = "applied"
|
||||
await quote.resolve_payload(ctx, draft.payload)
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
|
||||
# In-place edit main review card (Requirement 2.b)
|
||||
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||
|
||||
# Delete the ambiguity message (Requirement 2.b)
|
||||
if amb.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Delete user's message to keep chat clean (Requirement 2.b)
|
||||
if incoming_msg_id:
|
||||
try:
|
||||
await ctx.delete(chat_id, incoming_msg_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from app import drafts as draft_store
|
||||
from app.runtime import LinkedUser
|
||||
from app.texts import CANCELLED, UNLINKED, esc
|
||||
|
||||
from . import quote, supplier, material, decisions
|
||||
from . import quote, supplier, material, project, decisions
|
||||
|
||||
logger = logging.getLogger("bot.callbacks")
|
||||
|
||||
@@ -57,6 +57,45 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
|
||||
return
|
||||
|
||||
# ---- ambiguity sub-messages (Requirement 2.a) -------------------------
|
||||
if parts[0] == "amb" and len(parts) >= 4:
|
||||
action, prefix, amb_id = parts[1], parts[2], parts[3]
|
||||
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
||||
if draft is None:
|
||||
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
||||
return
|
||||
|
||||
payload = draft.payload or {}
|
||||
ambiguities = payload.get("ambiguities") or []
|
||||
target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None)
|
||||
if not target_amb:
|
||||
return
|
||||
|
||||
if action == "ok":
|
||||
proposed = target_amb.get("proposed_patch") or {}
|
||||
draft_store.merge_quote_patch(payload, proposed)
|
||||
target_amb["status"] = "applied"
|
||||
await quote.resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
# In-place edit main review card
|
||||
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||
# Delete ONLY this ambiguity message
|
||||
if target_amb.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, target_amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
elif action == "no":
|
||||
target_amb["status"] = "rejected"
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
# Delete ONLY this ambiguity message
|
||||
if target_amb.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, target_amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# ---- pick a candidate for a draft field -------------------------------
|
||||
if parts[0] == "pk" and len(parts) == 4:
|
||||
draft = await draft_store.get_by_prefix(parts[1], chat_id)
|
||||
@@ -68,8 +107,13 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
|
||||
# ---- draft commit / cancel --------------------------------------------
|
||||
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
|
||||
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
|
||||
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
|
||||
if op in ("q", "s", "m", "p") and arg in ("ok", "ok2", "no"):
|
||||
kind = {
|
||||
"q": draft_store.KIND_QUOTE,
|
||||
"s": draft_store.KIND_SUPPLIER,
|
||||
"m": draft_store.KIND_MATERIAL,
|
||||
"p": draft_store.KIND_PROJECT,
|
||||
}[op]
|
||||
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
||||
if draft is None or draft.kind != kind:
|
||||
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
||||
@@ -89,8 +133,10 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
|
||||
elif kind == draft_store.KIND_SUPPLIER:
|
||||
await supplier.commit(ctx, chat_id, user, draft)
|
||||
else:
|
||||
elif kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, draft)
|
||||
elif kind == draft_store.KIND_PROJECT:
|
||||
await project.commit(ctx, chat_id, user, draft)
|
||||
|
||||
|
||||
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Add-project flow: name + optional description."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing,
|
||||
)
|
||||
from app.keyboards import simple_review_keyboard
|
||||
from app.texts import ERR_GENERIC, esc, project_committed_report, project_review_text
|
||||
|
||||
logger = logging.getLogger("bot.project")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_PROJECT)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
merge_project_patch(payload, patch)
|
||||
else:
|
||||
payload = new_project_payload()
|
||||
merge_project_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source)
|
||||
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
|
||||
|
||||
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
return simple_review_keyboard(draft_id, KIND_PROJECT, [])
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
payload = draft.payload
|
||||
missing = project_missing(payload)
|
||||
text = project_review_text(payload, missing)
|
||||
keyboard = _keyboard(payload, draft.id)
|
||||
if fresh:
|
||||
message = await ctx.send(chat_id, text, reply_markup=keyboard)
|
||||
draft.review_message_id = (message or {}).get("message_id")
|
||||
await draft_store.save_payload(draft, missing={"fields": missing},
|
||||
review_message_id=draft.review_message_id)
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
payload = draft.payload
|
||||
missing = {m["key"]: m["ok"] for m in project_missing(payload)}
|
||||
if not missing.get("name"):
|
||||
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
|
||||
return
|
||||
body = {"name": payload["name"]}
|
||||
if payload.get("description"):
|
||||
body["description"] = payload["description"]
|
||||
try:
|
||||
created = await ctx.api.create_project(body, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("create_project failed")
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.resolver.invalidate("projects")
|
||||
await ctx.send(chat_id, project_committed_report(created))
|
||||
+111
-4
@@ -26,6 +26,17 @@ async def handle_entities(
|
||||
draft = target_draft
|
||||
prev_review_msg_id = draft.review_message_id
|
||||
draft.review_message_id = None
|
||||
|
||||
# Clean up old ambiguity sub-messages (Requirement 2.c)
|
||||
old_ambs = (draft.payload or {}).get("ambiguities") or []
|
||||
for a in old_ambs:
|
||||
if a.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, a["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
draft.payload["ambiguities"] = []
|
||||
|
||||
payload = draft.payload
|
||||
merge_quote_patch(payload, patch)
|
||||
draft.source_message_id = incoming_msg_id
|
||||
@@ -70,11 +81,16 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
else:
|
||||
options.pop("sup", None)
|
||||
|
||||
resolved_projects = payload.setdefault("resolved_projects", {})
|
||||
|
||||
if not payload.get("project_id") and payload.get("project_name"):
|
||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||
if res.resolved:
|
||||
payload["project_id"] = res.candidates[0].id
|
||||
payload["project_name"] = res.candidates[0].name
|
||||
c = res.candidates[0]
|
||||
payload["project_id"] = c.id
|
||||
payload["project_name"] = c.name
|
||||
resolved_projects[c.name] = c.id
|
||||
resolved_projects[payload["project_name"]] = c.id
|
||||
if payload["project_id"] not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(payload["project_id"])
|
||||
options.pop("prj", None)
|
||||
@@ -86,7 +102,10 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
for p_name in payload.get("project_names") or []:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
p_id = res.candidates[0].id
|
||||
c = res.candidates[0]
|
||||
p_id = c.id
|
||||
resolved_projects[c.name] = p_id
|
||||
resolved_projects[p_name] = p_id
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
if not payload.get("project_id"):
|
||||
@@ -231,6 +250,9 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
||||
source_message_id=reply_to or draft.source_message_id,
|
||||
mark_bale_synced=True,
|
||||
)
|
||||
|
||||
# Send pending ambiguity sub-messages as replies to the main review card
|
||||
await send_pending_ambiguities(ctx, chat_id, draft)
|
||||
else:
|
||||
if not draft.review_message_id:
|
||||
return
|
||||
@@ -242,6 +264,36 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
|
||||
|
||||
async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
|
||||
from app.keyboards import ambiguity_keyboard
|
||||
from app.texts import ambiguity_message_text
|
||||
|
||||
if not draft.review_message_id:
|
||||
return
|
||||
|
||||
prefix = str(draft.id)[:8]
|
||||
payload = draft.payload or {}
|
||||
ambiguities = payload.get("ambiguities") or []
|
||||
updated = False
|
||||
|
||||
for amb in ambiguities:
|
||||
if amb.get("status") == "pending" and not amb.get("message_id"):
|
||||
msg_text = ambiguity_message_text(amb.get("title", ""), amb.get("details", ""))
|
||||
kb = ambiguity_keyboard(prefix, amb.get("id", ""))
|
||||
sent = await ctx.send(
|
||||
chat_id,
|
||||
msg_text,
|
||||
reply_markup=kb,
|
||||
reply_to=draft.review_message_id,
|
||||
)
|
||||
if sent and sent.get("message_id"):
|
||||
amb["message_id"] = sent["message_id"]
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -250,6 +302,8 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
|
||||
newly_created: dict = {}
|
||||
|
||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
cat_id = payload.get("supplier_category_id")
|
||||
@@ -267,11 +321,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
sup_body["phone"] = payload["supplier_phone"]
|
||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||
payload["supplier_id"] = created_sup["id"]
|
||||
newly_created["supplier"] = payload["supplier_name"]
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create supplier: %s", e)
|
||||
|
||||
# 2. Sequential pre-creation for new materials
|
||||
created_mats = []
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||
try:
|
||||
@@ -280,9 +336,60 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
"canonical_unit_id": int(item["unit_id"]),
|
||||
}, actor_id=user.actor_id)
|
||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||
created_mats.append(item["material_name"])
|
||||
ctx.resolver.invalidate("materials")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create material: %s", e)
|
||||
if created_mats:
|
||||
newly_created["materials"] = created_mats
|
||||
|
||||
# 3. Sequential pre-creation for new projects
|
||||
created_prjs = []
|
||||
all_p_names = []
|
||||
if payload.get("project_names"):
|
||||
for p in payload["project_names"]:
|
||||
p_str = str(p).strip()
|
||||
if p_str and p_str not in all_p_names:
|
||||
all_p_names.append(p_str)
|
||||
elif payload.get("project_name"):
|
||||
p_str = str(payload["project_name"]).strip()
|
||||
if p_str and p_str not in all_p_names:
|
||||
all_p_names.append(p_str)
|
||||
|
||||
for it in payload.get("items") or []:
|
||||
it_p = it.get("project_name")
|
||||
if it_p:
|
||||
it_p_str = str(it_p).strip()
|
||||
if it_p_str and it_p_str not in all_p_names:
|
||||
all_p_names.append(it_p_str)
|
||||
|
||||
resolved_map = payload.setdefault("resolved_projects", {})
|
||||
for p_name in all_p_names:
|
||||
p_id = resolved_map.get(p_name)
|
||||
if not p_id:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
p_id = res.candidates[0].id
|
||||
resolved_map[p_name] = p_id
|
||||
else:
|
||||
try:
|
||||
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
|
||||
p_id = created_prj.get("id")
|
||||
if p_id:
|
||||
resolved_map[p_name] = p_id
|
||||
created_prjs.append(p_name)
|
||||
ctx.resolver.invalidate("projects")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create project %s: %s", p_name, e)
|
||||
|
||||
if p_id:
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = p_id
|
||||
|
||||
if created_prjs:
|
||||
newly_created["projects"] = created_prjs
|
||||
|
||||
ready, blockers = quote_ready(payload)
|
||||
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
||||
@@ -346,4 +453,4 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
||||
if dropped > 0:
|
||||
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining, newly_created=newly_created))
|
||||
|
||||
+10
-1
@@ -22,7 +22,7 @@ def _btn(text: str, data: str) -> dict:
|
||||
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
|
||||
|
||||
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
|
||||
op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q")
|
||||
op = {"quote": "q", "supplier": "s", "material": "m", "project": "p"}.get(kind, "q")
|
||||
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
|
||||
if has_partial_option:
|
||||
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
|
||||
@@ -72,3 +72,12 @@ def arrive_keyboard() -> dict:
|
||||
|
||||
def cancel_keyboard() -> dict:
|
||||
return _kb([[_btn("❌ انصراف", "ac:no")]])
|
||||
|
||||
|
||||
def ambiguity_keyboard(prefix: str, amb_id: str) -> dict:
|
||||
"""Inline buttons for ambiguity sub-message (≤64 bytes callback_data)."""
|
||||
return _kb([
|
||||
[_btn("✅ تأیید و اعمال در پیشنویس", f"amb:ok:{prefix}:{amb_id}")],
|
||||
[_btn("❌ تایید نمیشود", f"amb:no:{prefix}:{amb_id}")],
|
||||
])
|
||||
|
||||
|
||||
+20
-5
@@ -40,6 +40,7 @@ INTENTS = [
|
||||
"show_draft", # «وضعیت پیشنویس؟»
|
||||
"add_supplier",
|
||||
"add_material",
|
||||
"add_project",
|
||||
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
||||
"accept_quote", # پذیرش یک استعلام
|
||||
"arrive_items", # اعلام رسیدن اقلام
|
||||
@@ -70,13 +71,24 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
|
||||
- اگر سؤال درباره وضعیت است → intent="status_query".
|
||||
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمینکننده/استعلام.
|
||||
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمینکننده/استعلام و در patch.arrivals مقادیر.
|
||||
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
|
||||
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
|
||||
- برای پروژهها:
|
||||
* اگر کاربر یک یا چند پروژه را ذکر کرد (مانند «برای منشانه و دفتر» یا «پروژه آسمان و دفتر مرکزی»)، همه نامهای پروژهها را دقیقاً و بدون جا انداختن یا اشتباه گرفتن با برند در آرایه project_names قرار بده: ["منشانه", "دفتر"].
|
||||
* این پروژهها ممکن است پروژههای جدید باشند؛ هرگز آنها را با نامهای دیگر جایگزین نکن یا نادیده نگیر.
|
||||
* نام پروژهها را هرگز به عنوان برند کالا در نظر نگیر.
|
||||
* اگر هر کالا به پروژه خاصی تعلق داشت در item.project_name بنویس، وگرنه آن را خالی بگذار تا سیستم تفکیک کند.
|
||||
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد»، «سیمان» یا «گچ و خاک»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز، وزن بسته مانند ۵۰ کیلویی یا گرید) در آرایه specs قرار گیرد.
|
||||
- برای مهلت اعتبار یا پیشفاکتور: اگر کاربر گفت مثلاً «پیشفاکتور تا ۲۰ روز دیگر آماده/معتبر است»، تعداد روز را در expiry_days: 20 بگذار.
|
||||
- برای شرایط پرداخت: ساختار مفهومی را در payment_terms استخراج کن و نیازی به محاسبه تقویمی تاریخها نداری:
|
||||
* label: متن عنوان شرایط (مثلاً «سه قسط در طول زمان» یا «پرداخت یکباره امروز»).
|
||||
* type: یکی از "installments" (اقساطی)، "cash_today" (نقدی امروز)، "deferred_check" (چک)، "other".
|
||||
* installments_count: تعداد اقساط اگر ذکر شد (مثلاً ۳).
|
||||
* is_today: آیا تسویه یکباره امروز یا قسط اول امروز است (true/false).
|
||||
* discount_percent: درصد تخفیف نقدی اگر ذکر شد (مثلاً ۱۰).
|
||||
|
||||
شکل patch برای intent="register_quote"/"update_draft":
|
||||
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
|
||||
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
|
||||
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
|
||||
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD",
|
||||
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "project_name": str, "notes": str}],
|
||||
"payment_terms": [{"label": str, "type": "installments|cash_today|deferred_check|other", "installments_count": int, "is_today": bool, "discount_percent": number, "discount_type": "none|percent|fixed", "discount_value": number, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number, "note": str}], "notes": str}]}
|
||||
|
||||
شکل patch برای intent="add_supplier":
|
||||
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
|
||||
@@ -84,6 +96,9 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
|
||||
شکل patch برای intent="add_material":
|
||||
{"material": {"name": str, "unit_name": str, "topic": str}}
|
||||
|
||||
شکل patch برای intent="add_project":
|
||||
{"project": {"name": str, "description": str}}
|
||||
|
||||
شکل patch برای intent="status_query":
|
||||
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
|
||||
|
||||
|
||||
+11
-5
@@ -37,14 +37,18 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
|
||||
scored = []
|
||||
for item in items:
|
||||
name = normalize_text(item.name)
|
||||
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||
if q == name:
|
||||
ratio = 1.0
|
||||
elif q in name:
|
||||
ratio += 0.35
|
||||
scored.append((ratio, item))
|
||||
elif q in name and len(q) >= 3:
|
||||
ratio = 0.85 + 0.15 * (len(q) / len(name))
|
||||
elif name in q and len(name) >= 3:
|
||||
ratio = 0.85 + 0.15 * (len(name) / len(q))
|
||||
else:
|
||||
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||
if ratio >= 0.72:
|
||||
scored.append((ratio, item))
|
||||
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
|
||||
return [item for ratio, item in scored[:limit]]
|
||||
|
||||
|
||||
async def _db_units() -> list[Candidate]:
|
||||
@@ -259,6 +263,8 @@ class Resolver:
|
||||
top = candidates[0]
|
||||
top_norm = normalize_text(top.name)
|
||||
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
|
||||
if (norm_name in top_norm or top_norm in norm_name) and len(norm_name) >= 3:
|
||||
ratio = max(ratio, 0.90)
|
||||
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
|
||||
return Resolution("one", [top])
|
||||
return Resolution("many", candidates)
|
||||
|
||||
+55
-3
@@ -210,6 +210,20 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
|
||||
if cat_name:
|
||||
lines.append(f"\n🏷️ موضوع: {cat_name}")
|
||||
|
||||
# 🏗️ پروژه(ها)
|
||||
p_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else [])
|
||||
resolved_projects = payload.get("resolved_projects") or {}
|
||||
if p_names:
|
||||
if len(p_names) == 1:
|
||||
p = p_names[0]
|
||||
badge = " ✅" if (p in resolved_projects or payload.get("project_id")) else " ✨ (پروژه جدید)"
|
||||
lines.append(f"\n🏗️ پروژه: {p}{badge}")
|
||||
else:
|
||||
lines.append(f"\n🏗️ پروژهها ({fa_num(len(p_names))} مورد):")
|
||||
for p in p_names:
|
||||
badge = " ✅" if p in resolved_projects else " ✨ (پروژه جدید)"
|
||||
lines.append(f"▫️ {p}{badge}")
|
||||
|
||||
# 📦 اقلام
|
||||
items = payload.get("items") or []
|
||||
total_amount = 0.0
|
||||
@@ -232,7 +246,10 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
|
||||
# Project
|
||||
item_proj = it.get("project_name") or default_proj
|
||||
if item_proj:
|
||||
lines.append(f" 🏗️ {item_proj}")
|
||||
p_badge = ""
|
||||
if item_proj not in resolved_projects and not (payload.get("project_id") and len(p_names) <= 1):
|
||||
p_badge = " ✨ (پروژه جدید)"
|
||||
lines.append(f" 🏗️ {item_proj}{p_badge}")
|
||||
else:
|
||||
lines.append(" 🏗️ پروژه مشخص نشده")
|
||||
|
||||
@@ -368,7 +385,7 @@ def material_review_text(payload: dict, missing: list[dict]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
|
||||
def quote_committed_report(created: dict, payload: dict, remaining: list[str], newly_created: dict | None = None) -> str:
|
||||
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
|
||||
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
||||
if sup:
|
||||
@@ -379,8 +396,21 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
|
||||
sheets = created.get("termsheets") or []
|
||||
if sheets:
|
||||
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||||
if payload.get("project_id"):
|
||||
if payload.get("project_id") or payload.get("project_ids"):
|
||||
lines.append("پروژه: پیوست شد")
|
||||
|
||||
if newly_created:
|
||||
new_lines = []
|
||||
if newly_created.get("supplier"):
|
||||
new_lines.append(f"🏢 تأمینکننده جدید ثبت شد: {esc(newly_created['supplier'])}")
|
||||
if newly_created.get("projects"):
|
||||
new_lines.append(f"🏗️ پروژه(های) جدید ثبت شد: {esc('، '.join(newly_created['projects']))}")
|
||||
if newly_created.get("materials"):
|
||||
new_lines.append(f"📦 کالای جدید ثبت شد: {esc('، '.join(newly_created['materials']))}")
|
||||
if new_lines:
|
||||
lines.append("\n✨ موارد جدید ایجادشده:")
|
||||
lines += new_lines
|
||||
|
||||
if remaining:
|
||||
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||||
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
||||
@@ -388,6 +418,19 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def project_review_text(payload: dict, missing: list[dict]) -> str:
|
||||
lines = ["🏗️ پیشنویس پروژه جدید", f"نام: {esc(payload.get('name'))}"]
|
||||
if payload.get("description"):
|
||||
lines.append(f"توضیحات: {esc(payload['description'])}")
|
||||
lines.append("\nوضعیت فیلدها:")
|
||||
lines.append(missing_block(missing))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def project_committed_report(created: dict) -> str:
|
||||
return f"✅ پروژه ثبت شد — {esc(created.get('name'))}"
|
||||
|
||||
|
||||
def supplier_committed_report(created: dict) -> str:
|
||||
return (
|
||||
f"✅ تأمینکننده ثبت شد — {esc(created.get('name'))}\n"
|
||||
@@ -430,3 +473,12 @@ def status_report(kind: str, quotes: list[dict], extra_note: str | None = None)
|
||||
lines.append(extra_note)
|
||||
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ambiguity_message_text(title: str, details: str) -> str:
|
||||
return (
|
||||
f"🤔 آیا نظر شما این بود؟\n"
|
||||
f"موضوع: {title}\n\n"
|
||||
f"{details}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user