diff --git a/ai procurement/app/drafts.py b/ai procurement/app/drafts.py index 27db768..cbad879 100644 --- a/ai procurement/app/drafts.py +++ b/ai procurement/app/drafts.py @@ -300,14 +300,14 @@ def compute_quote_missing(payload: dict) -> list[dict]: add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True) if not item.get("quantity"): add(f"item:{i}:qty", f"مقدار «{name}»", False, True) - if not (item.get("unit_id") or item.get("unit_name")): - add(f"item:{i}:unit", f"واحد «{name}»", False, True) + if not item.get("unit_id"): + add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True) if not item.get("unit_price"): - add(f"item:{i}:price", f"قیمت واحد «{name}»", False, False) + add(f"item:{i}:price", f"قیمت واحد برای «{name}»", False, False) if items and not payload.get("termsheets"): add("terms", "شرایط پرداخت", False, False) if not payload.get("notes"): - add("notes", "یادداشت", False, False) + add("notes", "توضیحات", False, False) return out diff --git a/ai procurement/app/handlers/quote.py b/ai procurement/app/handlers/quote.py index 74df784..696ee1e 100644 --- a/ai procurement/app/handlers/quote.py +++ b/ai procurement/app/handlers/quote.py @@ -57,8 +57,13 @@ async def resolve_payload(ctx, payload: dict) -> None: if not payload.get("supplier_id") and payload.get("supplier_name"): res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"]) if res.resolved: - payload["supplier_id"] = res.candidates[0].id - payload["supplier_name"] = res.candidates[0].name + c = res.candidates[0] + payload["supplier_id"] = c.id + payload["supplier_name"] = c.name + extra = c.extra or {} + for k in ("location", "phone", "contact_person", "category_name"): + if extra.get(k): + payload[f"supplier_{k}"] = extra[k] options.pop("sup", None) elif res.candidates: options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates] diff --git a/ai procurement/app/resolve.py b/ai procurement/app/resolve.py index fb0af5f..cde72f0 100644 --- a/ai procurement/app/resolve.py +++ b/ai procurement/app/resolve.py @@ -91,7 +91,19 @@ class Resolver: async def suppliers(self, q: str | None = None) -> list[Candidate]: rows = await self._api.list_suppliers(q=q, page_size=20) - items = [Candidate(id=str(s["id"]), name=s.get("name") or str(s["id"])) for s in rows] + items = [ + Candidate( + id=str(s["id"]), + name=s.get("name") or str(s["id"]), + extra={ + "location": s.get("location"), + "phone": s.get("phone"), + "contact_person": s.get("contact_person"), + "category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None, + }, + ) + for s in rows + ] return _rank(q, items) if q else items async def materials(self, q: str | None = None) -> list[Candidate]: diff --git a/ai procurement/app/texts.py b/ai procurement/app/texts.py index bd62cd8..8afc7b4 100644 --- a/ai procurement/app/texts.py +++ b/ai procurement/app/texts.py @@ -18,10 +18,71 @@ def esc(value) -> str: return html.escape(str(value if value is not None else "—")) -def fa_date(value: str | None) -> str: +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 fa_num(val, use_fa_digits: bool = True) -> str: + """Format integer/float with thousands separator and Persian digits.""" + if val is None or val == "": + return "—" + try: + num = float(val) + if num.is_integer(): + s = f"{int(num):,}" + else: + s = f"{num:,.2f}".rstrip("0").rstrip(".") + if use_fa_digits: + trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬") + return s.translate(trans) + return s + except (ValueError, TypeError): + return str(val) + + +def fa_digits(val) -> str: + """Convert ASCII digits to Persian digits without thousands separators.""" + if not val: + return "" + trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") + return str(val).translate(trans) + + +def fa_date(value, use_fa_digits: bool = True) -> str: if not value: return "—" - return str(value)[:10] + s = str(value)[:10] + try: + parts = [int(p) for p in s.replace("/", "-").split("-")] + if len(parts) == 3 and parts[0] > 1900: + jy, jm, jd = g2j(parts[0], parts[1], parts[2]) + res = f"{jy:04d}/{jm:02d}/{jd:02d}" + if use_fa_digits: + trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") + return res.translate(trans) + return res + except Exception: + pass + if use_fa_digits: + trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") + return s.translate(trans) + return s WELCOME = ( @@ -90,79 +151,196 @@ ACCEPT_DONE_TS = "شرایط انتخاب‌شده: {label}" def missing_block(missing: list[dict]) -> str: - """✅/❌ list of what's captured vs. still empty (⚠️ = nice-to-have).""" + """Checklist of missing vs. captured fields.""" if not missing: return "همه فیلدها کامل است ✅" lines = [] for m in missing: - mark = MISSING_MARKS[m["ok"]] if m["blocking"] else (MISSING_MARKS.get(m["ok"], WARNING_MARK)) - if not m["blocking"] and not m["ok"]: - mark = WARNING_MARK - lines.append(f"{mark} {m['label']}") + if m.get("ok"): + mark = "✅" + elif m.get("blocking"): + mark = "❌" + else: + mark = "⚠️" + lines.append(f"{mark} {esc(m.get('label'))}") return "\n".join(lines) def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str: - lines = ["🧾 پیش‌نویس استعلام:"] + lines = ["📋 پیش‌نویس استعلام"] + + has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing) + has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing) + + if not has_blocking and not has_warnings: + lines.append("✅ همه‌ی اطلاعات کامل است، آماده ثبت.") + elif has_blocking: + lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.") + else: + lines.append("⚠️ پیش‌نویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.") + if status_note: lines.append(status_note) - sup = payload.get("supplier_name") or "—" + + lines.append("━━━━━━━━━━━━━━\n") + + # 🏢 تأمین‌کننده + lines.append("🏢 تأمین‌کننده") + sup_name = payload.get("supplier_name") + is_official = payload.get("is_official", True) + official_badge = " (رسمی)" if is_official else " (غیررسمی)" + if payload.get("supplier_id"): - sup_label = f"{esc(sup)} ✅" - elif payload.get("supplier_name"): - sup_label = f"{esc(sup)} ✨ (تأمین‌کننده جدید)" + lines.append(f"{esc(sup_name)}{official_badge} ✅") + if payload.get("supplier_location"): + lines.append(f"📍 {esc(payload['supplier_location'])}") + contact_parts = [] + if payload.get("supplier_phone"): + contact_parts.append(fa_digits(payload["supplier_phone"])) + if payload.get("supplier_contact_person"): + contact_parts.append(esc(payload["supplier_contact_person"])) + if contact_parts: + lines.append(f"📞 {' — '.join(contact_parts)}") + elif sup_name: + lines.append(f"{esc(sup_name)}{official_badge} ✨ (تأمین‌کننده جدید)") else: - sup_label = "—" - lines.append(f"تأمین‌کننده: {sup_label}") - proj_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else []) - if proj_names: - lines.append(f"پروژه(ها): {esc('، '.join(proj_names))}") - lines.append(f"نوع: {'رسمی' if payload.get('is_official', True) else 'برآوردی'}") - if payload.get("expiry_date"): - lines.append(f"اعتبار تا: {fa_date(payload['expiry_date'])}") + lines.append("— (مشخص نشده)") + cat_name = payload.get("supplier_category_name") + if cat_name: + lines.append(f"\n🏷️ موضوع: {esc(cat_name)}") + + # 📦 اقلام items = payload.get("items") or [] - if items: - lines.append("\n📦 اقلام:") - for i, it in enumerate(items, start=1): - name = it.get('material_name') or '?' - if it.get('brand_name'): - name += f" ({it['brand_name']})" - parts = [f"{i}. {esc(name)}"] - if it.get('specs'): - parts.append(esc("، ".join(it['specs']))) - if it.get("quantity"): - parts.append(f"مقدار {esc(it['quantity'])}") - unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None) - if unit: - parts.append(str(unit)) - if it.get("unit_price"): - parts.append(f"قیمت {esc(it['unit_price'])}") - if not it.get("material_id"): - parts.append("✨ کالای جدید") - lines.append(" — ".join(str(p) for p in parts)) - else: - lines.append("\nاقلام: هنوز چیزی ثبت نشده ❌") + total_amount = 0.0 + has_any_price = False + if items: + lines.append(f"\n📦 اقلام ({fa_num(len(items))})") + default_proj = payload.get("project_name") + for i, it in enumerate(items, start=1): + name = it.get("material_name") or f"قلم {i}" + title_parts = [f"{esc(name)}"] + if it.get("brand_name"): + title_parts.append(esc(it["brand_name"])) + if it.get("specs"): + title_parts.append(esc("، ".join(it["specs"]))) + if not it.get("material_id"): + title_parts.append("✨ (کالای جدید)") + lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}") + + # Project + item_proj = it.get("project_name") or default_proj + if item_proj: + lines.append(f" 🏗️ {esc(item_proj)}") + else: + lines.append(" 🏗️ پروژه مشخص نشده") + + # Qty, Unit, Price, Total + qty = it.get("quantity") + unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None) + unit_price = it.get("unit_price") + + calc_line = " " + if qty is not None: + calc_line += f"{fa_num(qty)} {esc(unit or '')}".strip() + else: + calc_line += "مقدار نامشخص" + + if unit_price: + has_any_price = True + calc_line += f" × {fa_num(unit_price)}" + if qty is not None: + try: + line_sum = float(qty) * float(unit_price) + total_amount += line_sum + calc_line += f" = {fa_num(line_sum)} تومان" + except (ValueError, TypeError): + calc_line += " تومان" + else: + calc_line += " تومان" + else: + calc_line += " × — = — تومان" + + lines.append(calc_line) + + if has_any_price and total_amount > 0: + lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان") + elif not has_any_price: + lines.append("\n💰 مجموع مبلغ: محاسبه‌نشده (قیمت اقلام وارد نشده)") + else: + lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌") + + # 💳 شرایط پرداخت sheets = payload.get("termsheets") or [] if sheets: - ts_parts = [] - for t in sheets: - lbl = esc(t.get('label')) - if t.get("milestones"): - steps_txt = " + ".join(f"{fa_date(m.get('due_date'))}: {esc(m.get('amount'))}" for m in t["milestones"] if m.get("amount")) - if steps_txt: - lbl += f" [{steps_txt}]" - elif t.get("discount_value"): - lbl += f" (تخفیف {esc(t.get('discount_value'))})" - ts_parts.append(lbl) - lines.append("\n💳 شرایط پرداخت: " + "؛ ".join(ts_parts)) + lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})") + for s_idx, t in enumerate(sheets, start=1): + lbl = t.get("label") or f"گزینه {s_idx}" + milestones = t.get("milestones") or t.get("schedule_steps") or [] + if milestones: + lines.append(f"▫️ {esc(lbl)} — {fa_num(len(milestones))} مرحله") + step_sum = 0.0 + for m in milestones: + due = fa_date(m.get("due_date")) + amt = m.get("amount") + amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی" + if amt: + try: + step_sum += float(amt) + except (ValueError, TypeError): + pass + note = f" ({esc(m['note'])})" if m.get("note") else "" + lines.append(f" • {due} — {amt_str}{note}") + + if total_amount > 0 and step_sum > 0: + diff = abs(total_amount - step_sum) + if diff < 1.0: + lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است") + else: + lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان") + if len(items) > 0: + lines.append(f" شامل: هر {fa_num(len(items))} قلم") + else: + disc = "" + if t.get("discount_value"): + disc = f" (تخفیف {fa_num(t['discount_value'])})" + lines.append(f"▫️ {esc(lbl)}{disc}") + elif items: + lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیش‌فرض)") + + # Metadata + meta_lines = [] + if payload.get("quote_date"): + meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}") + if payload.get("expiry_date"): + meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}") + if payload.get("proforma_file"): + meta_lines.append(f"📎 پیش‌فاکتور: {esc(payload['proforma_file'])}") if payload.get("notes"): - lines.append(f"یادداشت: {esc(payload['notes'])}") + meta_lines.append(f"📝 توضیحات: {esc(payload['notes'])}") + + if meta_lines: + lines.append("") + lines.extend(meta_lines) + + # 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد) + if has_blocking or has_warnings: + lines.append("\n📋 وضعیت فیلدها:") + lines.append(missing_block(missing)) + + # Footer + lines.append("\n━━━━━━━━━━━━━━") + if has_blocking: + lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).") + lines.append("با پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.") + elif has_warnings: + lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).") + lines.append("می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.") + else: + lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).") + lines.append("با پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.") - lines.append("\n📋 وضعیت فیلدها:") - lines.append(missing_block(missing)) - lines.append("\nبا پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.") return "\n".join(lines)