556 lines
27 KiB
Python
556 lines
27 KiB
Python
"""Offline smoke test for the Bale bot — no Bale network, no postgres, no AI keys.
|
|
|
|
It stubs the Bale client, the ERP API client, STT and the NLU module, replaces
|
|
the draft store with an in-memory fake, then drives the real handler pipeline
|
|
end-to-end through fake updates (text, voice, inline-button callbacks).
|
|
|
|
Run inside the bot container (or anywhere the requirements are installed):
|
|
|
|
python tools/smoke_test.py
|
|
|
|
Exit code 0 = all checks passed.
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
# asyncpg is only needed when a real connection opens; stub it so app.db imports.
|
|
try:
|
|
import asyncpg # noqa: F401
|
|
except ImportError:
|
|
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
|
|
|
|
CHAT = 1461166742
|
|
|
|
_passed = _failed = 0
|
|
|
|
|
|
def check(name: str, ok: bool, detail: str = "") -> None:
|
|
global _passed, _failed
|
|
ok = bool(ok)
|
|
mark = "PASS" if ok else "FAIL"
|
|
print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else ""))
|
|
_passed += ok
|
|
_failed += (not ok)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FakeBale:
|
|
def __init__(self):
|
|
self.sent: list[dict] = []
|
|
self.edited: list[dict] = []
|
|
self.answered: list[str] = []
|
|
self._mid = 0
|
|
|
|
async def send_message(self, chat_id, text, reply_markup=None, reply_to=None):
|
|
self._mid += 1
|
|
self.sent.append({"chat_id": chat_id, "text": text, "reply_markup": reply_markup})
|
|
return {"message_id": self._mid, "chat": {"id": chat_id}}
|
|
|
|
async def edit_message_text(self, chat_id, message_id, text, reply_markup=None):
|
|
self.edited.append({"chat_id": chat_id, "message_id": message_id, "text": text,
|
|
"reply_markup": reply_markup})
|
|
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):
|
|
self.answered.append(callback_query_id)
|
|
|
|
async def send_chat_action(self, chat_id, action="typing"):
|
|
pass
|
|
|
|
async def download_file(self, file_id):
|
|
return b"fake-ogg-bytes"
|
|
|
|
def last_text(self) -> str:
|
|
return self.sent[-1]["text"] if self.sent else ""
|
|
|
|
def last_markup(self) -> dict | None:
|
|
return self.sent[-1]["reply_markup"] if self.sent else None
|
|
|
|
|
|
class FakeApi:
|
|
"""Canned ERP responses + captured mutation payloads."""
|
|
|
|
def __init__(self):
|
|
self.created_quotes: list[dict] = []
|
|
self.created_suppliers: list[dict] = []
|
|
self.created_materials: list[dict] = []
|
|
self.created_projects: list[dict] = []
|
|
self.accept_payloads: list[dict] = []
|
|
self.arrive_payloads: list[dict] = []
|
|
self.login_calls: list[dict] = []
|
|
|
|
async def login(self, username, password):
|
|
self.login_calls.append({"username": username, "password": password})
|
|
if username == "mohaghegh" and password == "secret":
|
|
return {"access_token": "jwt", "user": {"id": "user-1", "first_name": "علی",
|
|
"last_name": "محقق", "role": "procurement_manager"}}
|
|
raise RuntimeError("invalid credentials")
|
|
|
|
async def list_suppliers(self, q=None, page_size=20):
|
|
return [{"id": "sup-1", "name": "سازان نو", "category_id": "cat-2"}]
|
|
|
|
async def create_supplier(self, payload, actor_id=None):
|
|
self.created_suppliers.append(payload)
|
|
return {"id": "sup-new", "name": payload["name"],
|
|
"category": {"id": payload["category_id"], "name": "لوله و اتصالات"}}
|
|
|
|
async def list_materials(self, q=None, page_size=20):
|
|
return [
|
|
{"id": "mat-1", "name": "میلگرد A3 سایز ۱۴", "canonical_unit_id": 3},
|
|
{"id": "mat-2", "name": "میلگرد A3 سایز ۱۶", "canonical_unit_id": 3},
|
|
{"id": "mat-3", "name": "شیر فلکه چهارطرفه", "canonical_unit_id": 2},
|
|
]
|
|
|
|
async def create_material(self, payload, actor_id=None):
|
|
self.created_materials.append(payload)
|
|
return {"id": "mat-new", "name": payload["name"]}
|
|
|
|
async def list_units(self, q=None, page_size=100):
|
|
return [{"id": 2, "code": "adad", "name_fa": "عدد"},
|
|
{"id": 3, "code": "kg", "name_fa": "کیلوگرم"}]
|
|
|
|
async def list_projects(self, page_size=100):
|
|
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):
|
|
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
|
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
|
|
|
def _expanded(self, quote_id, status):
|
|
"""Realistic GET /quotes?expand=line_items,termsheets,supplier shape."""
|
|
return {
|
|
"id": quote_id, "supplier": {"name": "سازان نو"}, "project_id": None, "status": status,
|
|
"quote_date": "2026-09-10T00:00:00Z", "expiry_date": "2026-09-20",
|
|
"line_items": [
|
|
{"id": "li-1", "material": {"name": "میلگرد A3 سایز ۱۴"}, "unit": {"name_fa": "کیلوگرم"},
|
|
"quantity": 500, "unit_price": 45000, "projects": [],
|
|
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
|
|
{"id": "li-2", "material": {"name": "شیر فلکه چهارطرفه"}, "unit": {"name_fa": "عدد"},
|
|
"quantity": 200, "unit_price": 90000, "projects": [],
|
|
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
|
|
],
|
|
"termsheets": [{"id": "ts-1", "label": "۳ فاز", "discount_type": "none", "discount_value": 0,
|
|
"schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 1000}]}],
|
|
}
|
|
|
|
async def list_quotes(self, *, status=None, q=None, expand=None, sort=None, page=1, page_size=10):
|
|
if status == "offered":
|
|
return [self._expanded("q-aaaa1111", "offered")], {"total": 1}
|
|
if status == "accepted":
|
|
return [self._expanded("q-bbbb2222", "accepted")], {"total": 1}
|
|
return [], {"total": 0}
|
|
|
|
async def get_quote(self, quote_id, expand=None, actor_id=None):
|
|
return self._expanded(quote_id, "accepted" if quote_id.startswith("q-bbbb") else "offered")
|
|
|
|
async def create_quote(self, payload, actor_id=None):
|
|
self.created_quotes.append(payload)
|
|
return {"id": "q-new", "supplier": {"name": "سازان نو"},
|
|
"line_items": payload.get("line_items") or [],
|
|
"termsheets": payload.get("termsheets") or []}
|
|
|
|
async def accept_quote(self, quote_id, payload, actor_id=None):
|
|
self.accept_payloads.append({"quote_id": quote_id, "payload": payload})
|
|
return {"id": quote_id, "status": "accepted", "line_items": [{"id": "li-1", "decision": {}}]}
|
|
|
|
async def arrive_items(self, quote_id, payload, actor_id=None):
|
|
self.arrive_payloads.append({"quote_id": quote_id, "payload": payload})
|
|
return {"id": quote_id, "status": "arrived",
|
|
"items": [{"line_item_id": it["line_item_id"], "received_qty": float(it["received_qty"])}
|
|
for it in payload["items"]]}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# in-memory draft store + link store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
MEM: dict = {"drafts": {}, "links": {}}
|
|
|
|
|
|
def _install_fake_stores() -> None:
|
|
import uuid as _uuid
|
|
|
|
import app.drafts as drafts
|
|
import app.runtime as runtime
|
|
|
|
async def create(chat_id, kind, payload, source=None, source_message_id=None):
|
|
draft_id = _uuid.uuid4()
|
|
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
|
missing=None, status="active", source=source,
|
|
source_message_id=source_message_id,
|
|
review_message_id=None)
|
|
MEM["drafts"][str(draft_id)] = draft
|
|
return draft
|
|
|
|
async def get_active(chat_id, kind=None):
|
|
found = [d for d in MEM["drafts"].values()
|
|
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
|
|
|
|
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):
|
|
for draft in MEM["drafts"].values():
|
|
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
|
return draft
|
|
return None
|
|
|
|
async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False):
|
|
draft.missing = missing
|
|
if 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):
|
|
if draft_id in MEM["drafts"]:
|
|
MEM["drafts"][draft_id].status = status
|
|
|
|
async def expire_stale(ttl_hours):
|
|
return 0
|
|
|
|
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
|
|
|
|
async def link_get(self, chat_id):
|
|
return MEM["links"].get(chat_id)
|
|
|
|
async def link_upsert(self, chat_id, user_id, username, display_name, role):
|
|
MEM["links"][chat_id] = types.SimpleNamespace(chat_id=chat_id, user_id=user_id,
|
|
casdoor_username=username,
|
|
display_name=display_name, role=role)
|
|
return MEM["links"][chat_id]
|
|
|
|
async def link_delete(self, chat_id):
|
|
return MEM["links"].pop(chat_id, None) is not None
|
|
|
|
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
|
|
|
|
|
|
async def fake_log(self, chat_id, direction, kind, body=None):
|
|
pass
|
|
|
|
|
|
NLU_QUEUE: list[dict] = []
|
|
|
|
|
|
async def fake_understand(settings, text, draft_summary=None):
|
|
return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0}
|
|
|
|
|
|
async def fake_transcribe(settings, audio, mime_type=None, filename=None):
|
|
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
|
|
"پرداخت سه فاز")
|
|
|
|
|
|
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None):
|
|
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# driver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_ctx():
|
|
import app.handlers as H
|
|
from app.resolve import Resolver
|
|
from app.runtime import BotContext
|
|
|
|
bale, api = FakeBale(), FakeApi()
|
|
ctx = BotContext(bale, api)
|
|
ctx.bale = bale
|
|
ctx.api = api
|
|
ctx.resolver = Resolver(api)
|
|
ctx._log = types.MethodType(fake_log, ctx)
|
|
H.understand = fake_understand
|
|
H.transcribe = fake_transcribe
|
|
H.transcribe_multi = fake_transcribe_multi
|
|
return ctx, bale, api
|
|
|
|
|
|
def text_update(update_id, text, reply_to=None, chat_id=None, from_user=None):
|
|
cid = chat_id or CHAT
|
|
fuser = from_user or {"id": cid, "first_name": "مدیر تدارکات", "username": "proc_manager"}
|
|
msg = {"message_id": update_id, "chat": {"id": cid}, "text": text, "from": fuser}
|
|
if reply_to:
|
|
msg["reply_to_message"] = {"message_id": reply_to}
|
|
return {"update_id": update_id, "message": msg}
|
|
|
|
|
|
def voice_update(update_id):
|
|
return {"update_id": update_id,
|
|
"message": {"chat": {"id": CHAT}, "voice": {"file_id": "v1", "mime_type": "audio/ogg", "duration": 6}}}
|
|
|
|
|
|
def callback_update(update_id, data):
|
|
return {"update_id": update_id, "callback_query": {"id": f"cb{update_id}", "data": data,
|
|
"message": {"chat": {"id": CHAT}}}}
|
|
|
|
|
|
async def main() -> int:
|
|
_install_fake_stores()
|
|
from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload
|
|
import app.handlers as H
|
|
from app.nlu import _extract_json, normalize_digits, understand
|
|
from app.config import settings
|
|
|
|
# ---- pure logic -------------------------------------------------------
|
|
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
|
|
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
|
|
|
|
# ---- multi-STT understand pipeline support ---------------------------
|
|
multi_stt_samples = [
|
|
{"name": "whisper", "text": "25 کیسه گچ از مصطفوی"},
|
|
{"name": "vosk", "text": "بیست و پنج کیسه گچ از مصطفوی"},
|
|
]
|
|
stt_res = await understand(settings, multi_stt_samples)
|
|
check("multi-stt: pipeline accepted list input", isinstance(stt_res, dict) and "consensus_text" in stt_res)
|
|
check("multi-stt: consensus text generated", bool(stt_res.get("consensus_text")))
|
|
|
|
payload = new_quote_payload()
|
|
merge_quote_patch(payload, {
|
|
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
|
"payment_terms": [{"label": "۳ فاز"}],
|
|
})
|
|
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: qty updated", payload["items"][0]["quantity"] == 600)
|
|
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
|
body, problems = build_quote_request(payload)
|
|
check("build: blocked without supplier", body is None)
|
|
|
|
# ---- pipeline: whitelist gate & unregistered blocking -----------------
|
|
ctx, bale, api = build_ctx()
|
|
UNAUTH_CHAT = 999999999
|
|
from app.handlers import UNREGISTERED_FILE
|
|
if UNREGISTERED_FILE.exists():
|
|
try:
|
|
UNREGISTERED_FILE.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
# 1. Unregistered user is blocked and recorded
|
|
await H.handle_update(
|
|
ctx,
|
|
text_update(
|
|
1,
|
|
"سلام من متقاضی جدید هستم",
|
|
chat_id=UNAUTH_CHAT,
|
|
from_user={"id": UNAUTH_CHAT, "first_name": "کاربر متفرقه", "username": "intruder"},
|
|
),
|
|
)
|
|
check("gate: unauthorized user blocked", "دسترسی شما هنوز تعریف نشده است" in bale.last_text())
|
|
|
|
import json
|
|
unreg_records = json.loads(UNREGISTERED_FILE.read_text(encoding="utf-8")) if UNREGISTERED_FILE.exists() else []
|
|
check(
|
|
"gate: unregistered logged to JSON file",
|
|
any(u.get("chat_id") == UNAUTH_CHAT and u.get("first_name") == "کاربر متفرقه" for u in unreg_records),
|
|
)
|
|
|
|
# 2. Whitelisted user (1461166742) bypasses Casdoor completely
|
|
await H.handle_update(ctx, text_update(2, "/whoami"))
|
|
check("gate: whitelisted user active without casdoor", "مدیر تدارکات" in bale.last_text())
|
|
check("gate: no casdoor login prompted", "نام کاربری Casdoor" not in bale.last_text())
|
|
|
|
# ---- voice → draft quote ----------------------------------------------
|
|
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
|
|
"supplier_name": "سازان نو", "project_name": "برج آسمان",
|
|
"items": [{"material_name": "میلگرد", "quantity": 500, "unit_name": "کیلوگرم",
|
|
"unit_price": 45000}],
|
|
"payment_terms": [{"label": "۳ فاز", "description": "سه قسط"}],
|
|
}})
|
|
await H.handle_update(ctx, voice_update(4))
|
|
draft = await _active("quote")
|
|
check("voice: draft created", draft is not None)
|
|
check("voice: supplier resolved", draft.payload.get("supplier_id") == "sup-1")
|
|
check("voice: project resolved", draft.payload.get("project_id") == "prj-1")
|
|
item = (draft.payload.get("items") or [{}])[0]
|
|
check("voice: material ambiguous → pick buttons", "mat1" in (draft.payload.get("options") or {}))
|
|
check("voice: review shows missing list", "وضعیت فیلدها" in bale.last_text())
|
|
check("voice: review mentions unit", "کیلوگرم" in bale.last_text())
|
|
|
|
# pick the material via inline button
|
|
markup = bale.last_markup() or {}
|
|
pick_data = _first_callback(markup, "pk:")
|
|
check("voice: pick callback present", pick_data is not None and ":mat1:0" in pick_data)
|
|
await H.handle_update(ctx, callback_update(5, pick_data))
|
|
item = (draft.payload.get("items") or [{}])[0]
|
|
check("pick: material resolved", item.get("material_id") == "mat-1")
|
|
check("pick: unit resolved from spoken name", str(item.get("unit_id")) == "3" and item.get("unit_explicit") is True)
|
|
|
|
# refine price via follow-up text
|
|
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
|
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
|
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
|
|
draft = await _active("quote")
|
|
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
|
|
|
# commit via ✅
|
|
await H.handle_update(ctx, callback_update(7, f"q:ok:{draft.id[:8]}"))
|
|
check("commit: quote posted", len(api.created_quotes) == 1)
|
|
body = api.created_quotes[0]
|
|
check("commit: supplier id", body.get("supplier_id") == "sup-1")
|
|
check("commit: line item temp/refs", body["line_items"][0]["temp_id"] == "t1"
|
|
and body["item_termsheets"][0]["line_item_temp_id"] == "t1")
|
|
check("commit: price linked", body["item_termsheets"][0]["unit_price"] == 47000)
|
|
check("commit: termsheet carried", body["termsheets"][0]["label"] == "۳ فاز")
|
|
check("commit: report sent", "ثبت شد" in bale.last_text())
|
|
check("commit: draft committed", (await _active("quote")) is None)
|
|
|
|
# ---- add supplier ------------------------------------------------------
|
|
NLU_QUEUE.append({"intent": "add_supplier", "confidence": 0.9, "patch": {
|
|
"supplier": {"name": "پروفیل بر", "category_name": "لوله و اتصالات", "phone": "0912"}}})
|
|
await H.handle_update(ctx, text_update(8, "تأمینکننده جدید پروفیل بر دسته لوله و اتصالات تلفن 0912"))
|
|
draft = await _active("supplier")
|
|
check("supplier: draft created", draft is not None)
|
|
check("supplier: category resolved", draft.payload.get("category_id") == "cat-2")
|
|
await H.handle_update(ctx, callback_update(9, f"s:ok:{draft.id[:8]}"))
|
|
check("supplier: posted", api.created_suppliers and api.created_suppliers[0]["name"] == "پروفیل بر")
|
|
check("supplier: report", "تأمینکننده ثبت شد" in bale.last_text())
|
|
|
|
# ---- status query ------------------------------------------------------
|
|
NLU_QUEUE.append({"intent": "status_query", "confidence": 0.9,
|
|
"patch": {"query": {"kind": "pending"}}})
|
|
await H.handle_update(ctx, text_update(10, "استعلامهای در انتظار چیه؟"))
|
|
check("query: pending digest", "در انتظار بررسی" in bale.last_text() and "#q-aaaa11" in bale.last_text())
|
|
|
|
# ---- accept flow -------------------------------------------------------
|
|
NLU_QUEUE.append({"intent": "accept_quote", "confidence": 0.9,
|
|
"patch": {"target": {"supplier_name": "سازان نو"}}})
|
|
await H.handle_update(ctx, text_update(11, "استعلام سازان نو را قبول کن"))
|
|
check("accept: summary shown", "پذیرش استعلام" in bale.last_text())
|
|
flow = ctx.pending.get(CHAT)
|
|
check("accept: pending flow", flow is not None and flow.kind == "accept")
|
|
await H.handle_update(ctx, callback_update(12, "ac:ok"))
|
|
check("accept: payload posted", len(api.accept_payloads) == 1)
|
|
payload = api.accept_payloads[0]["payload"]
|
|
check("accept: termsheet echoed with steps",
|
|
payload["accepted_termsheets"][0]["id"] == "ts-1" and payload["accepted_termsheets"][0]["schedule_steps"])
|
|
check("accept: decision price", payload["item_decisions"][0]["adjusted_unit_price"] == 45000)
|
|
check("accept: decision index", payload["item_decisions"][0]["accepted_termsheet_index"] == 0)
|
|
check("accept: report", "پذیرش انجام شد" in bale.last_text())
|
|
|
|
# ---- arrive flow -------------------------------------------------------
|
|
NLU_QUEUE.append({"intent": "arrive_items", "confidence": 0.9,
|
|
"patch": {"target": {"supplier_name": "سازان نو"}}})
|
|
await H.handle_update(ctx, text_update(13, "اقلام سازان نو رسید"))
|
|
check("arrive: items shown", "اعلام رسیدن" in bale.last_text())
|
|
await H.handle_update(ctx, text_update(14, "۵۰۰ و ۲۰۰"))
|
|
flow = ctx.pending.get(CHAT)
|
|
check("arrive: quantities parsed", flow is not None and flow.kind == "arrive_confirm"
|
|
and flow.data["quantities"] == {"li-1": 500.0, "li-2": 200.0})
|
|
await H.handle_update(ctx, callback_update(15, "ar:ok"))
|
|
check("arrive: payload posted", len(api.arrive_payloads) == 1
|
|
and api.arrive_payloads[0]["payload"]["items"][0]["received_qty"] == 500)
|
|
check("arrive: report", "ثبت رسید انجام شد" in bale.last_text())
|
|
|
|
# ---- add material ------------------------------------------------------
|
|
NLU_QUEUE.append({"intent": "add_material", "confidence": 0.9,
|
|
"patch": {"material": {"name": "میلگرد A3 سایز ۱۸", "unit_name": "کیلوگرم"}}})
|
|
await H.handle_update(ctx, text_update(16, "کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم"))
|
|
draft = await _active("material")
|
|
check("material: draft created", draft is not None)
|
|
check("material: unit resolved", str(draft.payload.get("canonical_unit_id")) == "3")
|
|
await H.handle_update(ctx, callback_update(17, f"m:ok:{draft.id[:8]}"))
|
|
check("material: posted", api.created_materials
|
|
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 --------------------------------------------------
|
|
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
|
await H.handle_update(ctx, text_update(22, "استعلام از سازان نو"))
|
|
await H.handle_update(ctx, text_update(23, "/cancel"))
|
|
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
|
|
|
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
|
await H.handle_update(ctx, text_update(24, "اسپاگتی بپز"))
|
|
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
|
|
|
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
|
return 1 if _failed else 0
|
|
|
|
|
|
async def _active(kind):
|
|
from app import drafts as draft_store
|
|
return await draft_store.get_active(CHAT, kind)
|
|
|
|
|
|
def _first_callback(markup: dict | None, prefix: str) -> str | None:
|
|
for row in (markup or {}).get("inline_keyboard") or []:
|
|
for btn in row:
|
|
if btn.get("callback_data", "").startswith(prefix):
|
|
return btn["callback_data"]
|
|
return None
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(asyncio.run(main()))
|