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

This commit is contained in:
2026-10-08 22:33:15 +03:30
parent 638076dbaa
commit 77c0cacbb6
12 changed files with 905 additions and 59 deletions
+3
View File
@@ -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 []
+252
View File
@@ -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
View File
@@ -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]
+73 -1
View File
@@ -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
+50 -4
View File
@@ -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:
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")