feat(agent): integrate procurement AI agent with Bale bot, real-time draft sync, and git history bundle

This commit is contained in:
2026-09-21 20:23:13 +03:30
parent 4cfb9ec6f6
commit 9e19be8354
44 changed files with 4582 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Bale Bot API — token from Bale's BotFather (required)
PROCUREMENT_BOT_BOT_TOKEN=
PROCUREMENT_BOT_BALE_API_BASE=https://tapi.bale.ai
# ERP procurement API (in Docker: http://api:8000/api/v1)
PROCUREMENT_BOT_API_BASE_URL=http://localhost:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/procurement
# LLM provider — any OpenAI-compatible endpoint (JSON-mode chat completions)
PROCUREMENT_BOT_AI_BASE_URL=https://api.deepseek.com
PROCUREMENT_BOT_AI_API_KEY=
PROCUREMENT_BOT_LLM_MODEL=deepseek-flash
# STT provider — must expose POST /audio/transcriptions.
# Falls back to AI_* when unset. DeepSeek has no audio API, so voice needs
# its own whisper-compatible provider (OpenAI, GLM, self-hosted faster-whisper…).
PROCUREMENT_BOT_STT_BASE_URL=
PROCUREMENT_BOT_STT_API_KEY=
PROCUREMENT_BOT_STT_MODEL=whisper-1
PROCUREMENT_BOT_STT_LANGUAGE=fa
# Bale chat ids allowed without Casdoor linking (comma separated)
PROCUREMENT_BOT_ADMIN_CHAT_IDS=
# Optional bearer for the day the ERP starts enforcing auth on procurement routes
PROCUREMENT_BOT_SERVICE_TOKEN=
# Drafts older than this are swept into `expired`
PROCUREMENT_BOT_DRAFT_TTL_HOURS=24
PROCUREMENT_BOT_LOG_LEVEL=INFO
+14
View File
@@ -0,0 +1,14 @@
# ---- Env / secrets ----
.env
*.local
# ---- Python ----
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# ---- OS junk ----
.DS_Store
Thumbs.db
+23
View File
@@ -0,0 +1,23 @@
# Bale procurement bot — a long-polling worker against tapi.bale.ai.
# ffmpeg converts Bale voice notes (OGG/Opus) before they are sent to STT.
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv/bot
# Dependencies first (layer caching). PIP_INDEX_URL overridable like the api image.
ARG PIP_INDEX_URL=https://pypi.org/simple
COPY requirements.txt .
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
# Application
COPY app ./app
COPY tools ./tools
CMD ["python", "-m", "app.main"]
+120
View File
@@ -0,0 +1,120 @@
# procurement_ai_agent — Bale chatbot for the procurement ERP
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
into procurement actions in the
[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) —
draft-first: it captures what it understood, tells you **what it did** and
**what is still empty**, and **commits only after you approve** with a ✅ button.
```
you (Bale, text/voice) ┌─────────────────────────────────┐
│ getUpdates (long poll) │ procurement_ai_agent │
▼ │ ffmpeg → STT (Persian) │
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│
└───────────────┬─────────────────┘
│ HTTP + X-Actor-Id
▼
┌─────────────────────────────────┐
│ ERP api (/api/v1) │
│ quotes · suppliers · materials│
└─────────────────────────────────┘
```
## What it can do
| Activity | Example (voice or text) | Committed via |
|---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
### The draft → approve loop
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM
extracts a typed **intent + entities** (JSON mode, Persian digits
normalized).
2. Names are fuzzy-matched against the ERP. Several candidates → inline
**pick buttons**; no match → listed as missing.
3. The bot replies with a **review card**: everything captured + a
✅/⚠️/❌ list of what is still empty.
4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and
re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
5. **Nothing is written to the ERP until you tap ✅.** After commit you get the
created quote id and the fields left empty.
### Team access & attribution
Each member links their Bale chat once: `/start` → Casdoor username → password
(validated via the ERP's real `POST /auth/login`, used once, never stored).
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
records the **human** who asked.
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink`
## Quick start (Docker)
```bash
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY
docker build -t procurement-ai-agent .
docker run -d --name procurement-bot --env-file .env \
--add-host host.docker.internal:host-gateway \
procurement-ai-agent
docker logs -f procurement-bot
```
Or as part of the ERP stack — see [`integration/`](integration/README.md)
(compose service + the one tiny ERP-side patch).
Full setup, env-var reference, flows and troubleshooting:
**[docs/SETUP.md](docs/SETUP.md)**.
## Architecture
```
app/
├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop
├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files)
├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope)
├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions
├── nlu.py Persian intent+entity extraction (JSON-mode chat completions)
├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches
├── drafts.py draft store · QuoteCreate payload builder · missing-field logic
├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
├── texts.py Persian message templates
├── runtime.py context, link store, in-memory pending flows
├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema)
└── handlers/ dispatch · commands · callbacks · links ·
quote · supplier · material · queries · decisions
```
Design notes:
- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling —
no public port, no webhook, resumable offset persisted across restarts.
- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is
converted into the exact `POST /quotes` request body (temp_id
cross-references) only on approval; the API deliberately tolerates
incomplete quotes, so partial commits are explicit, never accidental.
- **Accept flow echoes the termsheet's existing `schedule_steps`** back to
`PATCH /quotes/{id}/accept` (the API replaces steps with what it receives).
- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding,
Arabic ya/ka folding, difflib ranking on top of the API's ilike search.
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before
STT. The LLM provider and the STT provider are configured independently
(e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice).
## Testing
An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler
pipeline with fake updates — no token, DB or API key needed:
```bash
pip install -r requirements.txt
python tools/smoke_test.py # → ALL PASSED: 48 passed
```
+2
View File
@@ -0,0 +1,2 @@
"""Bale procurement chatbot — drafts ERP actions from chat text/voice,
reports what it captured + what is missing, and commits only on approval."""
+1
View File
@@ -0,0 +1 @@
"""ERP API client — the only way the bot touches procurement data."""
+137
View File
@@ -0,0 +1,137 @@
"""Async client for the procurement API (/api/v1).
Attribution: every call carries ``X-Actor-Id`` (the linked ERP user id) so
``quote_events.actor`` records the human who asked, not the bot. A service
bearer token is sent when configured (procurement endpoints are currently
open — the slot is for the day auth lands).
"""
import logging
from typing import Any
import httpx
logger = logging.getLogger("bot.api")
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str, field: str | None = None):
super().__init__(message)
self.status = status
self.code = code
self.message = message
self.field = field
class ErpClient:
def __init__(self, base_url: str, timeout: float = 30.0, service_token: str = ""):
self._base = base_url.rstrip("/")
self._timeout = timeout
self._service_token = service_token
def _actor_headers(self, actor_id: str | None, actor_name: str | None) -> dict:
headers: dict[str, str] = {}
if self._service_token:
headers["Authorization"] = f"Bearer {self._service_token}"
if actor_id:
headers["X-Actor-Id"] = actor_id
return headers
async def _request(self, method: str, path: str, *, actor_id: str | None = None,
json: Any = None, params: dict | None = None) -> Any:
url = f"{self._base}{path}"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
resp = await client.request(
method, url, json=json, params=params, headers=self._actor_headers(actor_id, None)
)
except httpx.HTTPError as exc:
raise ApiError(0, "NETWORK", f"ERP API unreachable: {exc}") from exc
if resp.status_code in (200, 201):
return resp.json()
try:
err = resp.json().get("error") or {}
except ValueError:
err = {}
raise ApiError(
resp.status_code,
str(err.get("code") or "INTERNAL_ERROR"),
str(err.get("message") or f"HTTP {resp.status_code}"),
err.get("field"),
)
# ------------------------------------------------------------------ auth
async def login(self, username: str, password: str) -> dict:
body = await self._request("POST", "/auth/login", json={"username": username, "password": password})
return body.get("data") or {}
# ---------------------------------------------------------------- lookup
async def list_suppliers(self, q: str | None = None, page_size: int = 20) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {}
async def list_materials(self, q: str | None = None, page_size: int = 20) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/materials", params=params)).get("data") or []
async def create_material(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/materials", json=payload, actor_id=actor_id)).get("data") or []
async def list_units(self, q: str | None = None, page_size: int = 100) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/units", params=params)).get("data") or []
async def list_projects(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
async def list_categories(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
# ---------------------------------------------------------------- quotes
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
expand: str | None = None, sort: str | None = None,
page: int = 1, page_size: int = 10) -> tuple[list[dict], dict]:
params: dict = {"page": page, "page_size": page_size}
if status:
params["status"] = status
if q:
params["q"] = q
if expand:
params["expand"] = expand
if sort:
params["sort"] = sort
body = await self._request("GET", "/quotes", params=params)
return body.get("data") or [], body.get("meta") or {}
async def get_quote(self, quote_id: str, expand: str | None = None, actor_id: str | None = None) -> dict:
params = {"expand": expand} if expand else None
return (await self._request("GET", f"/quotes/{quote_id}", params=params, actor_id=actor_id)).get("data") or {}
async def create_quote(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/quotes", json=payload, actor_id=actor_id)).get("data") or {}
async def accept_quote(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
path = f"/quotes/{quote_id}/accept"
return (await self._request("PATCH", path, json=payload, actor_id=actor_id)).get("data") or {}
async def arrive_quote(self, quote_id: str, actor_id: str | None = None) -> dict:
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive", actor_id=actor_id)).get("data") or {}
async def arrive_items(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive-items", json=payload, actor_id=actor_id)).get("data") or {}
async def reject_quote(self, quote_id: str, reason: str | None = None, actor_id: str | None = None) -> dict:
return (await self._request(
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
)).get("data") or {}
+1
View File
@@ -0,0 +1 @@
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
+175
View File
@@ -0,0 +1,175 @@
"""Thin async client for the Bale Bot API.
Bale's bot surface is a Telegram Bot API clone: ``POST {base}/bot{token}/{method}``
and file downloads at ``{base}/file/bot{token}/{file_path}``. Long polling via
``getUpdates`` avoids any webhook/TLS setup on the server.
HTML parse_mode is requested first; if Bale rejects it for a given payload we
transparently retry as plain text so messages never get lost.
"""
import asyncio
import logging
import httpx
logger = logging.getLogger("bot.bale")
REPLY_MARKUP = "reply_markup"
class BaleError(Exception):
def __init__(self, description: str, error_code: int | None = None):
super().__init__(description)
self.description = description
self.error_code = error_code
class BaleClient:
def __init__(self, base_url: str, token: str, timeout: float = 60.0):
self._base = base_url.rstrip("/")
self._token = token
self._timeout = timeout
# ------------------------------------------------------------------ core
def _method_url(self, method: str) -> str:
return f"{self._base}/bot{self._token}/{method}"
def _file_url(self, file_path: str) -> str:
return f"{self._base}/file/bot{self._token}/{file_path.lstrip('/')}"
async def _call(self, method: str, payload: dict | None = None, timeout: float | None = None) -> dict:
async with httpx.AsyncClient(timeout=timeout or self._timeout) as client:
try:
resp = await client.post(self._method_url(method), json=payload or {})
except httpx.HTTPError as exc:
raise BaleError(f"network error calling {method}: {exc}") from exc
try:
body = resp.json()
except ValueError as exc:
raise BaleError(f"non-JSON response for {method} (HTTP {resp.status_code})") from exc
if not body.get("ok"):
raise BaleError(str(body.get("description") or "unknown bale error"), resp.status_code)
return body.get("result")
# ------------------------------------------------------------- receiving
async def get_updates(self, offset: int, timeout_seconds: int) -> list[dict]:
"""Long poll. ``timeout_seconds=0`` returns immediately (drain mode)."""
result = await self._call(
"getUpdates",
{"offset": offset, "timeout": timeout_seconds, "allowed_updates": ["message", "callback_query"]},
timeout=timeout_seconds + 15,
)
return result or []
# -------------------------------------------------------------- sending
async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None,
reply_to: int | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "text": text[:4000], "parse_mode": "HTML"}
if reply_markup:
payload[REPLY_MARKUP] = reply_markup
if reply_to:
payload["reply_to_message_id"] = reply_to
try:
return await self._call("sendMessage", payload)
except BaleError:
# parse_mode / markup rejection fallback: retry plain, keep the keyboard.
payload.pop("parse_mode", None)
return await self._call("sendMessage", payload)
async def edit_message_text(self, chat_id: int, message_id: int, text: str,
reply_markup: dict | None = None) -> dict:
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000], "parse_mode": "HTML"}
if reply_markup:
payload[REPLY_MARKUP] = reply_markup
try:
return await self._call("editMessageText", payload)
except BaleError:
payload.pop("parse_mode", None)
return await self._call("editMessageText", payload)
async def answer_callback_query(self, callback_query_id: str, text: str | None = None) -> None:
payload: dict = {"callback_query_id": callback_query_id}
if text:
payload["text"] = text[:200]
try:
await self._call("answerCallbackQuery", payload)
except BaleError as exc:
logger.warning("answerCallbackQuery failed: %s", exc)
async def send_chat_action(self, chat_id: int, action: str = "typing") -> None:
try:
await self._call("sendChatAction", {"chat_id": chat_id, "action": action})
except BaleError as exc:
logger.debug("sendChatAction failed: %s", exc)
# ---------------------------------------------------------------- files
async def get_file(self, file_id: str) -> dict:
return await self._call("getFile", {"file_id": file_id})
async def download_file(self, file_id: str) -> bytes:
"""Resolve a file_id to bytes (getFile → file_path → download URL)."""
info = await self.get_file(file_id)
file_path = info.get("file_path")
if not file_path:
raise BaleError("getFile returned no file_path")
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.get(self._file_url(file_path))
resp.raise_for_status()
return resp.content
async def poll_loop(client: BaleClient, on_update, offset_key: str = "bale_offset") -> None:
"""Long-poll supervisor: resumable offset, one update at a time, never dies.
``on_update`` must be an async callable receiving the raw update dict.
"""
from app.db import SessionLocal
from app.models import BotMeta
async def load_offset() -> int:
async with SessionLocal() as session:
row = (await session.execute(
__import__("sqlalchemy").select(BotMeta.value).where(BotMeta.key == offset_key)
)).scalar()
return int(row) if row else 0
async def save_offset(value: int) -> None:
from sqlalchemy.dialects.postgresql import insert as pg_insert
async with SessionLocal() as session:
stmt = pg_insert(BotMeta).values(key=offset_key, value=str(value))
stmt = stmt.on_conflict_do_update(index_elements=[BotMeta.key], set_={"value": str(value)})
await session.execute(stmt)
await session.commit()
offset = await load_offset()
if offset == 0:
# Skip any backlog accumulated while the bot was down: peek once
# without waiting and jump straight to the newest update.
backlog = await client.get_updates(0, 0)
if backlog:
offset = max(u["update_id"] for u in backlog) + 1
logger.info("polling started (offset=%s)", offset)
while True:
try:
updates = await client.get_updates(offset, 25)
for update in updates:
offset = max(offset, update["update_id"] + 1)
await save_offset(offset)
try:
await on_update(update)
except Exception:
logger.exception("handler crashed on update %s", update.get("update_id"))
except asyncio.CancelledError:
raise
except BaleError as exc:
logger.warning("poll error: %s", exc)
await asyncio.sleep(3)
except Exception:
logger.exception("unexpected poll error")
await asyncio.sleep(3)
+77
View File
@@ -0,0 +1,77 @@
"""Bot configuration.
All settings can be overridden with environment variables prefixed with
``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local
``.env`` file. Docker/compose passes them in directly (same convention as
the api service's ``PROCUREMENT_*`` prefix).
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="PROCUREMENT_BOT_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai.
BALE_API_BASE: str = "https://tapi.bale.ai"
BOT_TOKEN: str = "" # from Bale's BotFather
POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window
POLL_ERROR_SLEEP_SECONDS: float = 3.0
# ERP API (in-network service URL inside Docker).
API_BASE_URL: str = "http://localhost:8000/api/v1"
API_TIMEOUT_SECONDS: float = 30.0
# Optional bearer for the day procurement endpoints start enforcing auth.
SERVICE_TOKEN: str = ""
# Frontend URL (for opening Wizard from Bale bot)
FRONTEND_URL: str = "http://localhost:8081"
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
AI_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
LLM_TIMEOUT_SECONDS: float = 60.0
# STT provider — any OpenAI-compatible /audio/transcriptions endpoint.
# Falls back to the LLM provider when unset. Note: some providers
# (e.g. DeepSeek) have no audio API — voice stays disabled gracefully.
STT_BASE_URL: str = ""
STT_API_KEY: str = ""
STT_MODEL: str = "whisper-1"
STT_LANGUAGE: str = "fa"
@property
def stt_base_url(self) -> str:
return self.STT_BASE_URL or self.AI_BASE_URL
@property
def stt_api_key(self) -> str:
return self.STT_API_KEY or self.AI_API_KEY
# PostgreSQL — same instance/database as the api service.
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
# Chats that may use the bot without Casdoor linking (comma separated).
ADMIN_CHAT_IDS: str = ""
# Drafts older than this are marked expired by the sweeper loop.
DRAFT_TTL_HOURS: int = 24
LOG_LEVEL: str = "INFO"
@property
def admin_chat_ids(self) -> set[int]:
out: set[int] = set()
for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","):
part = part.strip()
if part.isdigit():
out.add(int(part))
return out
settings = Settings()
+7
View File
@@ -0,0 +1,7 @@
"""Async engine/session for the bot's own tables (same postgres as the api)."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+77
View File
@@ -0,0 +1,77 @@
"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py
convention — the ORM never creates tables; this script does, IF NOT EXISTS).
"""
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Base
logger = logging.getLogger("bot.db_init")
DDL = [
"""
CREATE TABLE IF NOT EXISTS bot_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT UNIQUE NOT NULL,
user_id UUID,
casdoor_username TEXT,
display_name TEXT,
role VARCHAR(32),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)",
"""
CREATE TABLE IF NOT EXISTS bot_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
missing JSONB,
status VARCHAR(16) NOT NULL DEFAULT 'active',
source VARCHAR(16),
review_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
"""
CREATE TABLE IF NOT EXISTS bot_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
direction VARCHAR(4) NOT NULL,
kind VARCHAR(16) NOT NULL DEFAULT 'text',
body TEXT,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)",
"""
CREATE TABLE IF NOT EXISTS bot_meta (
key VARCHAR(64) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
]
async def ensure_bot_tables(session: AsyncSession) -> None:
for stmt in DDL:
await session.execute(text(stmt))
await session.commit()
logger.info("bot tables ready")
async def ensure_schema() -> None:
from app.db import SessionLocal
async with SessionLocal() as session:
await ensure_bot_tables(session)
+443
View File
@@ -0,0 +1,443 @@
"""Draft store + payload/missing-field logic.
A draft's ``payload`` is a talking-friendly JSON the bot keeps refining. When
the user approves, ``build_quote_payload`` converts it into the exact
``POST /quotes`` request body (temp_ids cross-referencing line items,
termsheets and prices).
Resolution state lives inside the payload:
payload["supplier_id"], payload["project_id"],
item["material_id"], item["unit_id"], item["unit_explicit"],
payload["options"][field] = candidate list shown as pick-buttons.
"""
import difflib
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import cast, func, select, String, update
from app.db import SessionLocal
from app.models import BotDraft
from app.nlu import normalize_text
KIND_QUOTE = "quote"
KIND_SUPPLIER = "supplier"
KIND_MATERIAL = "material"
# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------
async def create(chat_id: int, kind: str, payload: dict, source: str | None = None) -> BotDraft:
async with SessionLocal() as session:
# one active draft per (chat, kind)
await session.execute(
update(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.kind == kind, BotDraft.status == "active")
.values(status="superseded")
)
draft = BotDraft(chat_id=chat_id, kind=kind, payload=payload, source=source)
session.add(draft)
await session.commit()
await session.refresh(draft)
return draft
async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None:
async with SessionLocal() as session:
stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
if kind:
stmt = stmt.where(BotDraft.kind == kind)
stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None:
"""Resolve the short id used in callback_data (first 8 hex of the UUID)."""
async with SessionLocal() as session:
stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%"))
if chat_id is not None:
stmt = stmt.where(BotDraft.chat_id == chat_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def save_payload(draft: BotDraft, missing: dict | None = None,
review_message_id: int | None = None) -> None:
async with SessionLocal() as session:
await session.execute(
update(BotDraft)
.where(BotDraft.id == draft.id)
.values(payload=draft.payload, missing=missing, review_message_id=review_message_id or draft.review_message_id)
)
await session.commit()
async def set_status(draft_id: str, status: str) -> None:
async with SessionLocal() as session:
await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status))
await session.commit()
async def set_bale_updated_at(draft_id) -> None:
async with SessionLocal() as session:
await session.execute(
update(BotDraft)
.where(BotDraft.id == draft_id)
.values(last_bale_update_at=func.now())
)
await session.commit()
async def get_pending_bale_updates() -> list[BotDraft]:
async with SessionLocal() as session:
stmt = (
select(BotDraft)
.where(
BotDraft.review_message_id.is_not(None),
(BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at),
)
.order_by(BotDraft.updated_at.asc())
.limit(10)
)
return list((await session.execute(stmt)).scalars().all())
async def expire_stale(ttl_hours: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours)
async with SessionLocal() as session:
result = await session.execute(
update(BotDraft)
.where(BotDraft.status == "active", BotDraft.updated_at < cutoff)
.values(status="expired")
)
await session.commit()
return result.rowcount or 0
# ---------------------------------------------------------------------------
# Quote payload
# ---------------------------------------------------------------------------
def new_quote_payload() -> dict:
return {
"supplier_name": None, "supplier_id": None,
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
"is_official": True, "notes": None, "expiry_date": None,
"items": [], "termsheets": [],
"options": {}, "proforma_file": None,
}
def new_item(material_name: str | None = None) -> dict:
return {
"material_name": material_name, "material_id": None,
"quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False,
"unit_price": None, "brand_name": None, "specs": [], "notes": None,
}
def _similarity(a: str | None, b: str | None) -> float:
na, nb = normalize_text(a or ""), normalize_text(b or "")
if not na or not nb:
return 0.0
ratio = difflib.SequenceMatcher(None, na, nb).ratio()
if na in nb or nb in na:
ratio += 0.3
return ratio
def merge_quote_patch(payload: dict, patch: dict) -> bool:
"""Merge an NLU patch into the payload. Returns True if anything changed."""
changed = False
def set_field(key: str, value) -> None:
nonlocal changed
if value is None:
return
if payload.get(key) != value:
payload[key] = value
changed = True
if patch.get("supplier_name"):
payload["supplier_name"] = str(patch["supplier_name"]).strip()
payload["supplier_id"] = None # needs re-resolution
changed = True
if patch.get("project_names"):
payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p]
if payload["project_names"] and not payload.get("project_name"):
payload["project_name"] = payload["project_names"][0]
payload["project_id"] = None
changed = True
elif patch.get("project_name"):
payload["project_name"] = str(patch["project_name"]).strip()
payload["project_id"] = None
if not payload.get("project_names"):
payload["project_names"] = [payload["project_name"]]
changed = True
set_field("is_official", patch.get("is_official"))
if patch.get("notes"):
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
changed = True
if patch.get("expiry_date"):
set_field("expiry_date", patch["expiry_date"])
for raw in patch.get("items") or []:
name = (raw.get("material_name") or "").strip() or None
target = None
if name:
best, best_ratio = None, 0.0
for item in payload["items"]:
# If both items have distinct specs, they are different line items
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
continue
# If both items have distinct brands, they are different line items
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
continue
ratio = _similarity(name, item.get("material_name"))
if ratio > best_ratio:
best, best_ratio = item, ratio
if best is not None and best_ratio >= 0.55:
target = best
if target is None:
target = new_item(name)
payload["items"].append(target)
changed = True
for key in ("quantity", "unit_price"):
if raw.get(key) is not None:
try:
value = float(raw[key])
except (TypeError, ValueError):
continue
if target.get(key) != value:
target[key] = value
changed = True
if raw.get("specs"):
target["specs"] = [str(s).strip() for s in raw["specs"] if s]
spec_str = "، ".join(target["specs"])
if spec_str and not target.get("notes"):
target["notes"] = spec_str
changed = True
for key in ("unit_name", "brand_name", "notes"):
if raw.get(key):
val = str(raw[key]).strip()
if target.get(key) != val:
target[key] = val
changed = True
for raw in patch.get("payment_terms") or []:
label = (raw.get("label") or "شرایط پرداخت").strip()
milestones = []
for m in raw.get("milestones") or []:
if m.get("due_date") or m.get("amount"):
milestones.append({
"due_date": m.get("due_date"),
"amount": float(m.get("amount") or 0),
"note": m.get("note"),
})
entry = {
"label": label,
"discount_type": raw.get("discount_type") or "none",
"discount_value": float(raw.get("discount_value") or 0),
"milestones": milestones,
"description": (raw.get("description") or "").strip() or None,
}
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
payload["termsheets"].append(entry)
changed = True
return changed
def compute_quote_missing(payload: dict) -> list[dict]:
"""What is still empty — the user's explicit ask («یادآوری چیزهای خالی»)."""
out: list[dict] = []
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
sup_ok = bool(payload.get("supplier_id"))
add("supplier", "تأمین‌کننده", sup_ok, True)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
items = payload.get("items") or []
add("items", "قلم (کالا/مقدار)", bool(items), True)
for i, item in enumerate(items, start=1):
name = item.get("material_name") or f"قلم {i}"
if not item.get("material_id"):
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_price"):
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)
return out
def quote_ready(payload: dict) -> tuple[bool, list[str]]:
"""Blocking check before commit; returns (ok, blocking_labels)."""
missing = compute_quote_missing(payload)
return all(not (m["blocking"] and not m["ok"]) for m in missing), [
m["label"] for m in missing if m["blocking"] and not m["ok"]
]
def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]:
"""Convert the payload into a QuoteCreate body. Returns (body, problems)."""
problems: list[str] = []
if not payload.get("supplier_id"):
return None, ["تأمین‌کننده مشخص نیست"]
items = payload.get("items") or []
line_items: list[dict] = []
for i, item in enumerate(items, start=1):
if not item.get("material_id"):
if only_resolved_items:
continue
problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد")
continue
if not item.get("quantity"):
problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
continue
unit_id = item.get("unit_id")
if not unit_id:
problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
continue
notes_parts = []
if item.get("brand_name"):
notes_parts.append(f"برند: {item['brand_name']}")
if item.get("specs"):
notes_parts.append("، ".join(item["specs"]))
if item.get("notes"):
if item["notes"] not in notes_parts:
notes_parts.append(item["notes"])
notes_str = " | ".join(notes_parts) if notes_parts else None
line_items.append({
"temp_id": f"t{i}",
"material_id": item["material_id"],
"unit_id": int(unit_id),
"quantity": item["quantity"],
"notes": notes_str,
})
termsheets: list[dict] = []
item_termsheets: list[dict] = []
priced = [it for it in items if it.get("unit_price") and it.get("material_id")]
if priced:
raw_sheets = payload.get("termsheets") or []
if not raw_sheets:
raw_sheets = [{"label": "پیشنهاد قیمت"}]
for j, ts in enumerate(raw_sheets, start=1):
steps = []
for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1):
steps.append({
"step_no": s_idx,
"due_date": m.get("due_date"),
"amount": float(m.get("amount") or 0),
"note": m.get("note"),
})
termsheets.append({
"temp_id": f"p{j}",
"label": ts.get("label") or f"شرایط {j}",
"discount_type": ts.get("discount_type") or "none",
"discount_value": float(ts.get("discount_value") or 0),
"schedule_steps": steps,
})
first = termsheets[0]["temp_id"]
for i, item in enumerate(items, start=1):
if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"):
item_termsheets.append({
"line_item_temp_id": f"t{i}",
"termsheet_temp_id": first,
"unit_price": item["unit_price"],
})
body: dict = {
"supplier_id": payload["supplier_id"],
"is_official": bool(payload.get("is_official", True)),
"line_items": line_items,
"termsheets": termsheets,
"item_termsheets": item_termsheets,
}
if payload.get("project_ids"):
body["project_ids"] = payload["project_ids"]
elif payload.get("project_id"):
body["project_ids"] = [payload["project_id"]]
if payload.get("expiry_date"):
body["expiry_date"] = payload["expiry_date"]
if payload.get("notes"):
body["notes"] = payload["notes"]
if not line_items and problems:
return None, problems
return body, problems
# ---------------------------------------------------------------------------
# Supplier / material payloads
# ---------------------------------------------------------------------------
def new_supplier_payload() -> dict:
return {"name": None, "category_id": None, "category_name": None,
"phone": None, "location": None, "contact_person": None, "options": {}}
def merge_supplier_patch(payload: dict, patch: dict) -> bool:
src = patch.get("supplier") or patch
changed = False
for key in ("name", "phone", "location", "contact_person"):
if src.get(key) and payload.get(key) != str(src[key]).strip():
payload[key] = str(src[key]).strip()
changed = True
if src.get("category_name"):
payload["category_name"] = str(src["category_name"]).strip()
payload["category_id"] = None
changed = True
return changed
def supplier_missing(payload: dict) -> list[dict]:
return [
{"key": "name", "label": "نام تأمین‌کننده", "ok": bool(payload.get("name")), "blocking": True},
{"key": "category", "label": "دسته‌بندی", "ok": bool(payload.get("category_id")), "blocking": True},
{"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False},
{"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False},
]
def new_material_payload() -> dict:
return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}}
def merge_material_patch(payload: dict, patch: dict) -> bool:
src = patch.get("material") 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("unit_name"):
payload["unit_name"] = str(src["unit_name"]).strip()
payload["canonical_unit_id"] = None
changed = True
if src.get("topic") and payload.get("topic") != str(src["topic"]).strip():
payload["topic"] = str(src["topic"]).strip()
changed = True
return changed
def material_missing(payload: dict) -> list[dict]:
return [
{"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True},
{"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True},
{"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False},
]
def short_id() -> str:
return uuid.uuid4().hex[:8]
+192
View File
@@ -0,0 +1,192 @@
"""Update dispatch — the heart of the bot.
Pipeline per update:
callback_query → callbacks router (link-gated)
message text starting "/" → command router
voice/audio → download → STT → treated as text (source=voice)
text → pending-flow interception (linking / accept-arrive confirmations)
→ auth gate (link or admin)
→ NLU → intent router
"""
import logging
from app import drafts as draft_store
from app.config import settings
from app.nlu import understand
from app.runtime import BotContext, LinkedUser
from app.stt import transcribe
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY
from . import commands, callbacks, quote, supplier, material, queries, decisions
logger = logging.getLogger("bot.handlers")
async def handle_update(ctx: BotContext, update: dict) -> None:
if update.get("callback_query"):
await callbacks.handle_callback(ctx, update["callback_query"])
return
message = update.get("message") or update.get("edited_message")
if not message:
return
chat_id = message["chat"]["id"]
try:
await handle_message(ctx, chat_id, message)
except Exception:
logger.exception("message handling failed for chat %s", chat_id)
await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.")
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
text = (message.get("text") or "").strip()
source = "text"
if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text)
return
if not text and (message.get("voice") or message.get("audio") or message.get("document")):
source = "voice"
await ctx.bale.send_chat_action(chat_id, "typing")
file_id = (message.get("voice") or message.get("audio") or message.get("document"))["file_id"]
try:
audio = await ctx.bale.download_file(file_id)
text = await transcribe(settings, audio,
mime_type=(message.get("voice") or {}).get("mime_type"),
filename=(message.get("voice") or {}).get("file_name"))
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)))
return
if not text:
await ctx.send(chat_id, VOICE_EMPTY)
return
await ctx._log(chat_id, "in", "voice", text)
# Pending multi-step interactions take the message first.
flow = ctx.pending.get(chat_id)
if flow:
handled = await _route_to_flow(ctx, chat_id, flow, text)
if handled:
return
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through
user = await _gate(ctx, chat_id, message)
if user is None:
return # gate either started linking or is mid-linking
draft = await draft_store.get_active(chat_id)
draft_summary = _draft_summary(draft)
result = await understand(settings, text, draft_summary)
intent, patch = result.get("intent"), result.get("patch") or {}
logger.info("chat %s intent=%s conf=%s", chat_id, intent, result.get("confidence"))
if intent in ("register_quote", "update_draft"):
await quote.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "commit_draft":
active = await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT)
elif active.kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, active)
elif intent == "discard_draft":
active = await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
elif intent == "show_draft":
active = await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT)
elif active.kind == draft_store.KIND_QUOTE:
await quote.render(ctx, chat_id, active, fresh=False)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.render(ctx, chat_id, active)
elif intent == "add_supplier":
await supplier.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "add_material":
await material.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "status_query":
await queries.handle_query(ctx, chat_id, user, patch)
elif intent == "accept_quote":
await decisions.start_accept(ctx, chat_id, user, patch)
elif intent == "arrive_items":
await decisions.start_arrive(ctx, chat_id, user, patch)
else:
await ctx.send(chat_id, UNKNOWN_HINT)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
"""Link or admin gate. Returns None when the message was consumed by linking."""
from . import links
link = await ctx.links.get(chat_id)
if link and link.user_id:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
if chat_id in ctx.admin_chat_ids:
return LinkedUser(chat_id, None, None, None, is_admin=True)
flow = ctx.pending.get(chat_id)
if flow and flow.kind == "link_password":
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
return None
if flow and flow.kind == "link_username":
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
return None
await ctx.send(chat_id, LINK_START)
ctx.pending.set(chat_id, "link_username", {})
return None
async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool:
from . import links, decisions
if flow.kind == "link_username":
await links.finish_username_step(ctx, chat_id, flow, text)
return True
if flow.kind == "link_password":
await links.finish_link(ctx, chat_id, flow, text=text, password=text)
return True
if flow.kind == "quote_pick":
await decisions.handle_quote_pick_text(ctx, chat_id, flow, text)
return True
if flow.kind in ("accept", "arrive_qty", "arrive_confirm"):
return await decisions.handle_flow_text(ctx, chat_id, flow, text)
return False
def _draft_summary(draft) -> str | None:
if draft is None:
return None
import json
payload = draft.payload or {}
if draft.kind == draft_store.KIND_QUOTE:
items = ", ".join(
f"{it.get('material_name') or '?'}"
+ (f" {it.get('quantity')}" if it.get("quantity") else "")
+ (f" {it.get('unit_name')}" if it.get("unit_name") else "")
for it in payload.get("items") or []
)
summary = (
f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; "
f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}"
)
elif draft.kind == draft_store.KIND_SUPPLIER:
summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}"
elif draft.kind == draft_store.KIND_MATERIAL:
summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}"
else:
summary = json.dumps(payload, ensure_ascii=False)[:500]
return summary
+107
View File
@@ -0,0 +1,107 @@
"""Inline-button routing (callback_query).
Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx}
/ ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows.
"""
import logging
from app import drafts as draft_store
from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc
from . import quote, supplier, material, decisions
logger = logging.getLogger("bot.callbacks")
async def handle_callback(ctx, cq: dict) -> None:
chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id")
data = cq.get("data") or ""
if chat_id is None:
return
try:
await _dispatch(ctx, chat_id, data, cq)
except Exception:
logger.exception("callback failed: %s", data)
await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.")
finally:
await ctx.bale.answer_callback_query(cq.get("id"), None)
async def _gate(ctx, chat_id: int) -> LinkedUser | None:
link = await ctx.links.get(chat_id)
if link and link.user_id:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
if chat_id in ctx.admin_chat_ids:
return LinkedUser(chat_id, None, None, None, is_admin=True)
await ctx.send(chat_id, UNLINKED)
return None
async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
user = await _gate(ctx, chat_id)
if user is None:
return
parts = data.split(":")
# ---- pending-flow buttons (accept / arrive / quote pick) ---------------
if parts[0] == "qc" and len(parts) == 2:
flow = ctx.pending.get(chat_id)
if flow and flow.kind == "quote_pick":
await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1]))
return
if parts[0] == "ac":
await decisions.handle_accept_callback(ctx, chat_id, user, parts)
return
if parts[0] == "ar":
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
return
# ---- pick a candidate for a draft field -------------------------------
if parts[0] == "pk" and len(parts) == 4:
draft = await draft_store.get_by_prefix(parts[1], chat_id)
if draft is None:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3]))
return
# ---- draft commit / cancel --------------------------------------------
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
draft = await draft_store.get_by_prefix(prefix, chat_id)
if draft is None or draft.kind != kind:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
if arg == "no":
await draft_store.set_status(draft.id, "cancelled")
ctx.pending.pop(chat_id, None)
if draft.review_message_id:
try:
await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیش‌نویس لغو شد.", reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
except Exception:
pass
await ctx.send(chat_id, CANCELLED)
return
if kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
elif kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, draft)
else:
await material.commit(ctx, chat_id, user, draft)
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
options = (draft.payload.get("options") or {}).get(field) or []
if idx < 0 or idx >= len(options):
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
choice = options[idx]
if draft.kind == draft_store.KIND_QUOTE:
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_SUPPLIER:
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
elif draft.kind == draft_store.KIND_MATERIAL:
await material.apply_pick(ctx, chat_id, user, draft, field, choice)
+82
View File
@@ -0,0 +1,82 @@
"""/commands — /start /help /drafts /cancel /whoami /unlink."""
import logging
from app import drafts as draft_store
from app.runtime import ROLE_FA
from app.texts import CANCELLED, DRAFTS_EMPTY, DRAFTS_LIST_HEADER, HELP, UNLINKED, WELCOME, esc
logger = logging.getLogger("bot.commands")
KIND_FA = {"quote": "استعلام", "supplier": "تأمین‌کننده", "material": "کالا"}
STATUS_FA = {"active": "باز", "committed": "ثبت‌شده", "cancelled": "لغوشده", "expired": "منقضی"}
async def handle_command(ctx, chat_id: int, text: str) -> None:
command = text.split()[0].split("@")[0].lower()
if command == "/start":
await _start(ctx, chat_id)
elif command in ("/help", "/راهنما"):
await ctx.send(chat_id, HELP)
elif command == "/drafts":
await _drafts(ctx, chat_id)
elif command in ("/cancel", "/لغو"):
await _cancel(ctx, chat_id)
elif command == "/whoami":
await _whoami(ctx, chat_id)
elif command == "/unlink":
await ctx.links.delete(chat_id)
await ctx.send(chat_id, "اتصال قطع شد. برای اتصال دوباره /start بزن.")
else:
await ctx.send(chat_id, HELP)
async def _start(ctx, chat_id: int) -> None:
await ctx.send(chat_id, WELCOME)
link = await ctx.links.get(chat_id)
if not link or not link.user_id:
if chat_id not in ctx.admin_chat_ids:
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
ctx.pending.set(chat_id, "link_username", {})
async def _drafts(ctx, chat_id: int) -> None:
from sqlalchemy import select
from app.db import SessionLocal
from app.models import BotDraft
async with SessionLocal() as session:
rows = (await session.execute(
select(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
.order_by(BotDraft.updated_at.desc())
)).scalars().all()
if not rows:
await ctx.send(chat_id, DRAFTS_EMPTY)
return
lines = [DRAFTS_LIST_HEADER]
for d in rows:
p = d.payload or {}
title = p.get("supplier_name") or p.get("name") or "بدون عنوان"
lines.append(f"• {KIND_FA.get(d.kind, d.kind)}: {esc(title)}")
await ctx.send(chat_id, "\n".join(lines))
async def _cancel(ctx, chat_id: int) -> None:
ctx.pending.pop(chat_id)
active = await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
await ctx.send(chat_id, CANCELLED)
async def _whoami(ctx, chat_id: int) -> None:
link = await ctx.links.get(chat_id)
if chat_id in ctx.admin_chat_ids:
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
return
if not link or not link.user_id:
await ctx.send(chat_id, UNLINKED)
return
role = ROLE_FA.get(link.role or "", link.role or "بدون نقش")
await ctx.send(chat_id, f"اتصال: <b>{esc(link.display_name)}</b> — {role}")
+389
View File
@@ -0,0 +1,389 @@
"""Accept / arrive flows — always with an explicit confirmation step."""
import logging
import re
from app.api.client import ApiError
from app.keyboards import accept_keyboard, arrive_keyboard, quote_pick_keyboard
from app.nlu import normalize_digits
from app.texts import (
ACCEPT_DONE_TS, ACCEPT_REPORT, ARRIVE_REPORT, CANCELLED, ERR_GENERIC,
QUOTE_NOT_FOUND, STATUS_FA, esc,
)
logger = logging.getLogger("bot.decisions")
EXPAND = "supplier,line_items,termsheets"
CONFIRM_WORDS = {"تایید", "تأیید", "ثبت", "ثبت کن", "اوکی", "اوکیه", "بله", "yes", "ok"}
CANCEL_WORDS = {"لغو", "بی خیال", "بیخیال", "انصراف", "نه", "cancel"}
def _norm(text: str) -> str:
return (text or "").strip().lower().replace("\u200c", " ")
# ---------------------------------------------------------------------------
# shared: pick a quote by supplier text
# ---------------------------------------------------------------------------
async def start_accept(ctx, chat_id, user, patch: dict) -> None:
await _start_for_status(ctx, chat_id, user, patch, mode="accept", status="offered")
async def start_arrive(ctx, chat_id, user, patch: dict) -> None:
await _start_for_status(ctx, chat_id, user, patch, mode="arrive", status="accepted")
async def _start_for_status(ctx, chat_id, user, patch: dict, mode: str, status: str) -> None:
target = (patch.get("target") or {})
q = target.get("supplier_name") or target.get("project_name") or target.get("quote_id")
try:
quotes, _ = await ctx.api.list_quotes(status=status, q=q, expand=EXPAND, page_size=10)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
if not quotes:
await ctx.send(chat_id, QUOTE_NOT_FOUND.format(q=esc(q or "?")))
return
if len(quotes) == 1:
if mode == "accept":
await begin_accept(ctx, chat_id, user, quotes[0])
else:
await begin_arrive(ctx, chat_id, user, quotes[0])
return
pairs = [(_quote_label(qq), str(qq["id"])) for qq in quotes]
flow = ctx.pending.set(chat_id, "quote_pick", {"mode": mode, "ids": [pid for _, pid in pairs]})
flow.data["user_id"] = user.actor_id
message = await ctx.send(chat_id, "چند مورد پیدا شد؛ کدام را می‌خواهی؟", reply_markup=quote_pick_keyboard(pairs))
flow.data["message_id"] = (message or {}).get("message_id")
def _quote_label(quote: dict) -> str:
sup = (quote.get("supplier") or {}).get("name") or "—"
return f"#{str(quote.get('id'))[:8]} {sup}"
async def apply_quote_pick(ctx, chat_id, user, flow, idx: int) -> None:
ids = flow.data.get("ids") or []
ctx.pending.pop(chat_id)
if idx < 0 or idx >= len(ids):
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
try:
quote = await ctx.api.get_quote(ids[idx], expand=EXPAND, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
if flow.data.get("mode") == "arrive":
await begin_arrive(ctx, chat_id, user, quote)
else:
await begin_accept(ctx, chat_id, user, quote)
async def handle_quote_pick_text(ctx, chat_id, flow, text: str) -> None:
if _norm(text) in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
numbers = re.findall(r"\d+", normalize_digits(text))
if numbers:
await apply_quote_pick(ctx, chat_id, _flow_user(flow), flow, int(numbers[0]) - 1)
else:
await ctx.send(chat_id, "شماره مورد را بفرست یا از دکمه‌ها استفاده کن.")
def _flow_user(flow):
from app.runtime import LinkedUser
return LinkedUser(chat_id=None, user_id=flow.data.get("user_id"), display_name=None,
role=None, is_admin=False)
# ---------------------------------------------------------------------------
# accept
# ---------------------------------------------------------------------------
def _accept_summary(quote: dict, chosen: int) -> str:
lines = [
f"✔️ <b>پذیرش استعلام</b> #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
"",
"<b>اقلام:</b>",
]
for li in quote.get("line_items") or []:
mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?"
unit = ((li.get("unit") or {}).get("name_fa")) or ""
price = li.get("unit_price")
parts = [f"• {esc(mat)} — مقدار {esc(li.get('quantity'))} {unit}"]
if price:
parts.append(f"قیمت {esc(price)}")
lines.append(" — ".join(parts))
lines.append("")
lines.append("<b>شرایط پرداخت:</b>")
for i, ts in enumerate(quote.get("termsheets") or []):
mark = "👉" if i == chosen else " "
discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else ""
lines.append(f"{mark} [{i + 1}] {esc(ts.get('label'))}{discount} — {len(ts.get('schedule_steps') or [])} مرحله")
lines.append("")
lines.append("شرایط را انتخاب کن و پذیرش را تأیید کن.")
return "\n".join(lines)
async def begin_accept(ctx, chat_id, user, quote: dict) -> None:
termsheets = quote.get("termsheets") or []
if not termsheets:
await ctx.send(chat_id, "این استعلام شرایط پرداخت ندارد؛ اول در برنامه ثبت کن.")
return
if not quote.get("line_items"):
await ctx.send(chat_id, "این استعلام قلم ندارد.")
return
flow = ctx.pending.set(chat_id, "accept", {
"quote_id": str(quote["id"]),
"quote_snapshot": {
"id": quote.get("id"),
"supplier": quote.get("supplier"),
"line_items": quote.get("line_items") or [],
},
"termsheets": termsheets,
"chosen": 0,
"user_id": user.actor_id,
})
text = _accept_summary(quote, 0)
message = await ctx.send(chat_id, text, reply_markup=accept_keyboard([ts.get("label") or "?" for ts in termsheets]))
flow.data["message_id"] = (message or {}).get("message_id")
async def handle_accept_callback(ctx, chat_id, user, parts) -> None:
flow = ctx.pending.get(chat_id)
if not flow or flow.kind != "accept":
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
return
op = parts[1] if len(parts) > 1 else ""
if op == "no":
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
if op == "ts" and len(parts) == 3:
idx = int(parts[2])
if 0 <= idx < len(flow.data["termsheets"]):
flow.data["chosen"] = idx
await ctx.edit(chat_id, flow.data.get("message_id"),
_accept_summary(flow.data["quote_snapshot"], idx),
reply_markup=accept_keyboard([ts.get("label") or "?" for ts in flow.data["termsheets"]]))
return
if op == "ok":
await _commit_accept(ctx, chat_id, user, flow)
async def _commit_accept(ctx, chat_id, user, flow) -> None:
data = flow.data
quote_id = data["quote_id"]
termsheets = data["termsheets"]
ts_index = data.get("chosen", 0)
if not 0 <= ts_index < len(termsheets):
await ctx.send(chat_id, "شرایط پرداخت انتخاب نشده است.")
return
ts = termsheets[ts_index]
steps = ts.get("schedule_steps") or []
if not steps:
await ctx.send(chat_id, "ترم‌شیت انتخابی مرحله پرداخت ندارد؛ در برنامه تکمیلش کن.")
return
try:
quote = await ctx.api.get_quote(quote_id, expand=EXPAND, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
decisions_payload = []
problems = []
for li in quote.get("line_items") or []:
decision = li.get("decision") or {}
if decision.get("status") == "rejected":
continue
price = li.get("unit_price")
if not price:
problems.append(f"قیمت قلم «{(li.get('material') or {}).get('name') or '?'}» ثبت نشده")
continue
li_projects = li.get("projects") or []
project_id = li_projects[0]["id"] if len(li_projects) == 1 else quote.get("project_id")
decisions_payload.append({
"line_item_id": li["id"],
"status": "chosen",
"accepted_termsheet_index": 0,
"adjusted_unit_price": price,
"project_id": project_id,
})
if problems:
await ctx.send(chat_id, "پذیرش ممکن نیست:\n" + "\n".join(f"❌ {esc(p)}" for p in problems))
return
if not decisions_payload:
await ctx.send(chat_id, "قلم قابل پذیرشی نیست (همه رد شده‌اند؟).")
return
payload = {
"accepted_termsheets": [{
"id": ts["id"],
"label": ts.get("label"),
"discount_type": ts.get("discount_type") or "none",
"discount_value": ts.get("discount_value") or 0,
"schedule_steps": steps,
}],
"item_decisions": decisions_payload,
}
try:
result = await ctx.api.accept_quote(quote_id, payload, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
ctx.pending.pop(chat_id)
chosen_label = ts.get("label") or ""
await ctx.send(chat_id, ACCEPT_REPORT.format(
id=esc(str(result.get("id"))[:8]),
status=STATUS_FA.get(result.get("status"), result.get("status")),
count=len(decisions_payload),
) + "\n" + ACCEPT_DONE_TS.format(label=esc(chosen_label)))
async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
if flow.kind == "accept":
norm = _norm(text)
if norm in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return True
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
await _commit_accept(ctx, chat_id, _flow_user(flow), flow)
return True
await ctx.send(chat_id, "برای پذیرش از دکمه‌ها استفاده کن (یا /cancel).")
return True
if flow.kind == "arrive_qty":
await _handle_arrive_quantities(ctx, chat_id, flow, text)
return True
if flow.kind == "arrive_confirm":
norm = _norm(text)
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
await _commit_arrive(ctx, chat_id, _flow_user(flow), flow)
return True
if norm in CANCEL_WORDS:
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return True
await ctx.send(chat_id, "با دکمه‌ها تأیید یا لغو کن (یا /cancel).")
return True
return False
# ---------------------------------------------------------------------------
# arrive (partial deliveries)
# ---------------------------------------------------------------------------
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
lines = ["🚚 <b>اعلام رسیدن اقلام</b>", ""]
for li in items:
mat = (li.get("material") or {}).get("name") or "?"
ordered = li.get("quantity") or 0
received = ((li.get("receipt") or {}).get("received_qty")) or 0
unit = ((li.get("unit") or {}).get("name_fa")) or ""
got = ""
if quantities and li["id"] in quantities:
got = f" → <b>الان: {esc(quantities[li['id']])} {unit}</b>"
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
return "\n".join(lines)
async def begin_arrive(ctx, chat_id, user, quote: dict) -> None:
items = [li for li in quote.get("line_items") or [] if (li.get("decision") or {}).get("status") == "chosen"]
if not items:
await ctx.send(chat_id, "قلم پذیرفته‌شده‌ای برای تحویل نیست.")
return
flow = ctx.pending.set(chat_id, "arrive_qty", {
"quote_id": str(quote["id"]),
"items": [{"id": li["id"], "name": (li.get("material") or {}).get("name") or "?",
"ordered": li.get("quantity"), "unit": ((li.get("unit") or {}).get("name_fa")) or "",
"received": ((li.get("receipt") or {}).get("received_qty")) or 0} for li in items],
"user_id": user.actor_id,
})
text = (
f"استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}\n"
+ _arrive_summary(quote.get("line_items") or [])
+ "\n\nمقادیر رسیده را به ترتیب اقلام بفرست (مثلاً: «500 و 200»)، یا «تحویل کامل همه» را بزن."
)
message = await ctx.send(chat_id, text, reply_markup=arrive_keyboard())
flow.data["message_id"] = (message or {}).get("message_id")
async def _handle_arrive_quantities(ctx, chat_id, flow, text: str) -> None:
numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", normalize_digits(text))]
items = flow.data["items"]
if not numbers:
await ctx.send(chat_id, "مقادیر را عددی بفرست، مثل «500 و 200»، یا از دکمه‌ها استفاده کن.")
return
if len(numbers) == 1 and len(items) > 1:
await ctx.send(chat_id, f"{len(items)} قلم است؛ برای هر قلم یک عدد بفرست (به ترتیب).")
return
if len(numbers) != len(items):
await ctx.send(chat_id, "تعداد اعداد با تعداد اقلام نمی‌خواند؛ دوباره بفرست.")
return
quantities = {item["id"]: qty for item, qty in zip(items, numbers)}
flow.kind = "arrive_confirm"
flow.data["quantities"] = quantities
await ctx.edit(chat_id, flow.data.get("message_id"),
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
+ "\n\nتأیید می‌کنی؟",
reply_markup=arrive_keyboard())
def _items_with_details(items: list[dict]) -> list[dict]:
return [{
"id": it["id"],
"material": {"name": it["name"]},
"quantity": it["ordered"],
"unit": {"name_fa": it["unit"]},
"receipt": {"received_qty": it["received"]},
} for it in items]
async def handle_arrive_callback(ctx, chat_id, user, parts) -> None:
flow = ctx.pending.get(chat_id)
if not flow or not flow.kind.startswith("arrive"):
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
return
op = parts[1] if len(parts) > 1 else ""
if op == "no":
ctx.pending.pop(chat_id)
await ctx.send(chat_id, CANCELLED)
return
if op == "full":
quantities = {item["id"]: max(float(item["ordered"] or 0) - float(item["received"] or 0), 0) for item in flow.data["items"]}
if any(v <= 0 for v in quantities.values()):
await ctx.send(chat_id, "برای بعضی اقلام چیزی برای تحویل نمانده؛ مقادیر را دستی بفرست.")
return
flow.kind = "arrive_confirm"
flow.data["quantities"] = quantities
await ctx.edit(chat_id, flow.data.get("message_id"),
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
+ "\n\nتأیید می‌کنی؟",
reply_markup=arrive_keyboard())
return
if op == "ok":
if flow.kind != "arrive_confirm" or not flow.data.get("quantities"):
await ctx.send(chat_id, "اول مقادیر رسیده را بفرست یا «تحویل کامل همه» را بزن.")
return
await _commit_arrive(ctx, chat_id, user, flow)
async def _commit_arrive(ctx, chat_id, user, flow) -> None:
quantities: dict = flow.data["quantities"]
payload = {"items": [{"line_item_id": li_id, "received_qty": qty} for li_id, qty in quantities.items()]}
try:
result = await ctx.api.arrive_items(flow.data["quote_id"], payload, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
ctx.pending.pop(chat_id)
lines = []
for item in result.get("items") or []:
lines.append(f"• قلم {esc(str(item.get('line_item_id'))[:8])} — {esc(item.get('received_qty'))} ثبت شد")
await ctx.send(chat_id, ARRIVE_REPORT.format(
id=esc(str(result.get("id"))[:8]),
status=STATUS_FA.get(result.get("status"), result.get("status")),
lines="\n".join(lines) or "—",
))
+46
View File
@@ -0,0 +1,46 @@
"""Casdoor linking: chat ↔ ERP user (validated through the real login endpoint)."""
import logging
from app.api.client import ApiError
from app.runtime import ROLE_FA
from app.texts import LINK_ASK_PASSWORD, LINK_FAIL, LINK_OK, esc
logger = logging.getLogger("bot.links")
async def finish_username_step(ctx, chat_id: int, flow, text: str) -> None:
username = (text or "").strip()
if not username or username.startswith("/"):
ctx.pending.pop(chat_id)
return
flow.kind = "link_password"
flow.data["username"] = username
await ctx.send(chat_id, LINK_ASK_PASSWORD)
async def finish_link(ctx, chat_id: int, flow, text: str | None, password: str) -> None:
username = flow.data.get("username") or ""
ctx.pending.pop(chat_id)
if not password or password.startswith("/"):
return
try:
data = await ctx.api.login(username, password)
except ApiError as exc:
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(exc.message)))
return
except Exception as exc: # network etc.
logger.exception("login failed")
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(str(exc)[:200])))
return
user = data.get("user") or {}
display = " ".join(x for x in [user.get("first_name"), user.get("last_name")] if x) or username
await ctx.links.upsert(
chat_id=chat_id,
user_id=user.get("id"),
username=username,
display_name=display,
role=user.get("role"),
)
role_fa = ROLE_FA.get(user.get("role") or "", user.get("role") or "بدون نقش")
await ctx.send(chat_id, LINK_OK.format(name=esc(display), role=role_fa))
+98
View File
@@ -0,0 +1,98 @@
"""Add-material flow: name + canonical unit (required)."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
logger = logging.getLogger("bot.material")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
if draft is not None:
payload = draft.payload
merge_material_patch(payload, patch)
else:
payload = new_material_payload()
merge_material_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
if res.resolved:
payload["canonical_unit_id"] = res.candidates[0].id
payload["unit_name"] = res.candidates[0].name
options.pop("unit", None)
elif res.candidates:
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("unit", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
if field == "unit":
draft.payload["canonical_unit_id"] = choice["id"]
draft.payload["unit_name"] = choice["name"]
draft.payload.setdefault("options", {}).pop("unit", None)
await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)})
await render(ctx, chat_id, draft, fresh=False)
def _keyboard(payload: dict, draft_id: str) -> dict:
rows: list[list[dict]] = []
options = (payload.get("options") or {}).get("unit") or []
if options:
rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]])
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = material_missing(payload)
text = material_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
await resolve_payload(ctx, payload)
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام کالا", missing.get("name")),
("unit", "واحد اصلی", missing.get("unit")),
] if not ok]
if problems:
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" +
"\n".join(f"❌ {esc(p)}" for p in problems))
return
body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])}
if payload.get("topic"):
body["topic"] = payload["topic"]
try:
created = await ctx.api.create_material(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("materials")
await ctx.send(chat_id, material_committed_report(created))
+44
View File
@@ -0,0 +1,44 @@
"""Status queries: «استعلام‌های در انتظار؟»، «کدام‌ها منقضی می‌شوند؟» …"""
import logging
from datetime import date, timedelta
from app.api.client import ApiError
from app.texts import ERR_GENERIC, status_report, esc
logger = logging.getLogger("bot.queries")
EXPAND = "supplier,line_items"
async def handle_query(ctx, chat_id, user, patch: dict) -> None:
query = patch.get("query") or {}
kind = query.get("kind") or "pending"
supplier_name = query.get("supplier_name")
q = supplier_name or query.get("project_name")
try:
if kind == "expiring":
quotes, _ = await ctx.api.list_quotes(status="offered", expand=EXPAND,
q=q, sort="-quote_date", page_size=100)
deadline = date.today() + timedelta(days=query.get("days") or 7)
soon = []
for quote in quotes:
expiry = (quote.get("expiry_date") or "")[:10]
if expiry and expiry <= deadline.isoformat():
soon.append(quote)
soon.sort(key=lambda x: x.get("expiry_date") or "9999")
await ctx.send(chat_id, status_report("expiring", soon[:10]))
return
if kind in ("accepted", "arrived", "pending", "offered"):
status = "offered" if kind == "pending" else kind
quotes, _ = await ctx.api.list_quotes(status=status, expand=EXPAND, q=q,
sort="-quote_date", page_size=10)
await ctx.send(chat_id, status_report("pending" if kind == "offered" else kind, quotes))
return
# recent / anything else
quotes, meta = await ctx.api.list_quotes(expand=EXPAND, q=q, sort="-quote_date", page_size=5)
await ctx.send(chat_id, status_report("recent", quotes))
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
+280
View File
@@ -0,0 +1,280 @@
"""Register-quote flow: entities → draft → resolve → review (+missing) → commit."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
new_quote_payload, quote_ready,
)
from app.keyboards import quote_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
logger = logging.getLogger("bot.quote")
FIELD_FA = {"sup": "تأمین‌کننده", "prj": "پروژه"}
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_QUOTE)
if draft is not None:
payload = draft.payload
merge_quote_patch(payload, patch)
else:
payload = new_quote_payload()
merge_quote_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_QUOTE, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
# ---------------------------------------------------------------------------
# resolution
# ---------------------------------------------------------------------------
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
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
options.pop("sup", None)
elif res.candidates:
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("sup", None)
if not payload.get("project_id") and payload.get("project_name"):
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
if res.resolved:
payload["project_id"] = res.candidates[0].id
payload["project_name"] = res.candidates[0].name
if payload["project_id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(payload["project_id"])
options.pop("prj", None)
elif res.candidates:
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("prj", None)
for p_name in payload.get("project_names") or []:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
p_id = res.candidates[0].id
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
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name"):
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
if res.resolved:
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
options.pop(f"mat{i}", None)
elif res.candidates:
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop(f"mat{i}", None)
if not item.get("unit_id"):
await _resolve_unit(ctx, item)
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
item["material_id"] = material_id
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
async def _resolve_unit(ctx, item: dict) -> None:
if item.get("unit_id"):
return
if item.get("unit_name"):
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
if res.resolved:
item["unit_id"] = res.candidates[0].id
item["unit_explicit"] = True
item["unit_name"] = res.candidates[0].name
return
canonical = ((item.get("material_row") or {}).get("canonical_unit_id"))
if canonical:
item["unit_id"] = str(canonical)
item["unit_explicit"] = False
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
payload = draft.payload
options = payload.setdefault("options", {})
options.pop(field, None)
if field == "sup":
payload["supplier_id"] = choice["id"]
payload["supplier_name"] = choice["name"]
elif field == "prj":
payload["project_id"] = choice["id"]
payload["project_name"] = choice["name"]
elif field.startswith("mat"):
index = int(field[3:]) - 1
items = payload.get("items") or []
if 0 <= index < len(items):
items[index]["material_name"] = choice["name"]
_set_material(items[index], choice["id"], choice["name"])
await _resolve_unit(ctx, items[index])
elif field.startswith("unit"):
index = int(field[4:]) - 1
items = payload.get("items") or []
if 0 <= index < len(items):
items[index]["unit_id"] = choice["id"]
items[index]["unit_explicit"] = True
items[index]["unit_name"] = choice["name"]
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
await render(ctx, chat_id, draft, fresh=False)
# ---------------------------------------------------------------------------
# rendering
# ---------------------------------------------------------------------------
def _pick_rows(payload: dict) -> list[list[str]]:
"""Field labels shown as pick-button rows (via keyboards.pick_buttons)."""
rows: list[list[str]] = []
options = payload.get("options") or {}
if options.get("sup"):
rows.append(["sup"] + [o["name"] for o in options["sup"]])
if options.get("prj"):
rows.append(["prj"] + [o["name"] for o in options["prj"]])
for i, item in enumerate(payload.get("items") or [], start=1):
if options.get(f"mat{i}"):
rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]])
if options.get(f"unit{i}"):
rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]])
return rows
def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict:
from app.keyboards import pick_buttons
prefix = str(draft_id)[:8]
pick_rows: list[list[dict]] = []
for row in _pick_rows(payload):
field, names = row[0], row[1:]
pick_rows += pick_buttons(prefix, field, names[:6])
ready_items = sum(
1 for it in payload.get("items") or []
if it.get("material_id") and it.get("quantity") and it.get("unit_id")
)
has_partial = 0 < ready_items < len(payload.get("items") or [])
return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url)
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None) -> None:
from app.config import settings
payload = draft.payload
missing = compute_quote_missing(payload)
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}"
text = quote_review_text(payload, missing, status_note=note)
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
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)
await draft_store.set_bale_updated_at(draft.id)
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
await draft_store.set_bale_updated_at(draft.id)
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
# 1. Sequential pre-creation for new supplier if user wants to commit
if not payload.get("supplier_id") and payload.get("supplier_name"):
cat_id = payload.get("supplier_category_id")
if not cat_id:
cats = await ctx.resolver.categories()
if cats:
cat_id = cats[0].id
if cat_id:
try:
sup_body = {
"name": payload["supplier_name"],
"category_id": cat_id,
}
if payload.get("supplier_phone"):
sup_body["phone"] = payload["supplier_phone"]
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
payload["supplier_id"] = created_sup["id"]
ctx.resolver.invalidate("suppliers")
except Exception as e:
logger.warning("Could not auto-create supplier: %s", e)
# 2. Sequential pre-creation for new materials
for i, item in enumerate(payload.get("items") or [], start=1):
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
try:
created_mat = await ctx.api.create_material({
"name": item["material_name"],
"canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
ctx.resolver.invalidate("materials")
except Exception as e:
logger.warning("Could not auto-create material: %s", e)
ready, blockers = quote_ready(payload)
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
if body is None:
lines = ["ثبت ممکن نیست؛ این‌ها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems]
await ctx.send(chat_id, "\n".join(lines))
return
if not ready and not only_resolved:
lines = ["برای ثبت کامل، اول این‌ها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers]
lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."]
await ctx.send(chat_id, "\n".join(lines))
return
try:
created = await ctx.api.create_quote(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_quote failed")
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه"))
return
await draft_store.set_status(draft.id, "committed")
ctx.pending.pop(chat_id, None)
ctx.resolver.invalidate()
if draft.review_message_id:
try:
quote_id = (created or {}).get("id") or ""
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
except Exception:
pass
remaining = [
m["label"] for m in compute_quote_missing(payload)
if not m["blocking"] and not m["ok"]
]
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
if dropped > 0:
remaining.append(f"{dropped} قلم ناشناس حذف شد")
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
+103
View File
@@ -0,0 +1,103 @@
"""Add-supplier flow: name + one of the 8 fixed Persian categories (required)."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
)
from app.keyboards import pick_buttons, simple_review_keyboard
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
logger = logging.getLogger("bot.supplier")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
if draft is not None:
payload = draft.payload
merge_supplier_patch(payload, patch)
else:
payload = new_supplier_payload()
merge_supplier_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
await resolve_payload(ctx, payload)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
async def resolve_payload(ctx, payload: dict) -> None:
options = payload.setdefault("options", {})
if not payload.get("category_id") and payload.get("category_name"):
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
if res.resolved:
payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name
options.pop("cat", None)
elif res.candidates:
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
else:
options.pop("cat", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
if field == "cat":
draft.payload["category_id"] = choice["id"]
draft.payload["category_name"] = choice["name"]
draft.payload.setdefault("options", {}).pop("cat", None)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(draft.payload)})
await render(ctx, chat_id, draft, fresh=False)
def _keyboard(payload: dict, draft_id: str) -> dict:
rows: list[list[dict]] = []
options = (payload.get("options") or {}).get("cat") or []
if options:
rows += pick_buttons(draft_id, "cat", [o["name"] for o in options[:6]])
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = supplier_missing(payload)
text = supplier_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
await resolve_payload(ctx, payload)
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام تأمین‌کننده", missing.get("name")),
("category", "دسته‌بندی", missing.get("category")),
] if not ok]
if problems:
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" +
"\n".join(f"❌ {esc(p)}" for p in problems))
return
body = {
"name": payload["name"],
"category_id": payload["category_id"],
"phone": payload.get("phone"),
"location": payload.get("location"),
"contact_person": payload.get("contact_person"),
}
body = {k: v for k, v in body.items() if v}
try:
created = await ctx.api.create_supplier(body, actor_id=user.actor_id)
except ApiError as exc:
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("suppliers")
await ctx.send(chat_id, supplier_committed_report(created))
+74
View File
@@ -0,0 +1,74 @@
"""Inline keyboard builders.
Bale keeps Telegram's 64-byte ``callback_data`` cap, so every callback is a
short op-code plus the 8-char draft prefix; the full state lives in bot_drafts
(or the in-memory pending-flow map) and is looked up on tap.
"""
def _kb(rows: list[list[dict]]) -> dict:
return {"inline_keyboard": rows}
def _btn(text: str, data: str) -> dict:
return {"text": text, "callback_data": data}
# callback scheme (≤64 bytes):
# q:ok:{d8} / q:no:{d8} / q:ok2:{d8} quote commit / cancel / commit-resolved-only
# s:ok:{d8} / s:no:{d8} supplier
# m:ok:{d8} / m:no:{d8} material
# pk:{d8}:{field}:{idx} pick candidate for a draft field
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q")
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
if has_partial_option:
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
return rows
def pick_buttons(prefix: str, field: str, names: list[str]) -> list[list[dict]]:
return [[_btn(name[:60], f"pk:{prefix}:{field}:{i}")] for i, name in enumerate(names)]
def quote_review_keyboard(
prefix: str,
pick_rows: list[list[dict]],
has_partial_option: bool = False,
wizard_url: str | None = None,
) -> dict:
rows = list(pick_rows)
if wizard_url:
rows.append([{"text": "🖥 بازبینی و تکمیل در ویزارد", "url": wizard_url}])
rows += draft_actions(prefix, "quote", has_partial_option=has_partial_option)
return _kb(rows)
def simple_review_keyboard(prefix: str, kind: str, pick_rows: list[list[dict]]) -> dict:
rows = list(pick_rows)
rows += draft_actions(prefix, kind)
return _kb(rows)
def quote_pick_keyboard(pairs: list[tuple[str, str]]) -> dict:
"""Pick among candidate quotes: pairs of (label, quote_id)."""
return _kb([[_btn(label[:60], f"qc:{i}")] for i, (label, _) in enumerate(pairs)])
def accept_keyboard(termsheet_labels: list[str]) -> dict:
rows = [[_btn(f"شرایط: {label[:40]}", f"ac:ts:{i}")] for i, label in enumerate(termsheet_labels)]
rows.append([_btn("✅ تأیید پذیرش", "ac:ok"), _btn("❌ انصراف", "ac:no")])
return _kb(rows)
def arrive_keyboard() -> dict:
return _kb([
[_btn("📦 تحویل کامل همه اقلام", "ar:full")],
[_btn("✅ ثبت مقادیر اعلام‌شده", "ar:ok"), _btn("❌ انصراف", "ar:no")],
])
def cancel_keyboard() -> dict:
return _kb([[_btn("❌ انصراف", "ac:no")]])
+60
View File
@@ -0,0 +1,60 @@
"""Bot entrypoint: ensure tables → sweeper task → Bale long-poll loop.
Mirrors the api's pattern of a plain asyncio background loop (no Celery/Redis).
"""
import asyncio
import logging
from app.bale.client import BaleClient, poll_loop
from app.config import settings
from app.db_init import ensure_schema
from app.drafts import expire_stale
from app.handlers import handle_update
from app.runtime import BotContext
from app.api.client import ErpClient
from app.sync import sync_drafts_loop
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
)
logger = logging.getLogger("bot.main")
async def sweeper_loop() -> None:
"""Expire stale drafts + sweep dead pending flows every 10 minutes."""
while True:
try:
expired = await expire_stale(settings.DRAFT_TTL_HOURS)
if expired:
logger.info("expired %s stale drafts", expired)
except Exception:
logger.exception("sweeper failed")
await asyncio.sleep(600)
async def main() -> None:
if not settings.BOT_TOKEN:
raise SystemExit("PROCUREMENT_BOT_BOT_TOKEN is not set — get one from Bale's BotFather")
logger.info("starting bale procurement bot (api=%s, ai=%s)",
settings.API_BASE_URL, "configured" if settings.AI_BASE_URL else "NOT configured")
await ensure_schema()
bale = BaleClient(settings.BALE_API_BASE, settings.BOT_TOKEN)
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
ctx = BotContext(bale, api)
sweeper = asyncio.create_task(sweeper_loop())
sync_task = asyncio.create_task(sync_drafts_loop(ctx))
try:
await poll_loop(bale, lambda update: handle_update(ctx, update))
finally:
sweeper.cancel()
sync_task.cancel()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
+80
View File
@@ -0,0 +1,80 @@
"""Bot-owned tables (bot_* prefix, idempotent DDL in db_init.py).
These are separate from the ERP schema on purpose: the bot is an autonomous
container and owns its own state (links, drafts, message audit log).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
def _uuid_pk() -> Mapped[str]:
return mapped_column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
def _timestamptz(**kwargs) -> Mapped[datetime]:
return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, **kwargs)
class BotLink(Base):
"""Bale chat ↔ ERP user mapping (established via Casdoor login)."""
__tablename__ = "bot_links"
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True)
user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) # users.id
casdoor_username: Mapped[str | None] = mapped_column(Text)
display_name: Mapped[str | None] = mapped_column(Text)
role: Mapped[str | None] = mapped_column(String(32))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotDraft(Base):
"""A prepared-but-not-committed ERP action (quote / supplier / material)."""
__tablename__ = "bot_drafts"
__table_args__ = (Index("ix_bot_drafts_chat_status", "chat_id", "status"),)
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
missing: Mapped[dict | None] = mapped_column(JSONB)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed
source: Mapped[str | None] = mapped_column(String(16)) # text|voice
review_message_id: Mapped[int | None] = mapped_column(BigInteger)
last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotMessage(Base):
"""Audit log of every inbound/outbound message (who asked for what)."""
__tablename__ = "bot_messages"
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error
body: Mapped[str | None] = mapped_column(Text)
payload: Mapped[dict | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
class BotMeta(Base):
"""Tiny KV store (e.g. last processed Bale update_id across restarts)."""
__tablename__ = "bot_meta"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(Text)
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
+149
View File
@@ -0,0 +1,149 @@
"""Natural-language understanding: Persian free text → typed intent + entities.
One chat/completions call per message with JSON-mode output. The prompt embeds
the current draft summary so the model can decide between "start a new
activity" and "refine the open draft" (update_draft with a partial patch).
"""
import json
import logging
import re
import httpx
from app.config import Settings
logger = logging.getLogger("bot.nlu")
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
def normalize_digits(text: str) -> str:
return (text or "").translate(PERSIAN_DIGITS)
ZWNJ = "\u200c"
def normalize_text(text: str) -> str:
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
t = normalize_digits(text or "")
t = t.replace(ZWNJ, " ").replace("ي", "ی").replace("ك", "ک")
t = re.sub(r"\s+", " ", t).strip().lower()
return t
INTENTS = [
"register_quote", # ثبت استعلام/پیشنهاد جدید از یک تأمین‌کننده
"update_draft", # تکمیل/اصلاح پیش‌نویس باز (patch روی آن)
"commit_draft", # «ثبت کن» / «تأیید» — commit the open draft
"discard_draft", # «بی‌خیال» / «لغو»
"show_draft", # «وضعیت پیش‌نویس؟»
"add_supplier",
"add_material",
"status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ...
"accept_quote", # پذیرش یک استعلام
"arrive_items", # اعلام رسیدن اقلام
"help",
"unknown",
]
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
{
"intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده},
"confidence": 0.0..1.0
}
قوانین:
- اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته.
- اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft".
- اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft".
- اگر سؤال درباره وضعیت است → intent="status_query".
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام.
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمین‌کننده/استعلام و در patch.arrivals مقادیر.
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
شکل patch برای intent="register_quote"/"update_draft":
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
شکل patch برای intent="add_supplier":
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
شکل patch برای intent="add_material":
{"material": {"name": str, "unit_name": str, "topic": str}}
شکل patch برای intent="status_query":
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
شکل patch برای intent="accept_quote"/"arrive_items":
{"target": {"supplier_name": str, "project_name": str, "quote_id": str},
"arrivals": [{"material_name": str, "quantity": number, "quality_note": str}]}
{draft_block}
"""
def _build_system_prompt(draft_summary: str | None) -> str:
intents = ", ".join(INTENTS)
prompt = SYSTEM_PROMPT.replace("{intents}", intents).replace("{draft_block}", "")
if draft_summary:
prompt += (
"\n\nپیش‌نویسِ باز فعلی کاربر (اگر مرتبط است به آن تکیه کن و فیلدهای تکراری را بازنویسی نکن):\n"
+ draft_summary
)
return prompt
def _extract_json(raw: str) -> dict | None:
raw = (raw or "").strip()
if raw.startswith("```"):
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw).rstrip("`").strip()
start = raw.find("{")
end = raw.rfind("}")
if start == -1 or end <= start:
return None
try:
return json.loads(raw[start : end + 1])
except json.JSONDecodeError:
return None
async def understand(settings: Settings, text: str, draft_summary: str | None = None) -> dict:
"""Return {"intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"intent": "unknown", "patch": {}, "confidence": 0.0}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.warning("AI provider not configured; cannot understand message")
return fallback
try:
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
json={
"model": settings.LLM_MODEL,
"messages": [
{"role": "system", "content": _build_system_prompt(draft_summary)},
{"role": "user", "content": normalize_digits(text)},
],
"temperature": 0.1,
"response_format": {"type": "json_object"},
},
)
if resp.status_code != 200:
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
return fallback
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
parsed = _extract_json(content)
if not parsed or parsed.get("intent") not in INTENTS:
logger.warning("unparseable LLM output: %r", content[:200])
return fallback
parsed.setdefault("patch", {})
return parsed
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
logger.exception("NLU failure")
return fallback
+151
View File
@@ -0,0 +1,151 @@
"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches.
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
"""
import difflib
import time
from dataclasses import dataclass, field
from app.api.client import ApiError, ErpClient
from app.nlu import normalize_text
@dataclass
class Candidate:
id: str
name: str
extra: dict = field(default_factory=dict)
@dataclass
class Resolution:
status: str # "one" | "many" | "none"
candidates: list[Candidate]
@property
def resolved(self) -> bool:
return self.status == "one"
def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]:
q = normalize_text(query)
if not q:
return items[:limit]
scored = []
for item in items:
name = normalize_text(item.name)
ratio = difflib.SequenceMatcher(None, q, name).ratio()
if q == name:
ratio = 1.0
elif q in name:
ratio += 0.35
scored.append((ratio, item))
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
class Resolver:
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
def __init__(self, api: ErpClient, ttl_seconds: float = 1800):
self._api = api
self._ttl = ttl_seconds
self._cache: dict[str, tuple[float, list[Candidate]]] = {}
async def _cached(self, key: str, fetch) -> list[Candidate]:
entry = self._cache.get(key)
now = time.monotonic()
if entry and now - entry[0] < self._ttl:
return entry[1]
items = await fetch()
self._cache[key] = (now, items)
return items
def invalidate(self, key: str | None = None) -> None:
if key:
self._cache.pop(key, None)
else:
self._cache.clear()
# ---------------------------------------------------------------- sources
async def units(self) -> list[Candidate]:
async def fetch():
rows = await self._api.list_units()
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
extra={"code": u.get("code")}) for u in rows]
return await self._cached("units", fetch)
async def categories(self) -> list[Candidate]:
async def fetch():
rows = await self._api.list_categories()
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
return await self._cached("categories", fetch)
async def projects(self) -> list[Candidate]:
async def fetch():
rows = await self._api.list_projects()
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
return await self._cached("projects", fetch)
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]
return _rank(q, items) if q else items
async def materials(self, q: str | None = None) -> list[Candidate]:
rows = await self._api.list_materials(q=q, page_size=20)
extra = {str(m["id"]): m for m in rows}
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
for m in rows]
return _rank(q, items) if q else items
# ------------------------------------------------------------ resolutions
async def resolve(self, kind: str, name: str | None) -> Resolution:
"""Resolve by name; exact hit → "one", several close → "many", none → "none"."""
if not name or not name.strip():
return Resolution("none", [])
if kind == "supplier":
candidates = await self.suppliers(name)
elif kind == "material":
candidates = await self.materials(name)
elif kind == "project":
candidates = _rank(name, await self.projects())
elif kind == "unit":
candidates = _rank(name, await self.units())
elif kind == "category":
candidates = _rank(name, await self.categories())
else:
raise ValueError(f"unknown kind: {kind}")
if not candidates:
return Resolution("none", [])
norm_name = normalize_text(name)
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
if len(exact) == 1:
return Resolution("one", exact)
top = candidates[0]
top_norm = normalize_text(top.name)
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
return Resolution("one", [top])
return Resolution("many", candidates)
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
"""Return (unit_id, explicitly_resolved). Falls back to the material's
canonical unit when the spoken unit isn't found."""
if unit_name:
res = await self.resolve("unit", unit_name)
if res.resolved:
return res.candidates[0].id, True
if material and material.get("canonical_unit_id"):
return str(material["canonical_unit_id"]), False
return None, False
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
try:
return await resolver.resolve(kind, name)
except ApiError:
return Resolution("none", [])
+159
View File
@@ -0,0 +1,159 @@
"""Shared runtime: BotContext wiring + per-chat pending interaction flows.
Pending flows (Casdoor linking, quote picking, accept/arrive confirmations)
are short-lived and single-instance, so they live in memory; anything that
must survive restarts (drafts, links) lives in postgres.
"""
import logging
import time
from dataclasses import dataclass, field
from sqlalchemy import select, update
from app.api.client import ErpClient
from app.bale.client import BaleClient
from app.config import settings
from app.db import SessionLocal
from app.models import BotLink, BotMessage
from app.resolve import Resolver
logger = logging.getLogger("bot.runtime")
# ---------------------------------------------------------------------------
# Links (postgres)
# ---------------------------------------------------------------------------
class LinkStore:
async def get(self, chat_id: int) -> BotLink | None:
async with SessionLocal() as session:
return (await session.execute(
select(BotLink).where(BotLink.chat_id == chat_id)
)).scalar_one_or_none()
async def upsert(self, chat_id: int, user_id: str | None, username: str | None,
display_name: str | None, role: str | None) -> BotLink:
async with SessionLocal() as session:
link = (await session.execute(select(BotLink).where(BotLink.chat_id == chat_id))).scalar_one_or_none()
if link is None:
link = BotLink(chat_id=chat_id)
session.add(link)
link.user_id = user_id
link.casdoor_username = username
link.display_name = display_name
link.role = role
await session.commit()
await session.refresh(link)
return link
async def delete(self, chat_id: int) -> bool:
async with SessionLocal() as session:
result = await session.execute(
update(BotLink).where(BotLink.chat_id == chat_id).values(user_id=None, role=None,
casdoor_username=None)
)
await session.commit()
return (result.rowcount or 0) > 0
ROLE_FA = {"pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین"}
# ---------------------------------------------------------------------------
# Pending flows (memory) — one per chat
# ---------------------------------------------------------------------------
@dataclass
class PendingFlow:
kind: str # link_username | link_password | quote_pick | accept | arrive_qty | arrive_confirm
data: dict = field(default_factory=dict)
created: float = field(default_factory=time.monotonic)
@property
def age_seconds(self) -> float:
return time.monotonic() - self.created
class PendingFlows:
TTL_SECONDS = 1800
def __init__(self):
self._flows: dict[int, PendingFlow] = {}
def set(self, chat_id: int, kind: str, data: dict | None = None) -> PendingFlow:
flow = PendingFlow(kind=kind, data=data or {})
self._flows[chat_id] = flow
return flow
def get(self, chat_id: int) -> PendingFlow | None:
flow = self._flows.get(chat_id)
if flow and flow.age_seconds > self.TTL_SECONDS:
self._flows.pop(chat_id, None)
return None
return flow
def pop(self, chat_id: int, default=None):
return self._flows.pop(chat_id, default)
def sweep(self) -> None:
for chat_id, flow in list(self._flows.items()):
if flow.age_seconds > self.TTL_SECONDS:
self._flows.pop(chat_id, None)
# ---------------------------------------------------------------------------
# Context
# ---------------------------------------------------------------------------
class BotContext:
def __init__(self, bale: BaleClient, api: ErpClient):
self.bale = bale
self.api = api
self.resolver = Resolver(api)
self.links = LinkStore()
self.pending = PendingFlows()
@property
def admin_chat_ids(self) -> set[int]:
return settings.admin_chat_ids
async def send(self, chat_id: int, text: str, reply_markup: dict | None = None) -> dict:
"""Send + audit-log every outbound message."""
try:
message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup)
except Exception:
logger.exception("failed to send to chat %s", chat_id)
message = {}
await self._log(chat_id, "out", "text", text)
return message
async def edit(self, chat_id: int, message_id: int | None, text: str,
reply_markup: dict | None = None) -> None:
if not message_id:
await self.send(chat_id, text, reply_markup=reply_markup)
return
try:
await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup)
except Exception:
await self.send(chat_id, text, reply_markup=reply_markup)
async def _log(self, chat_id: int, direction: str, kind: str, body: str | None = None) -> None:
try:
async with SessionLocal() as session:
session.add(BotMessage(chat_id=chat_id, direction=direction, kind=kind, body=body))
await session.commit()
except Exception:
logger.exception("failed to log bot message")
@dataclass
class LinkedUser:
chat_id: int
user_id: str | None
display_name: str | None
role: str | None
is_admin: bool
@property
def actor_id(self) -> str | None:
return self.user_id
+62
View File
@@ -0,0 +1,62 @@
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
self-hosted whisper servers exposing the same contract).
"""
import asyncio
import logging
import subprocess
import tempfile
from pathlib import Path
import httpx
from app.config import Settings
logger = logging.getLogger("bot.stt")
_CONVERT_TIMEOUT = 60
_STT_TIMEOUT = 120
def _to_mp3(raw: bytes, suffix: str) -> bytes:
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / f"in{suffix or '.ogg'}"
dst = Path(tmp) / "out.mp3"
src.write_bytes(raw)
proc = subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
capture_output=True,
timeout=_CONVERT_TIMEOUT,
)
if proc.returncode != 0 or not dst.exists():
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
return dst.read_bytes()
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> str:
"""Return the Persian transcript for a voice note (empty string on silence)."""
if not settings.stt_base_url or not settings.stt_api_key:
raise RuntimeError("STT provider is not configured (PROCUREMENT_BOT_STT_BASE_URL / _AI_BASE_URL)")
suffix = Path(filename or "").suffix
if not suffix and mime_type:
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
url = f"{settings.stt_base_url.rstrip('/')}/audio/transcriptions"
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {settings.stt_api_key}"},
files={"file": ("voice.mp3", converted, "audio/mpeg")},
data={"model": settings.STT_MODEL, "language": settings.STT_LANGUAGE},
)
if resp.status_code != 200:
raise RuntimeError(f"STT provider returned HTTP {resp.status_code}: {resp.text[:300]}")
return str((resp.json() or {}).get("text") or "").strip()
+59
View File
@@ -0,0 +1,59 @@
"""Background sync loop: watches bot_drafts for updates from Web Wizard or external API.
If a draft was updated in the Wizard:
- If status == 'active': re-renders review text & keyboard in Bale so chat reflects edits.
- If status == 'committed': edits Bale message to show it was committed and removes buttons.
- If status == 'cancelled': edits Bale message to show it was cancelled and removes buttons.
"""
import asyncio
import logging
from app import drafts as draft_store
from app.handlers import quote
logger = logging.getLogger("bot.sync")
async def sync_drafts_loop(ctx) -> None:
while True:
try:
pending = await draft_store.get_pending_bale_updates()
for draft in pending:
try:
if not draft.review_message_id:
await draft_store.set_bale_updated_at(draft.id)
continue
if draft.status == "committed":
payload = draft.payload or {}
quote_id = payload.get("committed_quote_id") or ""
msg = "✅ این پیشنهاد با موفقیت در سیستم ثبت نهایی شد."
if quote_id:
msg += f"\nشناسه پیشنهاد: {quote_id}"
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
logger.info("Synced committed draft %s to Bale message %s", draft.id, draft.review_message_id)
elif draft.status == "cancelled":
msg = "❌ این پیش‌نویس لغو شد."
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
await draft_store.set_bale_updated_at(draft.id)
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id)
elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE:
await quote.render(ctx, draft.chat_id, draft, fresh=False)
await draft_store.set_bale_updated_at(draft.id)
logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
else:
await draft_store.set_bale_updated_at(draft.id)
except Exception as exc:
logger.warning("Failed to sync draft %s to Bale: %s", draft.id, exc)
await draft_store.set_bale_updated_at(draft.id)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Error in sync_drafts_loop")
await asyncio.sleep(1.5)
+254
View File
@@ -0,0 +1,254 @@
"""Persian message templates (the ERP's user-facing layer is Persian)."""
import html
from datetime import date
STATUS_FA = {
"offered": "🔵 در انتظار بررسی",
"accepted": "🟢 پذیرفته‌شده",
"arrived": "✅ رسیده",
"rejected": "🔴 ردشده",
"expired": "⚪️ منقضی",
}
MISSING_MARKS = {True: "✅", False: "❌"}
WARNING_MARK = "⚠️"
def esc(value) -> str:
return html.escape(str(value if value is not None else "—"))
def fa_date(value: str | None) -> str:
if not value:
return "—"
return str(value)[:10]
WELCOME = (
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
"می‌توانی با پیام متنی یا <b>صوتی</b> فعالیتی تعریف کنی — مثلاً:\n"
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
"من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان "
"می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n"
"دستورها:\n"
"/drafts — پیش‌نویس‌های باز\n"
"/cancel — لغو پیش‌نویس/عملیات جاری\n"
"/whoami — اتصال من\n"
"/unlink — قطع اتصال\n"
"/help — راهنما"
)
HELP = (
"<b>چه کارهایی می‌توانم بکنم؟</b>\n\n"
"🧾 <b>ثبت استعلام</b> — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n"
"🏢 <b>افزودن تأمین‌کننده</b> — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
"📦 <b>افزودن کالا</b> — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
"📊 <b>پرس‌وجو</b> — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n"
"✔️ <b>پذیرش استعلام</b> — «استعلام سازان نو را قبول کن»\n"
"🚚 <b>اعلام رسیدن</b> — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
"هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن."
)
LINK_START = (
"برای اتصال حساب Bale به حساب ERP، <b>نام کاربری Casdoor</b> خود را بفرست "
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
)
LINK_ASK_PASSWORD = "ممنون. حالا <b>رمز عبور</b> را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو"
LINK_OK = "✅ اتصال انجام شد: <b>{name}</b> ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین."
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود."
DRAFTS_LIST_HEADER = "📋 پیش‌نویس‌های باز:"
DRAFTS_EMPTY = "پیش‌نویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
NOTHING_TO_COMMIT = "پیش‌نویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
CANCELLED = "لغو شد. ✔️"
UNKNOWN_HINT = (
"متوجه منظورت نشد 🤔\n"
"می‌توانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
)
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
AI_NOT_CONFIGURED = (
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
"با مدیر سیستم تماس بگیر."
)
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
ERR_GENERIC = "خطا: {detail}"
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را می‌خواهی؟"
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
ACCEPT_REPORT = (
"✅ پذیرش انجام شد\n"
"استعلام #{id}\n"
"وضعیت: {status}\n"
"اقلام تصمیم‌گیری‌شده: {count}"
)
ARRIVE_REPORT = (
"🚚 ثبت رسید انجام شد\n"
"استعلام #{id} — وضعیت: {status}\n{lines}"
)
ACCEPT_DONE_TS = "شرایط انتخاب‌شده: {label}"
def missing_block(missing: list[dict]) -> str:
"""✅/❌ list of what's captured vs. still empty (⚠️ = nice-to-have)."""
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']}")
return "\n".join(lines)
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
lines = ["🧾 پیش‌نویس استعلام:"]
if status_note:
lines.append(status_note)
sup = payload.get("supplier_name") or "—"
if payload.get("supplier_id"):
sup_label = f"{esc(sup)} ✅"
elif payload.get("supplier_name"):
sup_label = f"{esc(sup)} ✨ (تأمین‌کننده جدید)"
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'])}")
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اقلام: هنوز چیزی ثبت نشده ❌")
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))
if payload.get("notes"):
lines.append(f"یادداشت: {esc(payload['notes'])}")
lines.append("\n📋 وضعیت فیلدها:")
lines.append(missing_block(missing))
lines.append("\nبا پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.")
return "\n".join(lines)
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["🏢 <b>پیش‌نویس تأمین‌کننده جدید</b>", f"نام: <b>{esc(payload.get('name'))}</b>"]
lines.append(f"دسته: {esc(payload.get('category_name'))}")
if payload.get("phone"):
lines.append(f"تلفن: {esc(payload['phone'])}")
if payload.get("location"):
lines.append(f"موقعیت: {esc(payload['location'])}")
if payload.get("contact_person"):
lines.append(f"رابط: {esc(payload['contact_person'])}")
lines.append("\n<b>وضعیت فیلدها:</b>")
lines.append(missing_block(missing))
return "\n".join(lines)
def material_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["📦 <b>پیش‌نویس کالای جدید</b>", f"نام: <b>{esc(payload.get('name'))}</b>"]
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
if payload.get("topic"):
lines.append(f"تاپیک: {esc(payload['topic'])}")
lines.append("\n<b>وضعیت فیلدها:</b>")
lines.append(missing_block(missing))
return "\n".join(lines)
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
lines = [f"✅ <b>ثبت شد</b> — استعلام #{esc(created.get('id'))}"]
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
if sup:
lines.append(f"تأمین‌کننده: {esc(sup)}")
items = created.get("line_items") or []
if items:
lines.append(f"اقلام: {len(items)}")
sheets = created.get("termsheets") or []
if sheets:
lines.append(f"شرایط پرداخت: {len(sheets)}")
if payload.get("project_id"):
lines.append("پروژه: پیوست شد")
if remaining:
lines.append("\n<b>خالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):</b>")
lines += [f"⚠️ {esc(r)}" for r in remaining]
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
return "\n".join(lines)
def supplier_committed_report(created: dict) -> str:
return (
f"✅ <b>تأمین‌کننده ثبت شد</b> — {esc(created.get('name'))}\n"
f"دسته: {esc((created.get('category') or {}).get('name'))}"
)
def material_committed_report(created: dict) -> str:
return f"✅ <b>کالا ثبت شد</b> — {esc(created.get('name'))}"
def quote_line(quote: dict) -> str:
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
items = quote.get("line_items") or []
item_names = []
for it in items:
mat = (it.get("material") or {}).get("name")
if mat:
qty = it.get("quantity")
item_names.append(f"{mat} ({qty})" if qty else mat)
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
return (
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
f"{fa_date(quote.get('quote_date'))}{tail}"
)
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
titles = {
"pending": "🔵 استعلام‌های در انتظار بررسی",
"accepted": "🟢 پذیرفته‌شده (در انتظار تحویل)",
"arrived": "✅ رسیده",
"expiring": "⏳ به‌زودی منقضی می‌شوند (۷ روز آینده)",
"recent": "🕘 آخرین استعلام‌ها",
}
if not quotes:
return f"{titles.get(kind, 'نتیجه‌ای')}\nموردی نیست."
lines = [titles.get(kind, "استعلام‌ها")]
if extra_note:
lines.append(extra_note)
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
return "\n".join(lines)
+172
View File
@@ -0,0 +1,172 @@
# Setup & Operations Guide
A Bale messenger bot that turns Persian **chat messages and voice notes** into
procurement actions in the ERP. It works **draft-first**: it captures what it
understood, tells you exactly **what it did** and **what is still empty**, and
**commits only after you approve** with a ✅ button.
Bale's Bot API is a Telegram-compatible clone hosted at `tapi.bale.ai`, so the
bot uses the familiar `getUpdates` long-polling — **no public port, no webhook,
no TLS setup**.
---
## What it can do (v1)
| Activity | Example (voice or text) | Committed via |
|---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` (nested create) |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
### The draft → approve loop (the core idea)
1. You send a message (or voice note). The bot converts voice to text
(STT) and extracts an **intent + entities** (LLM).
2. Names are fuzzy-matched against the ERP (supplier / material / unit /
project). Multiple candidates → inline **pick buttons**; no match → it is
listed as missing.
3. The bot replies with a **review card**: everything it captured, plus a
✅/⚠️/❌ **list of what is still empty** («یادآوری چیزهای خالی»).
4. You keep chatting to refine («قیمت میلگرد ۴۷ شد», «پروژه برج آسمان») — the
open draft is updated and re-rendered.
5. **Nothing is written to the ERP until you tap ✅ ثبت نهایی.** Partial
commits are possible («ثبت فقط اقلام مشخص») because the API deliberately
tolerates incomplete quotes. After commit you get the created quote id and
the list of fields that were left empty.
### Team access & attribution
- Every user links their Bale chat to their ERP account once: `/start` →
Casdoor **username** → **password** (validated through the real
`POST /auth/login`; the password is used once and never stored).
- Mutating API calls carry `X-Actor-Id: <users.id>` so `quote_events.actor`
records the **human** who asked, not the bot. (The bot's container talks to
the api in-network: `http://api:8000/api/v1`.)
- `BALE_ADMIN_CHAT_IDS` may list chat ids that bypass linking.
### Commands
`/start` · `/help` · `/drafts` (open drafts) · `/cancel` (abort draft/flow) ·
`/whoami` · `/unlink`
---
## Setup
1. **Create the bot** in Bale's BotFather and copy the token.
2. **Configure an AI provider** — any OpenAI-compatible endpoint works for
both STT (`POST {base}/audio/transcriptions`, Persian) and the LLM
(`POST {base}/chat/completions`, JSON mode). Examples: OpenAI `whisper-1`
+ `gpt-4o-mini`, or any proxy/provider you already use.
3. **Compose** (dev):
```powershell
# env passed at compose level (or a .env next to docker-compose.yml)
$env:BALE_BOT_TOKEN = "<token>"
$env:AI_BASE_URL = "https://api.openai.com/v1"
$env:AI_API_KEY = "<key>"
docker compose up -d --build bot
docker compose logs -f bot
```
For Coolify (`docker-compose.coolify.yml`) set the same variables as
service-level env vars; the bot container needs no domain/labels.
### Environment variables (prefix `PROCUREMENT_BOT_`)
| Variable | Default | Purpose |
|---|---|---|
| `BOT_TOKEN` | — (required) | token from Bale's BotFather |
| `BALE_API_BASE` | `https://tapi.bale.ai` | Bot API base (overridable for tests) |
| `API_BASE_URL` | `http://localhost:8000/api/v1` | ERP api (in Docker: `http://api:8000/api/v1`) |
| `DATABASE_URL` | same postgres as the api | bot's own tables (`bot_*`) |
| `AI_BASE_URL` / `AI_API_KEY` | — | OpenAI-compatible STT+LLM provider |
| `STT_MODEL` / `LLM_MODEL` | `whisper-1` / `gpt-4o-mini` | model names at the provider |
| `ADMIN_CHAT_IDS` | — | comma-separated Bale chat ids allowed without linking |
| `SERVICE_TOKEN` | — | optional bearer for the day procurement endpoints enforce auth |
| `DRAFT_TTL_HOURS` | `24` | drafts older than this are marked expired |
In compose these are fed from `BALE_BOT_TOKEN`, `BALE_API_BASE`, `AI_BASE_URL`,
`AI_API_KEY`, `AI_STT_MODEL`, `AI_LLM_MODEL`, `BALE_ADMIN_CHAT_IDS`,
`BALE_SERVICE_TOKEN`.
---
## How it's built
```
bot/
├── Dockerfile python:3.13-slim + ffmpeg (voice conversion)
├── app/
│ ├── main.py ensure tables → sweeper task → poll loop
│ ├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
│ ├── bale/client.py getUpdates/sendMessage/edit/callbacks/getFile+download
│ ├── api/client.py ERP /api/v1 client (X-Actor-Id attribution)
│ ├── stt.py ffmpeg (OGG/Opus → MP3) → /audio/transcriptions
│ ├── nlu.py Persian intent+entity extraction (JSON mode)
│ ├── resolve.py fuzzy name→id matching (difflib + ilike search)
│ ├── drafts.py draft store + QuoteCreate payload builder + missing-field logic
│ ├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
│ ├── texts.py Persian message templates
│ ├── runtime.py context, link store, in-memory pending flows
│ ├── models.py / db_init.py bot_links / bot_drafts / bot_messages / bot_meta (idempotent DDL)
│ └── handlers/ dispatch, commands, callbacks, links,
│ quote, supplier, material, queries, decisions
└── tools/smoke_test.py offline end-to-end harness (fake Bale/ERP/AI, in-memory store)
```
Design notes:
- **Draft state lives in postgres** (`bot_drafts.payload` JSONB in the shape of
the eventual API request), so drafts survive bot restarts; short-lived
interactions (linking, quote picking, accept/arrive confirmations) are
in-memory with a 30-minute TTL.
- **Callback grammar** fits Bale's 64-byte `callback_data`:
`q:ok:{draft8}` (commit), `pk:{draft8}:{field}:{idx}` (pick candidate),
`ac:ts:{i}` (choose payment terms), `ar:full` / `ar:ok` (delivery)…
- **Persian handling**: Persian/Arabic digits are normalized before parsing;
fuzzy matching folds ZWNJ and Arabic ya/ka; all user-facing text is Persian.
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3
before hitting the STT endpoint (accepted by essentially all providers).
- The **accept flow echoes the termsheet's existing `schedule_steps`** back to
`PATCH /quotes/{id}/accept` (the API replaces steps with what you send, so
the bot must send the current ones).
### Attribution tweak in the api (backward compatible)
`backend/app/utils/security.py::get_actor` now prefers an `X-Actor-Id` header
over the raw bearer token when attributing `quote_events.actor`. Requests
without that header behave exactly as before.
---
## Testing
Offline harness (no Bale token, no DB, no AI key — stubs everything and drives
the real handler pipeline with fake updates):
```powershell
docker compose run --rm bot python tools/smoke_test.py
# or, with the requirements installed locally:
cd bot && python tools/smoke_test.py
```
Covers: linking, voice→draft, ambiguous-material pick buttons, unit fallback,
draft refinement, commit payload shape (temp_id cross-references), supplier /
material commits, status digest, accept payload (steps echoed, per-item price),
arrival quantities («۵۰۰ و ۲۰۰»), cancel and unknown-intent handling.
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| Bot silent | wrong `BALE_BOT_TOKEN`; check `docker compose logs bot` |
| «پردازش هوشمند تنظیم نشده است» | `AI_BASE_URL` / `AI_API_KEY` missing |
| Voice → «نتوانستم پیام صوتی را تبدیل کنم» | provider rejected the model name (`STT_MODEL`) or key |
| «ERP API unreachable» | api container not up / wrong `API_BASE_URL` |
| Draft disappears | TTL expiry (`DRAFT_TTL_HOURS`) or someone pressed ❌ / `/cancel` |
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
# ERP integration pack
The agent is a **standalone container** — it talks to the ERP exclusively over
HTTP and keeps its own state in its own `bot_*` tables. Integrating it into the
[ERP stack](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) needs exactly
two things:
## 1. Compose service
Add [`erp-compose-service.yml`](erp-compose-service.yml) to the ERP's
`docker-compose.yml` (and the Coolify variant). The bot long-polls Bale, so it
needs **no ports, no webhook, no TLS** — it joins the compose `default` network
and reaches `http://api:8000/api/v1` in-network.
Credentials/env are passed at compose level from a `.env` next to the compose
file: `BALE_BOT_TOKEN`, `AI_BASE_URL`, `AI_API_KEY`, `AI_LLM_MODEL`
(see [`../.env.example`](../.env.example) for the full list).
> If your stack is started with multiple `-f` files (e.g. a prod_v2 checkout +
> port-remap override), keep using the same chain — the `bot` service merges in
> like any other service and the override's host-port remaps don't affect it.
## 2. Per-user attribution in the API
Apply [`x-actor-id.patch`](x-actor-id.patch) — a ~6-line, fully
backward-compatible change to `backend/app/utils/security.py`:
- before: `get_actor` returns the raw `Authorization: Bearer …` string and
`quote_events.actor` stores the bot's token;
- after: an `X-Actor-Id: <users.id>` header (sent by the bot for every mutating
call, carrying the **linked ERP user's id**) takes precedence, so the audit
trail records the human who asked, not the bot.
Requests without the header behave exactly as before.
## What the agent does NOT touch
- No ERP code is imported by the bot (HTTP-only, own postgres tables
`bot_links` / `bot_drafts` / `bot_messages` / `bot_meta`, created idempotently
at startup with `CREATE TABLE IF NOT EXISTS`).
- No Alembic migration needed — the bot owns its schema.
- The ERP's data model stays untouched; committed drafts flow through the
ordinary public endpoints (`POST /quotes`, `PATCH /quotes/{id}/accept`,
`PATCH /quotes/{id}/arrive-items`, `POST /suppliers`, `POST /materials`).
@@ -0,0 +1,39 @@
# ==========================================================================
# Add this service to the ERP stack's docker-compose.yml (and mirror it in
# docker-compose.coolify.yml with `container_name: procurement-bot`).
#
# It expects THIS repository checked out (or vendored) next to the ERP repo
# as `./bot` — either `git clone … bot` inside the ERP checkout, or change
# `context:` to an absolute/relative path to this repo.
#
# In-container URLs are unaffected by host port remaps (postgres/api are
# reached over the compose network, not host ports).
# ==========================================================================
services:
bot:
build:
context: ./bot # ← path to this repository
dockerfile: Dockerfile
container_name: procurement-bot
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-}
PROCUREMENT_BOT_STT_MODEL: ${AI_STT_MODEL:-whisper-1}
PROCUREMENT_BOT_STT_BASE_URL: ${AI_STT_BASE_URL:-}
PROCUREMENT_BOT_STT_API_KEY: ${AI_STT_API_KEY:-}
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-gpt-4o-mini}
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-}
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
restart: unless-stopped
@@ -0,0 +1,18 @@
diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py
index a78c1cb..75e7349 100644
--- a/backend/app/utils/security.py
+++ b/backend/app/utils/security.py
@@ -7,7 +7,13 @@ def get_actor(request: Request) -> str | None:
This is the reserved placeholder from the contract — no authentication
or authorization is enforced yet.
+
+ Services acting on behalf of a user (the Bale bot) pass ``X-Actor-Id``
+ (users.id); it wins over the raw token so the audit trail shows the human.
"""
+ actor_id = request.headers.get("X-Actor-Id")
+ if actor_id:
+ return actor_id.strip()
auth = request.headers.get("Authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
httpx>=0.27.0
sqlalchemy[asyncio]>=2.0.30
asyncpg>=0.29.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
+448
View File
@@ -0,0 +1,448 @@
"""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 = 100200300
_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 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.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 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):
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,
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_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):
draft.missing = missing
if review_message_id:
draft.review_message_id = review_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.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 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
"پرداخت سه فاز")
# ---------------------------------------------------------------------------
# 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
return ctx, bale, api
def text_update(update_id, text):
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
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
# ---- pure logic -------------------------------------------------------
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
payload = new_quote_payload()
merge_quote_patch(payload, {
"supplier_name": "سازان نو",
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
"payment_terms": [{"label": "۳ فاز"}],
})
merge_quote_patch(payload, {"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"] == "۳ فاز")
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)
check("build: blocked without supplier", body is None)
# ---- pipeline: linking gate -------------------------------------------
ctx, bale, api = build_ctx()
await H.handle_update(ctx, text_update(1, "سلام"))
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
await H.handle_update(ctx, text_update(2, "mohaghegh"))
await H.handle_update(ctx, text_update(3, "secret"))
check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh")
check("link: linked greeting", "اتصال انجام شد" 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, "قیمت میلگرد ۴۷ شد"))
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)
# ---- cancel / unknown --------------------------------------------------
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(19, "/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(20, "اسپاگتی بپز"))
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()))
+32
View File
@@ -0,0 +1,32 @@
{
"intent": "register_quote",
"patch": {
"supplier_name": "شرکت فولاد مبارکه",
"project_name": "برج آسمان",
"project_names": ["برج آسمان"],
"is_official": true,
"notes": "تحویل پای کارگاه",
"expiry_date": null,
"items": [
{
"material_name": "میلگرد",
"brand_name": null,
"specs": ["A3", "سایز ۱۴"],
"quantity": 5000,
"unit_name": "کیلوگرم",
"unit_price": 28500,
"notes": ""
}
],
"payment_terms": [
{
"label": "شرایط پرداخت",
"milestones": [
{"due_date": null, "amount": 50000000},
{"due_date": null, "amount": null}
]
}
]
},
"confidence": 0.95
}
+71
View File
@@ -0,0 +1,71 @@
import json
import urllib.request
api_key = "sk-a671eab15be846d494bf0839070a3b32"
url = "https://api.deepseek.com/chat/completions"
prompt = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
{
"intent": "register_quote",
"patch": {
"supplier_name": "نام تأمین‌کننده",
"project_name": "نام پروژه",
"project_names": ["نام پروژه"],
"is_official": true,
"notes": "یادداشت‌ها",
"expiry_date": "YYYY-MM-DD",
"items": [
{
"material_name": "فقط نام کلی کالا",
"brand_name": "نام کارخانه یا برند",
"specs": ["مشخصه ۱", "مشخصه ۲"],
"quantity": 5000,
"unit_name": "کیلوگرم",
"unit_price": 28500,
"notes": ""
}
],
"payment_terms": [
{
"label": "شرایط پرداخت",
"milestones": [
{"due_date": "YYYY-MM-DD", "amount": 50000000}
]
}
]
},
"confidence": 0.95
}
قوانین:
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
"""
user_msg = "استعلام قیمت از شرکت فولاد مبارکه برای ۵ تن میلگرد A3 سایز ۱۴ به قیمت هر کیلو ۲۸۵۰۰ تومن، تحویل پای کارگاه برج آسمان، پرداخت در دو مرحله ۵۰ میلیون نقد و مابقی تا آخر ماه"
payload = {
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": user_msg}
],
"response_format": {"type": "json_object"}
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
})
try:
resp = urllib.request.urlopen(req)
res_data = json.loads(resp.read().decode("utf-8"))
content = res_data["choices"][0]["message"]["content"]
with open("tests/deepseek_output.json", "w", encoding="utf-8") as out_f:
out_f.write(content)
print("DEEPSEEK SUCCESS! Saved to tests/deepseek_output.json")
print("KEYS IN EXTRACTED JSON:", list(json.loads(content).get("patch", {}).keys()))
except Exception as e:
print("DEEPSEEK ERROR:", e)
+123
View File
@@ -0,0 +1,123 @@
import json
import sys
import types
from pathlib import Path
# Stub external dependencies if not present in host python
for mod in ["sqlalchemy", "sqlalchemy.ext", "sqlalchemy.ext.asyncio", "sqlalchemy.orm", "asyncpg", "pydantic", "pydantic_settings", "httpx"]:
if mod not in sys.modules:
m = types.ModuleType(mod)
sys.modules[mod] = m
sys.modules["pydantic_settings"].BaseSettings = type("BaseSettings", (), {})
sys.modules["pydantic_settings"].SettingsConfigDict = lambda **k: None
sys.modules["pydantic"].BaseModel = type("BaseModel", (), {})
# Stub specific symbols needed by app.db and app.models
sys.modules["sqlalchemy"].select = lambda *a, **k: None
sys.modules["sqlalchemy"].update = lambda *a, **k: None
sys.modules["sqlalchemy"].func = types.SimpleNamespace(gen_random_uuid=lambda: None, now=lambda: None)
sys.modules["sqlalchemy"].BigInteger = type("BigInteger", (), {})
sys.modules["sqlalchemy"].DateTime = lambda *a, **k: None
sys.modules["sqlalchemy"].Index = lambda *a, **k: None
sys.modules["sqlalchemy"].Integer = type("Integer", (), {})
sys.modules["sqlalchemy"].String = lambda *a, **k: None
sys.modules["sqlalchemy"].Text = type("Text", (), {})
sys.modules["sqlalchemy.dialects.postgresql"] = types.ModuleType("sqlalchemy.dialects.postgresql")
sys.modules["sqlalchemy.dialects.postgresql"].JSONB = lambda *a, **k: None
sys.modules["sqlalchemy.dialects.postgresql"].UUID = lambda *a, **k: None
sys.modules["sqlalchemy.orm"].DeclarativeBase = type("DeclarativeBase", (), {})
sys.modules["sqlalchemy.orm"].Mapped = type("Mapped", (), {})
sys.modules["sqlalchemy.orm"].mapped_column = lambda *a, **k: None
sys.modules["sqlalchemy.ext.asyncio"].create_async_engine = lambda *a, **k: None
sys.modules["sqlalchemy.ext.asyncio"].async_sessionmaker = lambda *a, **k: None
# Add 'ai procurement' to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ai procurement"))
from app.drafts import (
new_quote_payload,
merge_quote_patch,
build_quote_request,
compute_quote_missing,
)
def test_wizard_conversion():
mock_path = Path(__file__).resolve().parents[1] / "tests" / "wizard_mock_data.json"
with open(mock_path, encoding="utf-8") as f:
data = json.load(f)
# 1. Start with clean payload
payload = new_quote_payload()
# 2. Extract patch from wizard mock data (simulating NLU patch extraction)
s1 = data["step1_topic_and_supplier"]
s2 = data["step2_line_items"]
s3 = data["step3_termsheets"]
s4 = data["step4_projects"]
s5 = data["step5_review_and_metadata"]
patch = {
"supplier_name": s1["supplier_name"],
"is_official": s1["is_official"],
"project_names": s4["projects"],
"expiry_date": s5["expiry_date"],
"notes": s5["notes"],
"items": [
{
"material_name": it["material_name"],
"brand_name": it["brand"],
"specs": it["specs"],
"quantity": it["quantity"],
"unit_name": it["unit"],
"unit_price": it["unit_price"],
}
for it in s2
],
"payment_terms": [
{
"label": ts["label"],
"milestones": ts["milestones"],
}
for ts in s3
],
}
# 3. Merge patch into payload
changed = merge_quote_patch(payload, patch)
assert changed, "Patch should have changed payload"
# 4. Simulate resolution
payload["supplier_id"] = "11111111-1111-1111-1111-111111111111"
payload["project_ids"] = ["22222222-2222-2222-2222-222222222222"]
for i, it in enumerate(payload["items"], start=1):
it["material_id"] = f"33333333-3333-3333-3333-33333333333{i}"
it["unit_id"] = 3
# 5. Build QuoteCreate request
body, problems = build_quote_request(payload)
assert problems == [], f"Expected no problems, got {problems}"
assert body is not None, "Body should not be None"
assert body["supplier_id"] == "11111111-1111-1111-1111-111111111111"
assert body["is_official"] is True
assert len(body["line_items"]) == 2
assert body["line_items"][0]["temp_id"] == "t1"
assert "A3، سایز ۱۴" in body["line_items"][0]["notes"]
assert "فولاد مبارکه" in body["line_items"][0]["notes"]
assert len(body["termsheets"]) == 1
assert body["termsheets"][0]["temp_id"] == "p1"
assert len(body["termsheets"][0]["schedule_steps"]) == 3
assert body["termsheets"][0]["schedule_steps"][0]["step_no"] == 1
assert body["termsheets"][0]["schedule_steps"][0]["amount"] == 300000000.0
assert len(body["item_termsheets"]) == 2
assert body["item_termsheets"][0]["line_item_temp_id"] == "t1"
assert body["item_termsheets"][0]["termsheet_temp_id"] == "p1"
assert body["item_termsheets"][0]["unit_price"] == 48500.0
print("ALL WIZARD CONVERSION ASSERTIONS PASSED SUCCESSFULLY! [OK]")
if __name__ == "__main__":
test_wizard_conversion()
+86
View File
@@ -0,0 +1,86 @@
{
"step1_topic_and_supplier": {
"topic": "آهن‌آلات و مقاطع فولادی",
"supplier_name": "فولاد آذرخش",
"is_official": true,
"new_supplier_details": {
"location": "تهران - ونک",
"phone": "02188776655",
"contact_person": "حسینی"
}
},
"step2_line_items": [
{
"material_name": "میلگرد",
"brand": "فولاد مبارکه",
"specs": [
"A3",
"سایز ۱۴"
],
"quantity": 12500.0,
"unit": "کیلوگرم",
"unit_price": 48500.0,
"total_price": 606250000.0
},
{
"material_name": "میلگرد",
"brand": "فولاد مبارکه",
"specs": [
"A3",
"سایز ۱۶"
],
"quantity": 8000.0,
"unit": "کیلوگرم",
"unit_price": 47200.0,
"total_price": 377600000.0
}
],
"step3_termsheets": [
{
"label": "گزینه ۱",
"milestones": [
{
"due_date": "2026-09-22",
"amount": 300000000.0
},
{
"due_date": "2026-09-28",
"amount": 450000000.0
},
{
"due_date": "2026-10-15",
"amount": 233850000.0
}
],
"covered_items": [
"میلگرد - فولاد مبارکه - A3 - سایز ۱۴",
"میلگرد - فولاد مبارکه - A3 - سایز ۱۶"
]
}
],
"step4_projects": {
"projects": [
"برج آسمان"
],
"item_project_assignments": [
{
"item_name": "میلگرد - فولاد مبارکه - A3 - سایز ۱۴",
"assigned_projects": [
"برج آسمان"
]
},
{
"item_name": "میلگرد - فولاد مبارکه - A3 - سایز ۱۶",
"assigned_projects": [
"برج آسمان"
]
}
]
},
"step5_review_and_metadata": {
"quote_date": "2026-09-19T08:30:00Z",
"expiry_date": "2026-10-05",
"notes": "تحویل پای کارگاه برج آسمان. کرایه حمل با فروشنده است.",
"proforma_file": "پیش‌فاکتور_فولاد_آذرخش_۱۴۰۳.pdf"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"step1_topic_and_supplier": {
"topic": null,
"supplier_name": null,
"is_official": null,
"new_supplier_details": {
"location": null,
"phone": null,
"contact_person": null
}
},
"step2_line_items": [
{
"material_name": null,
"brand": null,
"specs": [],
"quantity": null,
"unit": null,
"unit_price": null,
"total_price": null
}
],
"step3_termsheets": [
{
"label": null,
"milestones": [
{
"due_date": null,
"amount": null
}
],
"covered_items": []
}
],
"step4_projects": {
"projects": [],
"item_project_assignments": [
{
"item_name": null,
"assigned_projects": []
}
]
},
"step5_review_and_metadata": {
"quote_date": null,
"expiry_date": null,
"notes": null,
"proforma_file": null
}
}