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]:
|
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 []
|
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]:
|
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 []
|
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
|
||||||
+154
-8
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import cast, func, select, String, update
|
from sqlalchemy import cast, func, select, String, update
|
||||||
|
|
||||||
|
from app import defaults
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import BotDraft
|
from app.models import BotDraft
|
||||||
from app.nlu import normalize_text
|
from app.nlu import normalize_text
|
||||||
@@ -23,6 +24,7 @@ from app.nlu import normalize_text
|
|||||||
KIND_QUOTE = "quote"
|
KIND_QUOTE = "quote"
|
||||||
KIND_SUPPLIER = "supplier"
|
KIND_SUPPLIER = "supplier"
|
||||||
KIND_MATERIAL = "material"
|
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()
|
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:
|
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)."""
|
"""Resolve the short id used in callback_data (first 8 hex of the UUID)."""
|
||||||
async with SessionLocal() as session:
|
async with SessionLocal() as session:
|
||||||
@@ -154,9 +172,10 @@ def new_quote_payload() -> dict:
|
|||||||
return {
|
return {
|
||||||
"supplier_name": None, "supplier_id": None,
|
"supplier_name": None, "supplier_id": None,
|
||||||
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
|
"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": [],
|
"items": [], "termsheets": [],
|
||||||
"options": {}, "proforma_file": None,
|
"options": {}, "proforma_file": None,
|
||||||
|
"ambiguities": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,11 +225,15 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
|||||||
if not payload.get("project_names"):
|
if not payload.get("project_names"):
|
||||||
payload["project_names"] = [payload["project_name"]]
|
payload["project_names"] = [payload["project_name"]]
|
||||||
changed = True
|
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"):
|
if patch.get("notes"):
|
||||||
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
||||||
changed = True
|
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"])
|
set_field("expiry_date", patch["expiry_date"])
|
||||||
|
|
||||||
for raw in patch.get("items") or []:
|
for raw in patch.get("items") or []:
|
||||||
@@ -219,10 +242,8 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
|||||||
if name:
|
if name:
|
||||||
best, best_ratio = None, 0.0
|
best, best_ratio = None, 0.0
|
||||||
for item in payload["items"]:
|
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"]):
|
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
|
||||||
continue
|
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"]):
|
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
|
||||||
continue
|
continue
|
||||||
ratio = _similarity(name, item.get("material_name"))
|
ratio = _similarity(name, item.get("material_name"))
|
||||||
@@ -249,15 +270,78 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
|||||||
if spec_str and not target.get("notes"):
|
if spec_str and not target.get("notes"):
|
||||||
target["notes"] = spec_str
|
target["notes"] = spec_str
|
||||||
changed = True
|
changed = True
|
||||||
for key in ("unit_name", "brand_name", "notes"):
|
for key in ("unit_name", "brand_name", "project_name", "notes"):
|
||||||
if raw.get(key):
|
if raw.get(key):
|
||||||
val = str(raw[key]).strip()
|
val = str(raw[key]).strip()
|
||||||
if target.get(key) != val:
|
if target.get(key) != val:
|
||||||
target[key] = val
|
target[key] = val
|
||||||
changed = True
|
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 []:
|
for raw in patch.get("payment_terms") or []:
|
||||||
|
p_type = raw.get("type") or ""
|
||||||
label = (raw.get("label") or "شرایط پرداخت").strip()
|
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 = []
|
milestones = []
|
||||||
for m in raw.get("milestones") or []:
|
for m in raw.get("milestones") or []:
|
||||||
if m.get("due_date") or m.get("amount"):
|
if m.get("due_date") or m.get("amount"):
|
||||||
@@ -273,10 +357,43 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
|||||||
"milestones": milestones,
|
"milestones": milestones,
|
||||||
"description": (raw.get("description") or "").strip() or None,
|
"description": (raw.get("description") or "").strip() or None,
|
||||||
}
|
}
|
||||||
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
|
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)
|
payload["termsheets"].append(entry)
|
||||||
changed = True
|
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
|
return changed
|
||||||
|
|
||||||
|
|
||||||
@@ -292,7 +409,13 @@ def compute_quote_missing(payload: dict) -> list[dict]:
|
|||||||
add("supplier", "تأمینکننده", sup_ok, True)
|
add("supplier", "تأمینکننده", sup_ok, True)
|
||||||
|
|
||||||
# In DB: project_id is nullable (optional)
|
# 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)
|
# In DB: expiry_date is nullable (optional)
|
||||||
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
|
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:
|
def short_id() -> str:
|
||||||
return uuid.uuid4().hex[:8]
|
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.stt import transcribe, transcribe_multi
|
||||||
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
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")
|
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
|
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
if reply_to_msg_id is not None:
|
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)
|
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
|
||||||
if target_draft is None:
|
if target_draft is None:
|
||||||
await ctx.send(
|
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)
|
await supplier.commit(ctx, chat_id, user, active)
|
||||||
elif active.kind == draft_store.KIND_MATERIAL:
|
elif active.kind == draft_store.KIND_MATERIAL:
|
||||||
await material.commit(ctx, chat_id, user, active)
|
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":
|
elif intent == "discard_draft":
|
||||||
active = target_draft or await draft_store.get_active(chat_id)
|
active = target_draft or await draft_store.get_active(chat_id)
|
||||||
if active:
|
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)
|
await supplier.render(ctx, chat_id, active)
|
||||||
elif active.kind == draft_store.KIND_MATERIAL:
|
elif active.kind == draft_store.KIND_MATERIAL:
|
||||||
await material.render(ctx, chat_id, active)
|
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":
|
elif intent == "add_supplier":
|
||||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||||
elif intent == "add_material":
|
elif intent == "add_material":
|
||||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
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":
|
elif intent == "status_query":
|
||||||
await queries.handle_query(ctx, chat_id, user, patch)
|
await queries.handle_query(ctx, chat_id, user, patch)
|
||||||
elif intent == "accept_quote":
|
elif intent == "accept_quote":
|
||||||
@@ -323,3 +335,63 @@ def _draft_summary(draft) -> str | None:
|
|||||||
else:
|
else:
|
||||||
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
||||||
return summary
|
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.runtime import LinkedUser
|
||||||
from app.texts import CANCELLED, UNLINKED, esc
|
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")
|
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)
|
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
|
||||||
return
|
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 -------------------------------
|
# ---- pick a candidate for a draft field -------------------------------
|
||||||
if parts[0] == "pk" and len(parts) == 4:
|
if parts[0] == "pk" and len(parts) == 4:
|
||||||
draft = await draft_store.get_by_prefix(parts[1], chat_id)
|
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 --------------------------------------------
|
# ---- draft commit / cancel --------------------------------------------
|
||||||
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
|
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"):
|
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}[op]
|
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)
|
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
||||||
if draft is None or draft.kind != kind:
|
if draft is None or draft.kind != kind:
|
||||||
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
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"))
|
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
|
||||||
elif kind == draft_store.KIND_SUPPLIER:
|
elif kind == draft_store.KIND_SUPPLIER:
|
||||||
await supplier.commit(ctx, chat_id, user, draft)
|
await supplier.commit(ctx, chat_id, user, draft)
|
||||||
else:
|
elif kind == draft_store.KIND_MATERIAL:
|
||||||
await material.commit(ctx, chat_id, user, draft)
|
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:
|
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
|
draft = target_draft
|
||||||
prev_review_msg_id = draft.review_message_id
|
prev_review_msg_id = draft.review_message_id
|
||||||
draft.review_message_id = None
|
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
|
payload = draft.payload
|
||||||
merge_quote_patch(payload, patch)
|
merge_quote_patch(payload, patch)
|
||||||
draft.source_message_id = incoming_msg_id
|
draft.source_message_id = incoming_msg_id
|
||||||
@@ -70,11 +81,16 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
|||||||
else:
|
else:
|
||||||
options.pop("sup", None)
|
options.pop("sup", None)
|
||||||
|
|
||||||
|
resolved_projects = payload.setdefault("resolved_projects", {})
|
||||||
|
|
||||||
if not payload.get("project_id") and payload.get("project_name"):
|
if not payload.get("project_id") and payload.get("project_name"):
|
||||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||||
if res.resolved:
|
if res.resolved:
|
||||||
payload["project_id"] = res.candidates[0].id
|
c = res.candidates[0]
|
||||||
payload["project_name"] = res.candidates[0].name
|
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", []):
|
if payload["project_id"] not in payload.get("project_ids", []):
|
||||||
payload.setdefault("project_ids", []).append(payload["project_id"])
|
payload.setdefault("project_ids", []).append(payload["project_id"])
|
||||||
options.pop("prj", None)
|
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 []:
|
for p_name in payload.get("project_names") or []:
|
||||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||||
if res.resolved:
|
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", []):
|
if p_id not in payload.get("project_ids", []):
|
||||||
payload.setdefault("project_ids", []).append(p_id)
|
payload.setdefault("project_ids", []).append(p_id)
|
||||||
if not payload.get("project_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,
|
source_message_id=reply_to or draft.source_message_id,
|
||||||
mark_bale_synced=True,
|
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:
|
else:
|
||||||
if not draft.review_message_id:
|
if not draft.review_message_id:
|
||||||
return
|
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)
|
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
|
# commit
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -250,6 +302,8 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
|||||||
payload = draft.payload
|
payload = draft.payload
|
||||||
await resolve_payload(ctx, payload)
|
await resolve_payload(ctx, payload)
|
||||||
|
|
||||||
|
newly_created: dict = {}
|
||||||
|
|
||||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||||
cat_id = payload.get("supplier_category_id")
|
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"]
|
sup_body["phone"] = payload["supplier_phone"]
|
||||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||||
payload["supplier_id"] = created_sup["id"]
|
payload["supplier_id"] = created_sup["id"]
|
||||||
|
newly_created["supplier"] = payload["supplier_name"]
|
||||||
ctx.resolver.invalidate("suppliers")
|
ctx.resolver.invalidate("suppliers")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not auto-create supplier: %s", e)
|
logger.warning("Could not auto-create supplier: %s", e)
|
||||||
|
|
||||||
# 2. Sequential pre-creation for new materials
|
# 2. Sequential pre-creation for new materials
|
||||||
|
created_mats = []
|
||||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
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"):
|
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||||
try:
|
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"]),
|
"canonical_unit_id": int(item["unit_id"]),
|
||||||
}, actor_id=user.actor_id)
|
}, actor_id=user.actor_id)
|
||||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||||
|
created_mats.append(item["material_name"])
|
||||||
ctx.resolver.invalidate("materials")
|
ctx.resolver.invalidate("materials")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not auto-create material: %s", 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)
|
ready, blockers = quote_ready(payload)
|
||||||
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
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 [])
|
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
||||||
if dropped > 0:
|
if dropped > 0:
|
||||||
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
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)
|
# 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]]:
|
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}")]]
|
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
|
||||||
if has_partial_option:
|
if has_partial_option:
|
||||||
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
|
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
|
||||||
@@ -72,3 +72,12 @@ def arrive_keyboard() -> dict:
|
|||||||
|
|
||||||
def cancel_keyboard() -> dict:
|
def cancel_keyboard() -> dict:
|
||||||
return _kb([[_btn("❌ انصراف", "ac:no")]])
|
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", # «وضعیت پیشنویس؟»
|
"show_draft", # «وضعیت پیشنویس؟»
|
||||||
"add_supplier",
|
"add_supplier",
|
||||||
"add_material",
|
"add_material",
|
||||||
|
"add_project",
|
||||||
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
||||||
"accept_quote", # پذیرش یک استعلام
|
"accept_quote", # پذیرش یک استعلام
|
||||||
"arrive_items", # اعلام رسیدن اقلام
|
"arrive_items", # اعلام رسیدن اقلام
|
||||||
@@ -70,13 +71,24 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
|
|||||||
- اگر سؤال درباره وضعیت است → intent="status_query".
|
- اگر سؤال درباره وضعیت است → intent="status_query".
|
||||||
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمینکننده/استعلام.
|
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمینکننده/استعلام.
|
||||||
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمینکننده/استعلام و در patch.arrivals مقادیر.
|
- اگر درباره اعلام رسیدن اقلام است → 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":
|
شکل 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",
|
{"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, "notes": str}],
|
"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, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
|
"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":
|
شکل patch برای intent="add_supplier":
|
||||||
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
|
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
|
||||||
@@ -84,6 +96,9 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
|
|||||||
شکل patch برای intent="add_material":
|
شکل patch برای intent="add_material":
|
||||||
{"material": {"name": str, "unit_name": str, "topic": str}}
|
{"material": {"name": str, "unit_name": str, "topic": str}}
|
||||||
|
|
||||||
|
شکل patch برای intent="add_project":
|
||||||
|
{"project": {"name": str, "description": str}}
|
||||||
|
|
||||||
شکل patch برای intent="status_query":
|
شکل patch برای intent="status_query":
|
||||||
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
|
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -37,14 +37,18 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
|
|||||||
scored = []
|
scored = []
|
||||||
for item in items:
|
for item in items:
|
||||||
name = normalize_text(item.name)
|
name = normalize_text(item.name)
|
||||||
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
|
||||||
if q == name:
|
if q == name:
|
||||||
ratio = 1.0
|
ratio = 1.0
|
||||||
elif q in name:
|
elif q in name and len(q) >= 3:
|
||||||
ratio += 0.35
|
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.append((ratio, item))
|
||||||
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
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]:
|
async def _db_units() -> list[Candidate]:
|
||||||
@@ -259,6 +263,8 @@ class Resolver:
|
|||||||
top = candidates[0]
|
top = candidates[0]
|
||||||
top_norm = normalize_text(top.name)
|
top_norm = normalize_text(top.name)
|
||||||
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
|
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):
|
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
|
||||||
return Resolution("one", [top])
|
return Resolution("one", [top])
|
||||||
return Resolution("many", candidates)
|
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:
|
if cat_name:
|
||||||
lines.append(f"\n🏷️ موضوع: {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 []
|
items = payload.get("items") or []
|
||||||
total_amount = 0.0
|
total_amount = 0.0
|
||||||
@@ -232,7 +246,10 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
|
|||||||
# Project
|
# Project
|
||||||
item_proj = it.get("project_name") or default_proj
|
item_proj = it.get("project_name") or default_proj
|
||||||
if item_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:
|
else:
|
||||||
lines.append(" 🏗️ پروژه مشخص نشده")
|
lines.append(" 🏗️ پروژه مشخص نشده")
|
||||||
|
|
||||||
@@ -368,7 +385,7 @@ def material_review_text(payload: dict, missing: list[dict]) -> str:
|
|||||||
return "\n".join(lines)
|
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'))}"]
|
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
|
||||||
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
||||||
if sup:
|
if sup:
|
||||||
@@ -379,8 +396,21 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
|
|||||||
sheets = created.get("termsheets") or []
|
sheets = created.get("termsheets") or []
|
||||||
if sheets:
|
if sheets:
|
||||||
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||||||
if payload.get("project_id"):
|
if payload.get("project_id") or payload.get("project_ids"):
|
||||||
lines.append("پروژه: پیوست شد")
|
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:
|
if remaining:
|
||||||
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||||||
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
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)
|
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:
|
def supplier_committed_report(created: dict) -> str:
|
||||||
return (
|
return (
|
||||||
f"✅ تأمینکننده ثبت شد — {esc(created.get('name'))}\n"
|
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.append(extra_note)
|
||||||
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def ambiguity_message_text(title: str, details: str) -> str:
|
||||||
|
return (
|
||||||
|
f"🤔 آیا نظر شما این بود؟\n"
|
||||||
|
f"موضوع: {title}\n\n"
|
||||||
|
f"{details}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
+83
-12
@@ -59,6 +59,9 @@ class FakeBale:
|
|||||||
"reply_markup": reply_markup})
|
"reply_markup": reply_markup})
|
||||||
return {"message_id": message_id}
|
return {"message_id": message_id}
|
||||||
|
|
||||||
|
async def delete_message(self, chat_id, message_id):
|
||||||
|
return True
|
||||||
|
|
||||||
async def answer_callback_query(self, callback_query_id, text=None):
|
async def answer_callback_query(self, callback_query_id, text=None):
|
||||||
self.answered.append(callback_query_id)
|
self.answered.append(callback_query_id)
|
||||||
|
|
||||||
@@ -82,6 +85,7 @@ class FakeApi:
|
|||||||
self.created_quotes: list[dict] = []
|
self.created_quotes: list[dict] = []
|
||||||
self.created_suppliers: list[dict] = []
|
self.created_suppliers: list[dict] = []
|
||||||
self.created_materials: list[dict] = []
|
self.created_materials: list[dict] = []
|
||||||
|
self.created_projects: list[dict] = []
|
||||||
self.accept_payloads: list[dict] = []
|
self.accept_payloads: list[dict] = []
|
||||||
self.arrive_payloads: list[dict] = []
|
self.arrive_payloads: list[dict] = []
|
||||||
self.login_calls: list[dict] = []
|
self.login_calls: list[dict] = []
|
||||||
@@ -119,6 +123,10 @@ class FakeApi:
|
|||||||
async def list_projects(self, page_size=100):
|
async def list_projects(self, page_size=100):
|
||||||
return [{"id": "prj-1", "name": "برج آسمان"}]
|
return [{"id": "prj-1", "name": "برج آسمان"}]
|
||||||
|
|
||||||
|
async def create_project(self, payload, actor_id=None):
|
||||||
|
self.created_projects.append(payload)
|
||||||
|
return {"id": f"prj-{len(self.created_projects)}", "name": payload["name"]}
|
||||||
|
|
||||||
async def list_categories(self, page_size=100):
|
async def list_categories(self, page_size=100):
|
||||||
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
||||||
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
||||||
@@ -180,10 +188,11 @@ def _install_fake_stores() -> None:
|
|||||||
import app.drafts as drafts
|
import app.drafts as drafts
|
||||||
import app.runtime as runtime
|
import app.runtime as runtime
|
||||||
|
|
||||||
async def create(chat_id, kind, payload, source=None):
|
async def create(chat_id, kind, payload, source=None, source_message_id=None):
|
||||||
draft_id = _uuid.uuid4()
|
draft_id = _uuid.uuid4()
|
||||||
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
||||||
missing=None, status="active", source=source,
|
missing=None, status="active", source=source,
|
||||||
|
source_message_id=source_message_id,
|
||||||
review_message_id=None)
|
review_message_id=None)
|
||||||
MEM["drafts"][str(draft_id)] = draft
|
MEM["drafts"][str(draft_id)] = draft
|
||||||
return draft
|
return draft
|
||||||
@@ -193,16 +202,33 @@ def _install_fake_stores() -> None:
|
|||||||
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
|
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
|
||||||
return max(found, key=lambda d: d.id) if found else None
|
return max(found, key=lambda d: d.id) if found else None
|
||||||
|
|
||||||
|
async def get_by_message_id(chat_id, message_id):
|
||||||
|
for draft in MEM["drafts"].values():
|
||||||
|
if draft.chat_id == chat_id and draft.status == "active":
|
||||||
|
if draft.review_message_id == message_id or getattr(draft, "source_message_id", None) == message_id:
|
||||||
|
return draft
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_by_ambiguity_message_id(chat_id, message_id):
|
||||||
|
for draft in MEM["drafts"].values():
|
||||||
|
if draft.chat_id == chat_id and draft.status == "active":
|
||||||
|
for amb in (draft.payload or {}).get("ambiguities") or []:
|
||||||
|
if amb.get("message_id") == message_id:
|
||||||
|
return draft, amb
|
||||||
|
return None, None
|
||||||
|
|
||||||
async def get_by_prefix(prefix, chat_id=None):
|
async def get_by_prefix(prefix, chat_id=None):
|
||||||
for draft in MEM["drafts"].values():
|
for draft in MEM["drafts"].values():
|
||||||
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
||||||
return draft
|
return draft
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def save_payload(draft, missing=None, review_message_id=None):
|
async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False):
|
||||||
draft.missing = missing
|
draft.missing = missing
|
||||||
if review_message_id:
|
if review_message_id:
|
||||||
draft.review_message_id = review_message_id
|
draft.review_message_id = review_message_id
|
||||||
|
if source_message_id:
|
||||||
|
draft.source_message_id = source_message_id
|
||||||
|
|
||||||
async def set_status(draft_id, status):
|
async def set_status(draft_id, status):
|
||||||
if draft_id in MEM["drafts"]:
|
if draft_id in MEM["drafts"]:
|
||||||
@@ -212,6 +238,7 @@ def _install_fake_stores() -> None:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
|
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
|
||||||
|
drafts.get_by_message_id, drafts.get_by_ambiguity_message_id = get_by_message_id, get_by_ambiguity_message_id
|
||||||
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
|
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
|
||||||
|
|
||||||
async def link_get(self, chat_id):
|
async def link_get(self, chat_id):
|
||||||
@@ -245,6 +272,10 @@ async def fake_transcribe(settings, audio, mime_type=None, filename=None):
|
|||||||
"پرداخت سه فاز")
|
"پرداخت سه فاز")
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None):
|
||||||
|
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# driver
|
# driver
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -262,11 +293,15 @@ def build_ctx():
|
|||||||
ctx._log = types.MethodType(fake_log, ctx)
|
ctx._log = types.MethodType(fake_log, ctx)
|
||||||
H.understand = fake_understand
|
H.understand = fake_understand
|
||||||
H.transcribe = fake_transcribe
|
H.transcribe = fake_transcribe
|
||||||
|
H.transcribe_multi = fake_transcribe_multi
|
||||||
return ctx, bale, api
|
return ctx, bale, api
|
||||||
|
|
||||||
|
|
||||||
def text_update(update_id, text):
|
def text_update(update_id, text, reply_to=None):
|
||||||
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
|
msg = {"message_id": update_id, "chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}
|
||||||
|
if reply_to:
|
||||||
|
msg["reply_to_message"] = {"message_id": reply_to}
|
||||||
|
return {"update_id": update_id, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
def voice_update(update_id):
|
def voice_update(update_id):
|
||||||
@@ -291,16 +326,18 @@ async def main() -> int:
|
|||||||
|
|
||||||
payload = new_quote_payload()
|
payload = new_quote_payload()
|
||||||
merge_quote_patch(payload, {
|
merge_quote_patch(payload, {
|
||||||
"supplier_name": "سازان نو",
|
|
||||||
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||||
"payment_terms": [{"label": "۳ فاز"}],
|
"payment_terms": [{"label": "۳ فاز"}],
|
||||||
})
|
})
|
||||||
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]})
|
missing = compute_quote_missing(payload)
|
||||||
|
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||||
|
merge_quote_patch(payload, {
|
||||||
|
"supplier_name": "سازان نو",
|
||||||
|
"items": [{"material_name": "میلگرد A3", "quantity": 600}],
|
||||||
|
})
|
||||||
check("merge: no dup item", len(payload["items"]) == 1)
|
check("merge: no dup item", len(payload["items"]) == 1)
|
||||||
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
|
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
|
||||||
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
||||||
missing = compute_quote_missing(payload)
|
|
||||||
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
|
||||||
body, problems = build_quote_request(payload)
|
body, problems = build_quote_request(payload)
|
||||||
check("build: blocked without supplier", body is None)
|
check("build: blocked without supplier", body is None)
|
||||||
|
|
||||||
@@ -343,7 +380,7 @@ async def main() -> int:
|
|||||||
# refine price via follow-up text
|
# refine price via follow-up text
|
||||||
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
||||||
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
||||||
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد"))
|
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
|
||||||
draft = await _active("quote")
|
draft = await _active("quote")
|
||||||
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
||||||
|
|
||||||
@@ -417,14 +454,48 @@ async def main() -> int:
|
|||||||
check("material: posted", api.created_materials
|
check("material: posted", api.created_materials
|
||||||
and api.created_materials[0]["canonical_unit_id"] == 3)
|
and api.created_materials[0]["canonical_unit_id"] == 3)
|
||||||
|
|
||||||
|
# ---- new projects in quote + auto-creation on commit --------------------
|
||||||
|
NLU_QUEUE.append({
|
||||||
|
"intent": "register_quote",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"patch": {
|
||||||
|
"supplier_name": "سازان نو",
|
||||||
|
"project_names": ["منشانه", "دفتر"],
|
||||||
|
"items": [
|
||||||
|
{"material_name": "میلگرد A3 سایز ۱۴", "quantity": 100, "unit_price": 50000, "project_name": "منشانه"},
|
||||||
|
{"material_name": "شیر فلکه چهارطرفه", "quantity": 20, "unit_price": 100000, "project_name": "دفتر"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await H.handle_update(ctx, text_update(18, "استعلام برای منشانه و دفتر"))
|
||||||
|
q_draft = await _active("quote")
|
||||||
|
check("multi-project: draft created", q_draft is not None)
|
||||||
|
check("multi-project: no bogus match to برج آسمان",
|
||||||
|
"برج آسمان" not in bale.last_text() and "prj" not in (q_draft.payload.get("options") or {}))
|
||||||
|
check("multi-project: new project badge shown", "✨ (پروژه جدید)" in bale.last_text())
|
||||||
|
await H.handle_update(ctx, callback_update(19, f"q:ok:{q_draft.id[:8]}"))
|
||||||
|
created_prj_names = [p["name"] for p in api.created_projects]
|
||||||
|
check("multi-project: auto-created in ERP", "منشانه" in created_prj_names and "دفتر" in created_prj_names)
|
||||||
|
check("multi-project: commit report mentions projects", "پروژه(های) جدید ثبت شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- add project standalone --------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "add_project", "confidence": 0.9,
|
||||||
|
"patch": {"project": {"name": "پروژه امید", "description": "پروژه فاز سوم"}}})
|
||||||
|
await H.handle_update(ctx, text_update(20, "پروژه جدید امید"))
|
||||||
|
p_draft = await _active("project")
|
||||||
|
check("project: draft created", p_draft is not None)
|
||||||
|
await H.handle_update(ctx, callback_update(21, f"p:ok:{p_draft.id[:8]}"))
|
||||||
|
check("project: posted to ERP", any(p["name"] == "پروژه امید" for p in api.created_projects))
|
||||||
|
check("project: report sent", "پروژه ثبت شد" in bale.last_text())
|
||||||
|
|
||||||
# ---- cancel / unknown --------------------------------------------------
|
# ---- cancel / unknown --------------------------------------------------
|
||||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
||||||
await H.handle_update(ctx, text_update(18, "استعلام از سازان نو"))
|
await H.handle_update(ctx, text_update(22, "استعلام از سازان نو"))
|
||||||
await H.handle_update(ctx, text_update(19, "/cancel"))
|
await H.handle_update(ctx, text_update(23, "/cancel"))
|
||||||
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
||||||
|
|
||||||
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
||||||
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز"))
|
await H.handle_update(ctx, text_update(24, "اسپاگتی بپز"))
|
||||||
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
||||||
|
|
||||||
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user