4 Commits
Author SHA1 Message Date
Milad df3ff4ea14 feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform 2026-10-02 03:47:33 +03:30
Milad e9fef5a104 feat(pm-panel): pm panel level gating — RequirePmPanel/HomeRoute/menu honor panels.pm (full/limited); pm-only users land directly in the PM panel 2026-09-29 13:45:37 +03:30
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30
Milad c7d860f431 feat(inventory): limit supervisor access to own project inventory and remove rectangular change inventory icon 2026-09-27 18:42:05 +03:30
113 changed files with 2641 additions and 6106 deletions
-4
View File
@@ -26,12 +26,8 @@ frontend/.env
# ---- Local session / backups ---- # ---- Local session / backups ----
.zcode/ .zcode/
.zcodeignore
scratch/
v1.2 - Copy/ v1.2 - Copy/
inv dash/ inv dash/
bot/
voice/
# ---- Machine-local compose overrides (LAN phone access, etc.) ---- # ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml docker-compose.override.yml
-28
View File
@@ -1,28 +0,0 @@
# 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
@@ -1,14 +0,0 @@
# ---- Env / secrets ----
.env
*.local
# ---- Python ----
__pycache__/
*.pyc
*.pyo
.venv/
venv/
# ---- OS junk ----
.DS_Store
Thumbs.db
-23
View File
@@ -1,23 +0,0 @@
# 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
@@ -1,120 +0,0 @@
# 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
@@ -1,2 +0,0 @@
"""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
@@ -1 +0,0 @@
"""ERP API client — the only way the bot touches procurement data."""
-137
View File
@@ -1,137 +0,0 @@
"""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
@@ -1 +0,0 @@
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
-186
View File
@@ -1,186 +0,0 @@
"""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 delete_message(self, chat_id: int, message_id: int) -> bool:
payload: dict = {"chat_id": chat_id, "message_id": message_id}
try:
return bool(await self._call("deleteMessage", payload))
except BaleError as exc:
logger.warning("deleteMessage failed for chat %s msg %s: %s", chat_id, message_id, exc)
return False
except Exception as exc:
logger.warning("unexpected error in deleteMessage for chat %s msg %s: %s", chat_id, message_id, exc)
return False
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
@@ -1,77 +0,0 @@
"""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
@@ -1,7 +0,0 @@
"""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)
-81
View File
@@ -1,81 +0,0 @@
"""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),
source_message_id BIGINT,
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",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts (review_message_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts (source_message_id)",
"""
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)
-474
View File
@@ -1,474 +0,0 @@
"""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,
source_message_id: int | None = None) -> BotDraft:
async with SessionLocal() as session:
draft = BotDraft(
chat_id=chat_id,
kind=kind,
payload=payload,
source=source,
source_message_id=source_message_id,
)
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_message_id(chat_id: int, message_id: int) -> BotDraft | None:
"""Find the active draft associated with a given message_id (review card or user source message)."""
async with SessionLocal() as session:
stmt = (
select(BotDraft)
.where(
BotDraft.chat_id == chat_id,
BotDraft.status == "active",
(BotDraft.review_message_id == message_id) | (BotDraft.source_message_id == message_id),
)
.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,
source_message_id: int | None = None,
mark_bale_synced: bool = False) -> None:
async with SessionLocal() as session:
effective_review_msg_id = review_message_id if review_message_id is not None else draft.review_message_id
values: dict = {
"payload": draft.payload,
"missing": missing,
"review_message_id": effective_review_msg_id,
}
if source_message_id is not None:
values["source_message_id"] = source_message_id
draft.source_message_id = source_message_id
if mark_bale_synced:
values["last_bale_update_at"] = func.now()
await session.execute(
update(BotDraft)
.where(BotDraft.id == draft.id)
.values(**values)
)
await session.commit()
draft.review_message_id = effective_review_msg_id
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]
-215
View File
@@ -1,215 +0,0 @@
"""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
message_id = message.get("message_id")
reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
target_draft = None
if reply_to_msg_id is not None:
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None:
await ctx.send(
chat_id,
"⚠️ پیش‌نویس فعال مربوط به این پیام یافت نشد (ممکن است ثبت نهایی، منقضی یا لغو شده باشد).\nبرای ثبت استعلام جدید، پیام خود را بدون ریپلای ارسال فرمایید.",
reply_to=message_id,
)
return
draft_summary = _draft_summary(target_draft) if target_draft else None
result = await understand(settings, text, draft_summary)
intent, patch = result.get("intent"), result.get("patch") or {}
logger.info("chat %s intent=%s conf=%s (is_edit=%s)", chat_id, intent, result.get("confidence"), target_draft is not None)
if intent in ("register_quote", "update_draft"):
await quote.handle_entities(
ctx, chat_id, user, patch, source,
incoming_msg_id=message_id,
target_draft=target_draft,
)
elif intent == "commit_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
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 = target_draft or await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
if active.review_message_id:
try:
await ctx.delete(chat_id, active.review_message_id)
except Exception:
pass
ctx.pending.pop(chat_id, None)
await ctx.send(chat_id, CANCELLED, reply_to=message_id)
elif intent == "show_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
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
@@ -1,107 +0,0 @@
"""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
@@ -1,82 +0,0 @@
"""/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
@@ -1,389 +0,0 @@
"""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
@@ -1,46 +0,0 @@
"""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
@@ -1,98 +0,0 @@
"""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
@@ -1,44 +0,0 @@
"""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)))
-316
View File
@@ -1,316 +0,0 @@
"""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,
incoming_msg_id: int | None = None,
target_draft = None,
) -> None:
prev_review_msg_id = None
if target_draft is not None:
draft = target_draft
prev_review_msg_id = draft.review_message_id
draft.review_message_id = None
payload = draft.payload
merge_quote_patch(payload, patch)
draft.source_message_id = incoming_msg_id
else:
payload = new_quote_payload()
merge_quote_patch(payload, patch)
draft = await draft_store.create(
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
)
await resolve_payload(ctx, payload)
if prev_review_msg_id:
try:
await ctx.delete(chat_id, prev_review_msg_id)
except Exception as e:
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
# ---------------------------------------------------------------------------
# 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,
reply_to: int | 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, reply_to=reply_to)
new_review_id = (message or {}).get("message_id")
if new_review_id:
draft.review_message_id = new_review_id
await draft_store.save_payload(
draft,
missing={"fields": missing},
review_message_id=new_review_id,
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
else:
await draft_store.save_payload(
draft,
missing={"fields": missing},
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
else:
if not draft.review_message_id:
return
success = await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
if success:
await draft_store.save_payload(draft, missing={"fields": missing}, mark_bale_synced=True)
else:
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
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
@@ -1,103 +0,0 @@
"""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
@@ -1,74 +0,0 @@
"""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
@@ -1,60 +0,0 @@
"""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
-85
View File
@@ -1,85 +0,0 @@
"""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"),
Index("ix_bot_drafts_review_msg", "review_message_id"),
Index("ix_bot_drafts_source_msg", "source_message_id"),
)
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
source_message_id: Mapped[int | None] = mapped_column(BigInteger)
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
@@ -1,149 +0,0 @@
"""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
@@ -1,151 +0,0 @@
"""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", [])
-170
View File
@@ -1,170 +0,0 @@
"""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,
reply_to: int | None = None) -> dict:
"""Send + audit-log every outbound message."""
try:
message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup, reply_to=reply_to)
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) -> bool:
if not message_id:
return False
try:
await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup)
return True
except Exception as exc:
logger.warning("failed to edit message %s in chat %s: %s", message_id, chat_id, exc)
return False
async def delete(self, chat_id: int, message_id: int | None) -> bool:
if not message_id:
return False
try:
return await self.bale.delete_message(chat_id, message_id)
except Exception:
logger.exception("failed to delete message %s in chat %s", message_id, chat_id)
return False
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
@@ -1,62 +0,0 @@
"""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
@@ -1,59 +0,0 @@
"""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
@@ -1,254 +0,0 @@
"""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
@@ -1,172 +0,0 @@
# 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
@@ -1,44 +0,0 @@
# 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`).
@@ -1,39 +0,0 @@
# ==========================================================================
# 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
@@ -1,18 +0,0 @@
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
@@ -1,5 +0,0 @@
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
@@ -1,448 +0,0 @@
"""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()))
+79 -11
View File
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.db.engine import AsyncSessionLocal from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.utils.errors import ( from app.utils.errors import (
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
@@ -106,6 +106,10 @@ class UserContext:
project_id: object | None # uuid.UUID | None — default (first) project of a PM project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs) project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str display_name: str
# لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...}
panels: dict | None = None
# عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM)
member_project_ids: list | None = None
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
if not casdoor_id: if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization") raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
username = str(claims.get("name") or claims.get("preferred_username") or "")
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None: if user is None:
user = User( user = User(
casdoor_id=casdoor_id, casdoor_id=casdoor_id,
username=username or None,
first_name=str( first_name=str(
claims.get("firstName") claims.get("firstName")
or claims.get("firstname") or claims.get("firstname")
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
elif username and not user.username:
user.username = username
await db.flush()
project_ids: list = [] # لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
if user.role == "pm": panels: dict = {}
projects = ( panel_rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
)
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
await db.execute( await db.execute(
select(Project) select(UserProjectMembership)
.where(Project.pm_user_id == user.id) .where(UserProjectMembership.user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(UserProjectMembership.created_at)
) )
).scalars().all() ).scalars().all()
project_ids = [p.id for p in projects] ]
pm_projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
pm_project_ids_list = [p.id for p in pm_projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
@@ -155,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
project_id=project_id, project_id=project_id,
project_ids=project_ids, project_ids=project_ids,
display_name=display_name, display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
) )
async def pm_project_ids(db: AsyncSession, user: User) -> list: async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM user (oldest first).""" """Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
rows = ( pm_rows = (
await db.execute( await db.execute(
select(Project.id) select(Project.id)
.where(Project.pm_user_id == user.id) .where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
return list(rows) member_rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None: async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
@@ -217,3 +257,31 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
return _dep return _dep
def require_procurement_writer():
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
(دوره مهاجرت از مدل نقش به مدل پنل)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") == "full":
return ctx
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
return _dep
def allow_quote_create():
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
(محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
return ctx
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
return _dep
+1 -35
View File
@@ -174,7 +174,7 @@ _V26_DDL = [
""", """,
""" """
DO $$ BEGIN DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
EXCEPTION WHEN duplicate_object THEN NULL; END $$ EXCEPTION WHEN duplicate_object THEN NULL; END $$
""", """,
""" """
@@ -432,37 +432,3 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
for stmt in indexes: for stmt in indexes:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("audit_log table ensured") logger.info("audit_log table ensured")
async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
"""Ensure bot_drafts table and indexes exist for Bale bot quote sync."""
ddl = """
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),
source_message_id BIGINT,
review_message_id BIGINT,
last_bale_update_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"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",
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts(review_message_id)",
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts(source_message_id)",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("bot_drafts table ensured")
-5
View File
@@ -27,7 +27,6 @@ from app.db.engine import AsyncSessionLocal, engine
from app.db.init_db import ( from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_bot_drafts_table,
ensure_drop_category_tables, ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
@@ -108,7 +107,6 @@ async def lifespan(_app: FastAPI):
await ensure_v26(engine) await ensure_v26(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine) await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try: try:
yield yield
@@ -235,15 +233,12 @@ from app.routers.auth import router as _auth_router # noqa: E402
# attach an Authorization header to <iframe>/<img> loads, so the two file GET # attach an Authorization header to <iframe>/<img> loads, so the two file GET
# routes accept a short-lived signed ?t= token as well as a Bearer session. # routes accept a short-lived signed ?t= token as well as a Bearer session.
from app.routers.files import router as _files_router # noqa: E402 from app.routers.files import router as _files_router # noqa: E402
from app.routers.drafts import router as _drafts_router # noqa: E402
for router in ROUTERS: for router in ROUTERS:
if router is _auth_router: if router is _auth_router:
app.include_router(router, prefix=API_V1_PREFIX) app.include_router(router, prefix=API_V1_PREFIX)
elif router is _files_router: elif router is _files_router:
app.include_router(router, prefix=API_V1_PREFIX) app.include_router(router, prefix=API_V1_PREFIX)
elif router is _drafts_router:
app.include_router(router, prefix=API_V1_PREFIX)
else: else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)]) app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
-21
View File
@@ -1,21 +0,0 @@
from datetime import datetime
from uuid import UUID
from sqlalchemy import BigInteger, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from app.db.base import Base
class BotDraft(Base):
__tablename__ = "bot_drafts"
id = Column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
chat_id = Column(BigInteger, nullable=False)
kind = Column(String(16), nullable=False)
payload = Column(JSONB, nullable=False, default=dict)
missing = Column(JSONB)
status = Column(String(16), nullable=False, default="active")
source = Column(String(16))
source_message_id = Column(BigInteger)
review_message_id = Column(BigInteger)
last_bale_update_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+54 -1
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum( user_role_enum = Enum(
"pm", "procurement_manager", "admin", "pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True, name="user_role", native_enum=True,
) )
quote_status_enum = Enum( quote_status_enum = Enum(
@@ -58,6 +58,7 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
username: Mapped[str | None] = mapped_column(Text)
first_name: Mapped[str] = mapped_column(Text, nullable=False) first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text) phone: Mapped[str | None] = mapped_column(Text)
@@ -65,6 +66,47 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
class UserPanel(Base):
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
panel: 'procurement' | 'inventory' | 'pm'
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها
(به‌جز تغییر موجودی)
'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛
انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی
"""
__tablename__ = "user_panels"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
panel: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
class UserProjectMembership(Base):
"""user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت
پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند."""
__tablename__ = "user_project_members"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
class Supplier(Base): class Supplier(Base):
__tablename__ = "suppliers" __tablename__ = "suppliers"
@@ -148,12 +190,23 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
start_date: Mapped[date | None] = mapped_column(Date) start_date: Mapped[date | None] = mapped_column(Date)
planned_start: Mapped[date | None] = mapped_column(Date)
planned_end: Mapped[date | None] = mapped_column(Date)
actual_start: Mapped[date | None] = mapped_column(Date)
actual_end: Mapped[date | None] = mapped_column(Date)
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
# v2.6: the single PM responsible for this project (confirms arrivals). # v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column( pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL") ForeignKey("users.id", ondelete="SET NULL")
) )
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Quote(Base): class Quote(Base):
-2
View File
@@ -4,7 +4,6 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
drafts,
files, files,
inventory, inventory,
item_decisions, item_decisions,
@@ -52,5 +51,4 @@ ROUTERS = [
inventory.router, inventory.router,
pm.router, pm.router,
users.router, users.router,
drafts.router,
] ]
+16 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec from app.models.procurement import LineItemSpec
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
_procurement_writer = require_procurement_writer()
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)): async def create_attribute_definition(
payload: AttributeDefinitionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key)) duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None: if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -102,7 +108,10 @@ async def get_attribute_definition(
@router.patch("/{definition_id}") @router.patch("/{definition_id}")
async def update_attribute_definition( async def update_attribute_definition(
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db) definition_id,
payload: AttributeDefinitionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ad = await db.get(AttributeDefinition, definition_id) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
@@ -136,7 +145,11 @@ async def update_attribute_definition(
@router.delete("/{definition_id}") @router.delete("/{definition_id}")
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)): async def delete_attribute_definition(
definition_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ad = await db.get(AttributeDefinition, definition_id) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد") raise NotFoundError("تعریف ویژگی یافت نشد")
+5 -2
View File
@@ -22,7 +22,7 @@ from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict from app.routers.users import _user_projects, user_to_dict
from app.services import analytics from app.services import analytics
from app.utils.errors import ServiceUnavailableError, UnauthorizedError from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth") logger = logging.getLogger("procurement.auth")
@@ -78,6 +78,9 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token) claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db) ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track( await analytics.track(
"login_success", "login_success",
{"username": payload.username, "role": ctx.role}, {"username": payload.username, "role": ctx.role},
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": body.get("expires_in"), "expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, projects), "user": await user_to_dict(ctx.user, projects, db),
} }
} }
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem from app.models.procurement import LineItem
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_procurement_writer()
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
@@ -70,7 +72,11 @@ async def list_brand_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)): async def create_brand_material(
payload: BrandMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
if await db.get(Brand, payload.brand_id) is None: if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id") raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None: if await db.get(Material, payload.material_id) is None:
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
@router.patch("/{bm_id}") @router.patch("/{bm_id}")
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)): async def update_brand_material(
bm_id,
payload: BrandMaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
@router.delete("/{bm_id}") @router.delete("/{bm_id}")
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)): async def delete_brand_material(
bm_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"]) router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict: def brand_to_dict(brand: Brand) -> dict:
@@ -42,7 +44,11 @@ async def list_brands(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)): async def create_brand(
payload: BrandCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all() rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows): if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{brand_id}") @router.patch("/{brand_id}")
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)): async def update_brand(
brand_id,
payload: BrandUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
@router.delete("/{brand_id}") @router.delete("/{brand_id}")
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)): async def delete_brand(
brand_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
-106
View File
@@ -1,106 +0,0 @@
"""Drafts router — exposes bot_drafts to frontend for Wizard pre-population and bi-directional sync."""
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.draft import BotDraft
from app.utils.errors import NotFoundError
router = APIRouter(prefix="/drafts", tags=["drafts"])
class DraftUpdateIn(BaseModel):
payload: dict[str, Any] | None = None
status: str | None = None
class DraftCommitIn(BaseModel):
quote_id: str | None = None
def _serialize_draft(draft: BotDraft) -> dict:
return {
"id": str(draft.id),
"chat_id": draft.chat_id,
"kind": draft.kind,
"status": draft.status,
"payload": draft.payload or {},
"missing": draft.missing or [],
"source": draft.source,
"review_message_id": draft.review_message_id,
"last_bale_update_at": draft.last_bale_update_at.isoformat() if draft.last_bale_update_at else None,
"created_at": draft.created_at.isoformat() if draft.created_at else None,
"updated_at": draft.updated_at.isoformat() if draft.updated_at else None,
}
@router.get("/{draft_id}")
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
return {"data": _serialize_draft(draft)}
@router.patch("/{draft_id}")
async def update_draft(draft_id: UUID, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
if body.payload is not None:
merged = dict(draft.payload or {})
merged.update(body.payload)
draft.payload = merged
if body.status is not None:
draft.status = body.status
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/{draft_id}/commit")
async def commit_draft(draft_id: UUID, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "committed"
payload = dict(draft.payload or {})
if body.quote_id:
payload["committed_quote_id"] = body.quote_id
draft.payload = payload
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
@router.post("/{draft_id}/cancel")
async def cancel_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
stmt = select(BotDraft).where(BotDraft.id == draft_id)
draft = (await db.execute(stmt)).scalar_one_or_none()
if not draft:
raise NotFoundError("پیش‌نویس استعلام یافت نشد")
draft.status = "cancelled"
draft.updated_at = func.now()
await db.commit()
await db.refresh(draft)
return {"data": _serialize_draft(draft)}
+4 -3
View File
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"]) router = APIRouter(prefix="/quotes", tags=["files"])
# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود # حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
# برای هر کاربر واردشده آزاد است. # برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin") _attachment_deleter = require_role("procurement_manager", "admin")
_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = { ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
@@ -125,7 +126,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) @router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction # Proforma — no status restriction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) @router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
+95 -41
View File
@@ -58,6 +58,7 @@ class Scope:
role: str role: str
project_id: uuid.UUID | None project_id: uuid.UUID | None
actor: str | None actor: str | None
project_ids: list[uuid.UUID] | None = None
async def inventory_scope( async def inventory_scope(
@@ -75,25 +76,41 @@ async def inventory_scope(
if request.headers.get("authorization"): if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db) user: UserContext = await get_optional_user(request, db)
if user is not None: if user is not None:
if user.role is None: panels = user.panels or {}
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") if user.role is None and not panels:
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
if user.role == "procurement_manager": # ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
if user.role not in ("pm", "admin"): # لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role") if "inventory" in panels:
if user.role == "pm": if panels["inventory"] == "full":
allowed = list(user.project_ids or []) # کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
# limited: فقط پروژه‌های عضویت + امکان تغییر موجودی
allowed = list(user.member_project_ids or [])
if not allowed:
raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است")
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="pm", project_id=project_id, actor=user.display_name) return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role in ("procurement_manager", "pm"):
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
if user.role == "supervisor":
allowed = list(user.project_ids or [])
if not allowed: if not allowed:
raise ValidationError( raise ForbiddenError("پروژه تخصیص داده نشده است")
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id" if project_id is not None:
) if project_id not in allowed:
return Scope(role="pm", project_id=allowed[0], actor=user.display_name) raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ValidationError("نقش نامعتبر است", field="role")
# Dev fallback: header-based identity (fork's placeholder mode) — only # Dev fallback: header-based identity (fork's placeholder mode) — only
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
@@ -128,9 +145,9 @@ async def inventory_scope(
return Scope(role=role, project_id=project_id, actor=actor) return Scope(role=role, project_id=project_id, actor=actor)
def _require_pm(scope: Scope) -> None: def _require_inventory_modifier(scope: Scope) -> None:
if scope.role != ROLE_PM: if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست") raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -215,8 +232,16 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Warehouse) stmt = select(Warehouse)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name) stmt = stmt.order_by(Warehouse.name)
@@ -248,9 +273,11 @@ async def create_warehouse(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه) # PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role == ROLE_PM and payload.project_id != scope.project_id: if scope.role in (ROLE_PM, "supervisor"):
raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id") allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if payload.project_id not in allowed:
raise ValidationError("شما فقط می‌توانید برای پروژه تخصیص‌یافته به خود انبار بسازید", field="project_id")
if await db.get(Project, payload.project_id) is None: if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description) wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
@@ -267,8 +294,9 @@ async def update_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
if scope.project_id != wh.project_id: allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None: if payload.name is not None:
wh.name = payload.name wh.name = payload.name
@@ -292,8 +320,10 @@ async def delete_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and scope.project_id != wh.project_id: if scope.role in (ROLE_PM, "supervisor"):
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
# inventory rows cascade via DDL; transactions are RESTRICTed through # inventory rows cascade via DDL; transactions are RESTRICTed through
# inventory cascade — block deletion if any stock history exists. # inventory cascade — block deletion if any stock history exists.
linked = await db.scalar( linked = await db.scalar(
@@ -323,10 +353,22 @@ async def list_inventory(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(InventoryItem) stmt = select(InventoryItem)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
Warehouse.project_id == scope.project_id if project_id is not None:
) if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
elif allowed:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id.in_(allowed)
)
else:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id
)
elif project_id is not None: elif project_id is not None:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id Warehouse.project_id == project_id
@@ -388,8 +430,16 @@ async def list_transactions(
stmt = select(InventoryTransaction).join( stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
if warehouse_id is not None: if warehouse_id is not None:
@@ -454,8 +504,10 @@ async def inventory_history(
if item is None: if item is None:
raise NotFoundError("موجودی یافت نشد") raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id) wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id: if scope.role in (ROLE_PM, "supervisor"):
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute( txs = (await db.execute(
select(InventoryTransaction) select(InventoryTransaction)
.where(InventoryTransaction.inventory_id == inventory_id) .where(InventoryTransaction.inventory_id == inventory_id)
@@ -474,18 +526,20 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_require_pm(scope) _require_inventory_modifier(scope)
# No explicit db.begin(): the session may already be inside an implicit # No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db # transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure. # commits on success / rolls back on failure.
with nullcontext(): with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id) wh = await get_warehouse_or_404(db, payload.warehouse_id)
if wh.project_id != scope.project_id: allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") if scope.role in (ROLE_PM, "supervisor"):
if payload.dest_warehouse_id is not None: if wh.project_id not in allowed:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if dest.project_id != scope.project_id: if payload.dest_warehouse_id is not None:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id not in allowed:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor) txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record( await audit_record(
db, db,
+16 -2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -14,6 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"], tags=["item-receipts"],
) )
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)): async def create_receipt(
quote_id,
line_item_id,
payload: ReceiptCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id) await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id) decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
@router.patch("") @router.patch("")
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)): async def update_receipt(
quote_id,
line_item_id,
payload: ReceiptUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id) await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None: if decision is None:
+18 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -23,6 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"], tags=["item-termsheets"],
) )
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_item_termsheet( async def create_item_termsheet(
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
payload: ItemTermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
@@ -93,7 +99,12 @@ async def create_item_termsheet(
@router.patch("/{item_termsheet_id}") @router.patch("/{item_termsheet_id}")
async def update_item_termsheet( async def update_item_termsheet(
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
item_termsheet_id,
payload: ItemTermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
@@ -114,7 +125,11 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}") @router.delete("/{item_termsheet_id}")
async def delete_item_termsheet( async def delete_item_termsheet(
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
item_termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
it = await db.scalar( it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
+9 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
@router.put("") @router.put("")
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)): async def put_line_item_specs(
quote_id,
line_item_id,
payload: SpecsPutIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement — specs not in the payload are DELETED (P1-19).""" """FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
+19 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, Unit from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote from app.models.procurement import ItemDecision, LineItem, Quote
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -50,7 +52,12 @@ async def list_line_items(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): async def create_line_item(
quote_id,
payload: LineItemCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -89,7 +96,11 @@ async def get_line_item(
@router.patch("/{line_item_id}") @router.patch("/{line_item_id}")
async def update_line_item( async def update_line_item(
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) quote_id,
line_item_id,
payload: LineItemUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
@@ -131,7 +142,12 @@ async def update_line_item(
@router.delete("/{line_item_id}") @router.delete("/{line_item_id}")
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): async def delete_line_item(
quote_id,
line_item_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier from app.models.procurement import Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"]) router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict: def category_to_dict(category: MaterialCategory) -> dict:
@@ -46,7 +48,11 @@ async def list_categories(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)): async def create_category(
payload: MaterialCategoryCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None: if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{category_id}") @router.patch("/{category_id}")
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)): async def update_category(
category_id,
payload: MaterialCategoryUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
@router.delete("/{category_id}") @router.delete("/{category_id}")
async def delete_category(category_id, db: AsyncSession = Depends(get_db)): async def delete_category(
category_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
+36 -7
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import ( from app.models.catalog import (
AttributeDefinition, AttributeDefinition,
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"]) router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str): def _coerce_filter_value(data_type: str, raw: str):
@@ -125,7 +127,10 @@ async def list_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_material( async def create_material(
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db) payload: MaterialCreate,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
material = await material_service.create_material(db, payload) material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))} return {"data": await serialize_material(db, material, parse_expand(expand))}
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
@router.patch("/{material_id}") @router.patch("/{material_id}")
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)): async def update_material(
material_id,
payload: MaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
@router.delete("/{material_id}") @router.delete("/{material_id}")
async def delete_material(material_id, db: AsyncSession = Depends(get_db)): async def delete_material(
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -248,7 +262,10 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201) @router.post("/{material_id}/units", status_code=201)
async def create_material_unit( async def create_material_unit(
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db) material_id,
payload: MaterialUnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
await _material_or_404(db, material_id) await _material_or_404(db, material_id)
@@ -287,7 +304,11 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}") @router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit( async def update_material_unit(
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db) material_id,
mu_id,
payload: MaterialUnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
mu = await db.scalar( mu = await db.scalar(
@@ -319,7 +340,12 @@ async def update_material_unit(
@router.delete("/{material_id}/units/{mu_id}") @router.delete("/{material_id}/units/{mu_id}")
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)): async def delete_material_unit(
material_id,
mu_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
mu = await db.scalar( mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
) )
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
@router.put("/{material_id}/attribute-values") @router.put("/{material_id}/attribute-values")
async def put_material_attribute_values( async def put_material_attribute_values(
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db) material_id,
payload: MaterialAttributeValuesPut,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
"""FULL replacement of a material's EAV values (empty array deletes all).""" """FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin(): async with db.begin():
+39 -18
View File
@@ -71,16 +71,17 @@ class ArrivalScope:
user_id: uuid.UUID | None user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's active project; admin: optional filter project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope( async def arrivals_scope(
request: Request, request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"), project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ArrivalScope: ) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM → scoped to the requested project, which must be one they manage PM / supervisor → scoped to the requested project, which must be one they manage
(projects.pm_user_id, 1:N); without the param, their first project. (projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter). Admin / procurement manager → all projects (param = optional filter).
""" """
@@ -89,19 +90,19 @@ async def arrivals_scope(
if ctx is not None: if ctx is not None:
if ctx.role is None: if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm": if ctx.role in ("pm", "supervisor"):
allowed = list(ctx.project_ids or []) allowed = list(ctx.project_ids or [])
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
if not allowed: if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
if ctx.role in ("admin", "procurement_manager"): if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است") raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to # Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor). # admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would # Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM. # let anyone confirm receipts as PM.
@@ -110,8 +111,10 @@ async def arrivals_scope(
role = (request.headers.get("x-user-role") or "").strip().lower() role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager": if role == "procurement_manager":
role = "admin" role = "admin"
elif role == "supervisor":
role = "pm"
if role not in ("pm", "admin"): if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید") raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
# The explicit query param wins; otherwise the dev header carries it. # The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm": if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id") raw_project = request.headers.get("x-project-id")
@@ -189,10 +192,17 @@ async def pm_on_the_way(
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if ctx.role == "pm" and ctx.project_id is None: if ctx.role == "pm":
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if ctx.project_id is not None: if not allowed:
# PM: active project; admin/manager: optional project filter raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -243,8 +253,12 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id) decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen": if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.project_id is not None and decision.project_id != ctx.project_id: if ctx.role == "pm":
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست") allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if decision.project_id not in allowed:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
li = await db.get(LineItem, decision.line_item_id) li = await db.get(LineItem, decision.line_item_id)
quote = await db.get(Quote, li.quote_id) quote = await db.get(Quote, li.quote_id)
@@ -451,10 +465,17 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id) .join(Project, Project.id == ItemDecision.project_id)
) )
if ctx.role == "pm" and ctx.project_id is None: if ctx.role == "pm":
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if ctx.project_id is not None: if not allowed:
# PM: active project; admin/manager: optional project filter raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None:
if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows] receipts = [r.ItemReceipt for r in rows]
+56 -14
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_procurement_writer()
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = { d = {
"id": str(project.id), "id": str(project.id),
"name": project.name, "name": project.name,
"description": project.description, "location": project.location or "",
"project_manager": project.project_manager or "",
"description": project.description or "",
"start_date": to_date(project.start_date), "start_date": to_date(project.start_date),
"status": project.status or "ACTIVE",
"progress": float(project.progress) if project.progress is not None else 0.0,
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at), "created_at": to_iso(project.created_at),
} }
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
pm = await db.get(User, project.pm_user_id) pm = await db.get(User, project.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif project.project_manager:
d["pm_name"] = project.project_manager
return d return d
@@ -55,7 +62,14 @@ async def list_projects(
): ):
stmt = select(Project) stmt = select(Project)
if q: if q:
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%"))) stmt = stmt.where(
or_(
Project.name.ilike(f"%{q}%"),
Project.description.ilike(f"%{q}%"),
Project.location.ilike(f"%{q}%"),
Project.project_manager.ilike(f"%{q}%"),
)
)
stmt = sort_query(stmt, Project, sort) stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items) pm_map = await _pm_map(db, items)
@@ -64,24 +78,38 @@ async def list_projects(
d = { d = {
"id": str(p.id), "id": str(p.id),
"name": p.name, "name": p.name,
"description": p.description, "location": p.location or "",
"project_manager": p.project_manager or "",
"description": p.description or "",
"start_date": to_date(p.start_date), "start_date": to_date(p.start_date),
"status": p.status or "ACTIVE",
"progress": float(p.progress) if p.progress is not None else 0.0,
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at), "created_at": to_iso(p.created_at),
} }
pm = pm_map.get(p.pm_user_id) pm = pm_map.get(p.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif p.project_manager:
d["pm_name"] = p.project_manager
data.append(d) data.append(d)
return {"data": data, "meta": meta} return {"data": data, "meta": meta}
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)): async def create_project(
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = Project( project = Project(
name=payload.name, name=payload.name.strip(),
description=payload.description, location=payload.location.strip() if payload.location else "",
project_manager=payload.project_manager.strip() if payload.project_manager else "",
description=payload.description.strip() if payload.description else "",
start_date=payload.start_date, start_date=payload.start_date,
planned_start=payload.start_date,
status="ACTIVE",
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
@@ -103,37 +131,51 @@ async def update_project(
request: Request, request: Request,
ctx: UserContext | None = Depends(_optional_ctx), ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
if payload.name is not None: if payload.name is not None:
project.name = payload.name project.name = payload.name.strip()
if payload.location is not None:
project.location = payload.location.strip()
if payload.project_manager is not None:
project.project_manager = payload.project_manager.strip()
if payload.description is not None: if payload.description is not None:
project.description = payload.description project.description = payload.description.strip()
if payload.start_date is not None: if payload.start_date is not None:
project.start_date = payload.start_date project.start_date = payload.start_date
project.planned_start = payload.start_date
if payload.status is not None:
project.status = payload.status
# PM assignment (1:N): one PM per project, a PM may own many projects. # PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel). # Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set: if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager"): if role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است") raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
if payload.pm_user_id is None: if payload.pm_user_id is None:
project.pm_user_id = None project.pm_user_id = None
else: else:
pm_user = await db.get(User, payload.pm_user_id) pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None: if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id") raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role != "pm": if pm_user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id") raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
project.pm_user_id = pm_user.id project.pm_user_id = pm_user.id
if not project.project_manager:
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
await db.flush() await db.flush()
return {"data": await project_to_dict(db, project)} return {"data": await project_to_dict(db, project)}
@router.delete("/{project_id}") @router.delete("/{project_id}")
async def delete_project(project_id, db: AsyncSession = Depends(get_db)): async def delete_project(
project_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
+8 -1
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn from app.schemas.procurement import NoteEventIn
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_procurement_writer()
@router.get("") @router.get("")
@@ -34,7 +36,12 @@ async def list_events(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)): async def create_event(
quote_id,
payload: NoteEventIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
if quote is None: if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد") raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
+65 -10
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ( from app.models.procurement import (
@@ -36,12 +37,15 @@ from app.services.expand_service import (
serialize_quote_with_children, serialize_quote_with_children,
serialize_quotes, serialize_quotes,
) )
from app.utils.errors import NotFoundError from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import sort_query from app.utils.pagination import sort_query
from app.utils.security import get_actor from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"]) router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_procurement_writer()
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
@@ -156,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): async def create_quote(
payload: QuoteCreate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
ctx: UserContext = Depends(allow_quote_create()),
_auth=None,
):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor) quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)} return {"data": await serialize_quote_with_children(db, quote)}
@@ -167,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
@router.patch("/{quote_id}") @router.patch("/{quote_id}")
async def update_quote( async def update_quote(
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: QuoteUpdate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.update_quote(db, quote_id, payload, actor) quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())} return {"data": await serialize_quote(db, quote, set())}
@@ -175,7 +200,11 @@ async def update_quote(
@router.patch("/{quote_id}/children") @router.patch("/{quote_id}/children")
async def replace_quote_children( async def replace_quote_children(
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: QuoteChildrenReplaceIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
"""Full replacement of a quote's line items + termsheets (edit modal).""" """Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
@@ -188,7 +217,11 @@ async def replace_quote_children(
@router.patch("/{quote_id}/accept") @router.patch("/{quote_id}/accept")
async def accept_quote( async def accept_quote(
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: AcceptQuoteIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.accept_quote(db, quote_id, payload, actor) quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)} return {"data": await _accept_response(db, quote)}
@@ -224,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
@router.patch("/{quote_id}/arrive") @router.patch("/{quote_id}/arrive")
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): async def arrive_quote(
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.arrive_quote(db, quote_id, actor) quote = await quote_service.arrive_quote(db, quote_id, actor)
return { return {
"data": { "data": {
@@ -237,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
@router.patch("/{quote_id}/arrive-items") @router.patch("/{quote_id}/arrive-items")
async def arrive_items( async def arrive_items(
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: ArriveItemsIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.arrive_items(db, quote_id, payload, actor) quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return { return {
@@ -259,14 +301,23 @@ async def arrive_items(
@router.patch("/{quote_id}/reject") @router.patch("/{quote_id}/reject")
async def reject_quote( async def reject_quote(
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) quote_id,
payload: RejectQuoteIn | None = None,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire") @router.patch("/{quote_id}/expire")
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): async def expire_quote(
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.expire_quote(db, quote_id, actor) quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@@ -276,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.delete("/{quote_id}") @router.delete("/{quote_id}")
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)): async def delete_quote(
quote_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
result = await quote_service.delete_quote(db, quote_id) result = await quote_service.delete_quote(db, quote_id)
return {"data": result} return {"data": result}
+20 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -15,6 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"], tags=["schedule-steps"],
) )
_procurement_writer = require_procurement_writer()
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
@@ -57,7 +59,11 @@ async def list_schedule_steps(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_schedule_step( async def create_schedule_step(
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
payload: ScheduleStepCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no step_no = payload.step_no
@@ -84,7 +90,12 @@ async def create_schedule_step(
@router.patch("/{step_id}") @router.patch("/{step_id}")
async def update_schedule_step( async def update_schedule_step(
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
step_id,
payload: ScheduleStepUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
step = await _step_or_404(db, termsheet_id, step_id) step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no: if payload.step_no is not None and payload.step_no != step.step_no:
@@ -109,7 +120,13 @@ async def update_schedule_step(
@router.delete("/{step_id}") @router.delete("/{step_id}")
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)): async def delete_schedule_step(
quote_id,
termsheet_id,
step_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
step = await _step_or_404(db, termsheet_id, step_id) step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar( count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where( select(func.count()).select_from(TermsheetScheduleStep).where(
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import StandardConversion from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict: def sc_to_dict(sc: StandardConversion) -> dict:
@@ -41,7 +43,11 @@ async def list_standard_conversions(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)): async def create_standard_conversion(
payload: StandardConversionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar( duplicate = await db.scalar(
select(StandardConversion.id).where( select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit, StandardConversion.from_unit == payload.from_unit,
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{sc_id}") @router.patch("/{sc_id}")
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)): async def update_standard_conversion(
sc_id,
payload: StandardConversionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
@router.delete("/{sc_id}") @router.delete("/{sc_id}")
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): async def delete_standard_conversion(
sc_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
+12 -2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial from app.models.procurement import Supplier, SupplierMaterial
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(link: SupplierMaterial, material: Material | None = None,
@@ -63,7 +65,10 @@ async def list_supplier_materials(
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material( async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) supplier_id,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
@@ -72,7 +77,12 @@ async def create_supplier_material(
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)): async def delete_supplier_material(
supplier_id,
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id) await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier from app.models.procurement import Quote, Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"]) router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -71,7 +73,11 @@ async def list_suppliers(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)): async def create_supplier(
payload: SupplierCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await _category_or_404(db, payload.category_id) category = await _category_or_404(db, payload.category_id)
supplier = Supplier( supplier = Supplier(
name=payload.name, name=payload.name,
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{supplier_id}") @router.patch("/{supplier_id}")
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)): async def update_supplier(
supplier_id,
payload: SupplierUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
@router.delete("/{supplier_id}") @router.delete("/{supplier_id}")
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)): async def delete_supplier(
supplier_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
+24 -4
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -80,7 +82,12 @@ async def list_termsheets(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)): async def create_termsheet(
quote_id,
payload: TermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -110,7 +117,11 @@ async def get_termsheet(
@router.patch("/{termsheet_id}") @router.patch("/{termsheet_id}")
async def update_termsheet( async def update_termsheet(
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
payload: TermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered": if ts.status != "offered":
@@ -127,7 +138,11 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/status") @router.patch("/{termsheet_id}/status")
async def update_termsheet_status( async def update_termsheet_status(
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db) quote_id,
termsheet_id,
payload: TermsheetStatusUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -139,7 +154,12 @@ async def update_termsheet_status(
@router.delete("/{termsheet_id}") @router.delete("/{termsheet_id}")
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)): async def delete_termsheet(
quote_id,
termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+18 -3
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec from app.models.procurement import LineItem, LineItemSpec
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"]) router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict: def unit_full_to_dict(unit: Unit) -> dict:
@@ -45,7 +47,11 @@ async def list_units(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)): async def create_unit(
payload: UnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None: if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@router.patch("/{unit_id}") @router.patch("/{unit_id}")
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)): async def update_unit(
unit_id: int,
payload: UnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
@router.delete("/{unit_id}") @router.delete("/{unit_id}")
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)): async def delete_unit(
unit_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
+186 -31
View File
@@ -17,15 +17,21 @@ import uuid
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select, update from sqlalchemy import delete, select, update, text, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record from app.utils.audit import record as audit_record
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate from app.schemas.procurement import (
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import ( from app.utils.errors import (
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه", "pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات", "procurement_manager": "مدیر تدارکات",
"admin": "ادمین", "admin": "ادمین",
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
"supervisor": "سرپرست پروژه",
} }
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: PANEL_LEVELS = ("full", "limited")
PANELS = ("procurement", "inventory", "pm")
async def _panels_of(db: AsyncSession, user_id) -> dict:
"""panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها)."""
rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
)
).scalars().all()
return {row.panel: row.level for row in rows}
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
"""عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته."""
rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user_id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return [str(pid) for pid in rows]
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
"""Serialize a user. `projects` = every project the PM manages (1:N); """Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility.""" project_id/project_name stay as the first (oldest) one for compatibility.
با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند."""
projects = projects or [] projects = projects or []
first = projects[0] if projects else None first = projects[0] if projects else None
return { out = {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
"username": user.username,
"first_name": user.first_name, "first_name": user.first_name,
"last_name": user.last_name, "last_name": user.last_name,
"phone": user.phone, "phone": user.phone,
@@ -62,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
"projects": [{"id": str(p.id), "name": p.name} for p in projects], "projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at), "created_at": to_iso(user.created_at),
} }
if db is not None:
out["panels"] = await _panels_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin OR procurement manager (the same person) — organization management.""" """Admin, procurement manager, or project manager — organization management."""
if ctx.role not in ("admin", "procurement_manager"): if ctx.role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]: async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project the user manages as PM (oldest first; [] for non-PMs).""" """Every project assigned to the user (supervisor or PM) (oldest first).
if user.role != "pm": Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return [] return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = ( rows = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id == user.id) .where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me") @router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
return {"data": user_to_dict(ctx.user, projects)} return {"data": await user_to_dict(ctx.user, projects, db)}
@router.get("/me/projects") @router.get("/me/projects")
@@ -138,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
active project against this list and falls back gracefully when a active project against this list and falls back gracefully when a
project is revoked.""" project is revoked."""
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
# عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود
member_ids = set(ctx.member_project_ids or [])
have = {p.id for p in projects}
if member_ids:
extra = (
await db.execute(
select(Project)
.where(Project.id.in_(member_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
projects = list(projects) + [p for p in extra if p.id not in have]
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@@ -148,20 +212,20 @@ async def list_users(
): ):
"""Every user (with role + assigned projects) — Organization panel.""" """Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"} supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {} by_user: dict = {}
if pm_ids: if supervisor_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(pm_ids)) .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users)}, "meta": {"total": len(users)},
} }
@@ -187,30 +251,30 @@ async def create_user(
db.add(user) db.add(user)
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
@router.patch("/{user_id}") @router.patch("/{user_id}")
async def update_user_role( async def update_user_role(
user_id, user_id,
payload: UserRoleUpdate, payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")), ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not """Set/clear a role — organization management (admin, procurement manager, PM)."""
assign or elevate roles. Revoking (or switching away from «pm») also if payload.role == "admin" and ctx.role != "admin":
unassigns the user from their project.""" raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
previous_role = user.role previous_role = user.role
if user.role == "pm" and payload.role != "pm": if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
user.role = payload.role user.role = payload.role
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
projects = await _user_projects(db, user) projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)} return {"data": await user_to_dict(user, projects)}
@router.post("/sync") @router.post("/sync")
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
continue continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid)) existing = await db.scalar(select(User).where(User.casdoor_id == cid))
if existing is None: if existing is None:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
by_uname = await db.scalar(select(User).where(User.username == uname))
if by_uname is not None:
by_uname.casdoor_id = cid
continue
db.add(User( db.add(User(
casdoor_id=cid, casdoor_id=cid,
username=uname,
first_name=str( first_name=str(
cu.get("firstName") cu.get("firstName")
or cu.get("displayName") or cu.get("displayName")
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None, phone=str(cu.get("phone") or "") or None,
)) ))
created += 1 created += 1
elif not existing.username:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
existing.username = uname
await db.flush() await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"} supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {} by_user: dict = {}
if pm_ids: if supervisor_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(pm_ids)) .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users), "created": created}, "meta": {"total": len(users), "created": created},
} }
@@ -282,17 +357,97 @@ async def assign_user_project(
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
if user.role != "pm": if user.role not in ("supervisor", "pm"):
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
if payload.project_id is None: if payload.project_id is None:
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
if user.username:
await db.execute(
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
{"uname": user.username}
)
await db.flush() await db.flush()
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
project = await db.get(Project, payload.project_id) project = await db.get(Project, payload.project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id project.pm_user_id = user.id
if user.username:
await db.execute(
text("""
INSERT INTO project_assignments (id, user_id, project_id)
SELECT gen_random_uuid(), pu.id, :pid
FROM pm_users pu
WHERE pu.username = :uname
AND NOT EXISTS (
SELECT 1 FROM project_assignments pa
WHERE pa.user_id = pu.id AND pa.project_id = :pid
)
"""),
{"pid": project.id, "uname": user.username}
)
await db.flush() await db.flush()
return {"data": user_to_dict(user, await _user_projects(db, user))} return {"data": await user_to_dict(user, await _user_projects(db, user))}
# ---------------------------------------------------------------------------
# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
# ---------------------------------------------------------------------------
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
for panel, level in payload.panels.items():
if panel not in PANELS:
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
if level not in PANEL_LEVELS:
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
for panel, level in payload.panels.items():
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
wanted = list(dict.fromkeys(payload.project_ids))
if wanted:
found = (
await db.execute(select(Project).where(Project.id.in_(wanted)))
).scalars().all()
found_ids = {p.id for p in found}
unknown = [str(pid) for pid in wanted if pid not in found_ids]
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await db.execute(
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
)
for pid in wanted:
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
+20 -2
View File
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
class ProjectCreate(APIModel): class ProjectCreate(APIModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
class ProjectUpdate(APIModel): class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1) name: str | None = Field(default=None, min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
status: str | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects). # PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM. # Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None pm_user_id: UUID | None = None
@@ -203,13 +208,13 @@ class UserCreate(APIModel):
last_name: str = "" last_name: str = ""
phone: str | None = None phone: str | None = None
casdoor_id: str | None = None casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin"] | None = None role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserRoleUpdate(APIModel): class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment).""" """role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserProjectUpdate(APIModel): class UserProjectUpdate(APIModel):
@@ -341,3 +346,16 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0) received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None on_time: bool | None = None
quality_note: str | None = None quality_note: str | None = None
class UserPanelsUpdate(APIModel):
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود."""
panels: dict[str, str] = Field(default_factory=dict)
class UserPanelProjectsUpdate(APIModel):
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
project_ids: list[UUID] = Field(default_factory=list)
+4 -37
View File
@@ -11,7 +11,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
- "5455:5432" - "5433:5432"
networks: networks:
- default - default
@@ -208,7 +208,6 @@ services:
mem_limit: 768m mem_limit: 768m
api: api:
image: v15-api:latest
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -249,6 +248,9 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks: networks:
- traefik_network - traefik_network
- default - default
@@ -277,7 +279,6 @@ services:
mem_limit: 768m mem_limit: 768m
frontend: frontend:
image: v15-frontend:latest
build: build:
context: ./frontend context: ./frontend
args: args:
@@ -313,40 +314,6 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 128m mem_limit: 128m
bot:
image: v15-bot:latest
build:
context: ./bot
dockerfile: Dockerfile
container_name: procurement-bot
volumes:
- ./bot/app:/srv/bot/app
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
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_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-https://api.deepseek.com}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-deepseek-flash}
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-1461166742,1839839922}
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
PROCUREMENT_BOT_FRONTEND_URL: ${APP_HOST:-http://localhost}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title> <title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "دفترچه تأمین — سامانه تدارکات و انبار", "name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "دفترچه تأمین", "short_name": "مدیریت پروژه و تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی", "description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl", "dir": "rtl",
"lang": "fa", "lang": "fa",
"start_url": "/", "start_url": "/",
+277 -19
View File
@@ -38,11 +38,14 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
); );
const OrganizationPage = lazy(() => const OrganizationPage = lazy(() =>
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
); );
const AdminPanelPage = lazy(() => const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
); );
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
function Loading() { function Loading() {
return ( return (
@@ -52,30 +55,254 @@ function Loading() {
); );
} }
// همه‌ی صفحات پشت ورود هستند — به جز پیش‌نویس‌های ویزارد از ربات یا بدون نشست که به صفحه‌ی ورود می‌روند. // همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const location = useLocation(); const { pathname } = useLocation();
if (!user) return <Navigate to="/login" replace />;
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
شما به عنوان سرپرست پروژه در سیستم ثبت شده‌اید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است.
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
</p>
<button
type="button"
onClick={() => {
useAuthStore.getState().logout();
window.location.href = "/login";
}}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
}
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است // بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const searchParams = new URLSearchParams(location.search); const panels = user.panels ?? {};
const hasDraft = searchParams.has("draft") || searchParams.has("draft_id"); const isAdmin = user.role === "admin";
if (location.pathname === "/wizard" && hasDraft) { const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
function HomeRoute() {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
return <DashboardPage />;
}
// ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>; return <>{children}</>;
} }
if (!user) { const hasInventory =
const returnUrl = encodeURIComponent(location.pathname + location.search); isAdmin ||
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />; (hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() {
return (
<iframe
src="/pm/"
title="مدیریت پروژه (PM)"
className="h-dvh w-full border-0"
/>
);
}
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
} }
return <>{children}</>; return <>{children}</>;
} }
// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر // دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function HomeRoute() { function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role === "pm") return <Navigate to="/inventory" replace />; const isAdmin = user?.role === "admin";
return <DashboardPage />; const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>;
}
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed =
user?.role === "admin" ||
user?.role === "supervisor" ||
user?.panels?.inventory === "limited";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را می‌بیند)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
} }
export default function App() { export default function App() {
@@ -96,10 +323,13 @@ export default function App() {
path="/wizard" path="/wizard"
element={ element={
<RequireAuth> <RequireAuth>
<WizardPage /> <RequireProcurementWrite>
<WizardPage />
</RequireProcurementWrite>
</RequireAuth> </RequireAuth>
} }
/> />
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route <Route
path="/suppliers" path="/suppliers"
element={ element={
@@ -176,7 +406,9 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<ChangeInventoryPage /> <RequireInventoryChange>
<ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
@@ -196,18 +428,24 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<WarehousesPage /> <RequireWarehouseManage>
<WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
<Route <Route
path="/inventory/org" path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequireOrganization>
<OrganizationPage /> <OrganizationPage />
</RequirePmProjects> </RequireOrganization>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -219,6 +457,26 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
<Route
path="/requester"
element={
<RequireAuth>
<RequesterHome />
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
element={
<RequireAuth>
<RequirePmPanel>
<PmPanelPage />
</RequirePmPanel>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+67 -31
View File
@@ -1,48 +1,84 @@
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu"; import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu"; import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) — // منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر) // ۱. مدیریت انبار (/inventory)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج. // ۲. مدیریت تدارکات (/)
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی // ۳. مدیریت پروژه (/panel/pm)
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و // ۴. سازمان‌دهی (/organization)
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
export function AppSwitcherMenu({ export function AppSwitcherMenu({
activeModule, activeModule,
canSwitchToProcurement = true, canSwitchToProcurement = true,
}: { }: {
activeModule: "warehouse" | "procurement"; activeModule: "warehouse" | "procurement" | "organization";
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد canSwitchToProcurement?: boolean;
}) { }) {
const logout = useLogout(); const logout = useLogout();
const { pathname } = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const setActiveProject = useAuthStore((s) => s.setActiveProject); const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند
const canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = []; const items: MenuItem[] = [];
const isPm = user?.role === "pm"; if (hasInventory) {
const pmProjects = isPm ? user?.projects ?? [] : []; items.push({
const projectChildren: MenuItem[] | undefined = label: "انبار",
activeModule === "warehouse" && isPm to: "/inventory",
? pmProjects.map((p) => ({ active: activeModule === "warehouse",
label: p.name, });
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض می‌شود
active: p.id === user?.project_id,
onClick: () => setActiveProject(p.id),
}))
: undefined;
if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
}
} }
if (hasProcurement && canSwitchToProcurement) {
items.push({
label: "تدارکات",
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement",
});
}
if (hasPm) {
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
}
if (canManageOrg) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
active: activeModule === "organization",
});
}
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: logout }); items.push({ label: "خروج از حساب", onClick: logout });
return <PanelMenu title="پنل‌ها" items={items} />; return <PanelMenu title="پنل‌ها" items={items} />;
+17
View File
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
export interface MenuItem { export interface MenuItem {
label?: string; label?: string;
to?: string; to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean; active?: boolean;
divider?: boolean; divider?: boolean;
onClick?: () => void; onClick?: () => void;
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</div> </div>
)} )}
</div> </div>
) : it.href ? (
<a
key={i}
href={it.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
<span aria-hidden className="text-[10px] opacity-60">↗</span>
</a>
) : it.to ? ( ) : it.to ? (
<Link <Link
key={i} key={i}
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects); const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout(); const logout = useLogout();
const isPm = user?.role === "pm"; // پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok"); const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => { useEffect(() => {
if (!user || user.role !== "pm") { const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
if (!user || !scoped) {
setState("ok"); setState("ok");
return; return;
} }
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, isDev]); }, [user?.id, user?.role, user?.panels, isDev]);
if (state === "loading") { if (state === "loading") {
return ( return (
+22 -1
View File
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ChevronRight, ClipboardList } from "lucide-react"; import { ChevronRight, ClipboardList } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useAuthStore, type AuthUser } from "../../store/auth";
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
export function userSubtitle(user: AuthUser | null | undefined): string {
if (!user) return "";
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const username = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
}
interface HeaderProps { interface HeaderProps {
title: string; title: string;
@@ -18,6 +35,10 @@ interface HeaderProps {
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) { export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user);
const userSub = userSubtitle(user);
const displaySub = sub !== undefined ? sub : userSub;
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛ // اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛
// اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم. // اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم.
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1> <h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
{sub && <div className="text-[11px] text-muted">{sub}</div>} {displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
</div> </div>
</div> </div>
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div> <div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
@@ -8,7 +8,6 @@ import {
PackageSearch, PackageSearch,
Scale, Scale,
Truck, Truck,
UsersRound,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch }, { to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
]; ];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ) // تب‌های پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [ const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true }, { to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays }, { to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch }, { to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
]; ];
function TabBar({ tabs }: { tabs: TabDef[] }) { function TabBar({ tabs }: { tabs: TabDef[] }) {
@@ -81,14 +80,24 @@ export function MobileTabBar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null; if (pathname === "/login" || pathname === "/admin") return null;
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد
if (pathname === "/requester" || pathname === "/panel/pm") return null;
// کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد
const panels = useAuthStore.getState().user?.panels ?? {};
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
const isPM = role === "pm"; const isPM = role === "pm";
const isSupervisor = role === "supervisor";
const isOffice = role === "admin" || role === "procurement_manager"; const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) { if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />; if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />; if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
return null; return null;
} }
// سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد
if (isSupervisor) return null;
if (pathname === "/wizard") return null; if (pathname === "/wizard") return null;
const onOfficePage = const onOfficePage =
pathname === "/" || pathname === "/" ||
+1 -1
View File
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes"; export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials"; export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects"; export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits"; export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
+12
View File
@@ -21,3 +21,15 @@ export function useAddProject() {
onSettled: () => invalidate(), onSettled: () => invalidate(),
}); });
} }
export function useDeleteProject() {
const deleteProject = useAppStore((s) => s.deleteProject);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.projects.delete(id),
onMutate: (id) => deleteProject(id),
onSettled: () => invalidate(),
});
}
+57 -44
View File
@@ -87,7 +87,7 @@ function handle401(path: string, res: Response): void {
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند. // ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null { function activeProjectId(): string | null {
const { user } = useAuthStore.getState(); const { user } = useAuthStore.getState();
return user?.role === "pm" ? user.project_id : null; return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null;
} }
function projectQuery(projectId?: string | null): string { function projectQuery(projectId?: string | null): string {
@@ -250,6 +250,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data; return res.data;
} }
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" }); const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data; return res.data;
@@ -323,6 +327,12 @@ interface RawSupplier {
interface RawProject { interface RawProject {
id: string; id: string;
name: string; name: string;
location?: string | null;
project_manager?: string | null;
description?: string | null;
start_date?: string | null;
status?: string | null;
progress?: number | null;
} }
interface RawSpec { interface RawSpec {
attribute_key: string; attribute_key: string;
@@ -1020,41 +1030,6 @@ export const api = {
}, },
}, },
drafts: {
get: async (
id: string,
): Promise<{
id: string;
chat_id: number;
kind: string;
status: string;
payload: Record<string, any>;
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
source?: string;
created_at?: string;
updated_at?: string;
}> => {
return getData(`/drafts/${id}`);
},
update: async (
id: string,
payload: Record<string, any>,
): Promise<void> => {
await patchData(`/drafts/${id}`, { payload });
},
commit: async (
id: string,
quoteId?: string,
): Promise<void> => {
await postData(`/drafts/${id}/commit`, { quote_id: quoteId });
},
cancel: async (
id: string,
): Promise<void> => {
await postData(`/drafts/${id}/cancel`, {});
},
},
suppliers: { suppliers: {
list: async (): Promise<Supplier[]> => { list: async (): Promise<Supplier[]> => {
const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`); const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`);
@@ -1166,11 +1141,36 @@ export const api = {
projects: { projects: {
list: async (): Promise<Project[]> => { list: async (): Promise<Project[]> => {
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`); const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
return raw.map((p) => ({ id: p.id, name: p.name })); return raw.map((p) => ({
id: p.id,
name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
start_date: p.start_date,
status: p.status || "ACTIVE",
progress: p.progress != null ? Number(p.progress) : 0,
}));
}, },
create: async (p: Project): Promise<Project> => { create: async (p: Project): Promise<Project> => {
const raw = await postData<RawProject>("/projects", { name: p.name }); const raw = await postData<RawProject>("/projects", {
return { id: raw.id, name: raw.name }; name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
});
return {
id: raw.id,
name: raw.name,
location: raw.location || "",
project_manager: raw.project_manager || "",
description: raw.description || "",
status: raw.status || "ACTIVE",
progress: raw.progress != null ? Number(raw.progress) : 0,
};
},
delete: async (id: string): Promise<void> => {
await deleteData(`/projects/${id}`);
}, },
}, },
@@ -1560,11 +1560,16 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
export interface UserRow { export interface UserRow {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string | null;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...}
panels?: Record<string, string>;
// عضویت M:N پروژه‌ای — محدوده داده سطح limited
member_project_ids?: string[];
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
// 1:N — every project this PM manages (oldest first). // 1:N — every project this PM manages (oldest first).
@@ -1588,10 +1593,11 @@ export const apiV26 = {
user: { user: {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
@@ -1605,6 +1611,7 @@ export const apiV26 = {
expires_in: res.data.expires_in, expires_in: res.data.expires_in,
user: { user: {
...u, ...u,
username: u.username || u.casdoor_id,
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id, display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
}, },
}; };
@@ -1624,14 +1631,15 @@ export const apiV26 = {
users: { users: {
me: async (): Promise<import("../store/auth").AuthUser> => { me: async (): Promise<import("../store/auth").AuthUser> => {
const data = await getData<{ const data = await getData<{
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
project_id: string | null; project_name: string | null; project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[]; projects?: { id: string; name: string }[];
}>("/users/me"); }>("/users/me");
return { return {
id: data.id, id: data.id,
casdoor_id: data.casdoor_id, casdoor_id: data.casdoor_id,
username: data.username || data.casdoor_id,
first_name: data.first_name, first_name: data.first_name,
last_name: data.last_name, last_name: data.last_name,
phone: data.phone, phone: data.phone,
@@ -1655,8 +1663,13 @@ export const apiV26 = {
first_name: string; last_name?: string; phone?: string | null; first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null; casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): Promise<UserRow> => postData<UserRow>("/users", body), }): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> => updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }), patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
putData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> => assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }), postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
+6
View File
@@ -31,6 +31,12 @@ export interface Material {
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
location?: string;
project_manager?: string;
description?: string;
start_date?: string | null;
status?: string;
progress?: number;
} }
// مرحله‌ی پرداخت // مرحله‌ی پرداخت
+47 -12
View File
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه", pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات", procurement_manager: "مدیر تدارکات",
admin: "ادمین", admin: "ادمین",
supervisor: "سرپرست پروژه",
}; };
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و // پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و
@@ -80,6 +81,26 @@ export function AdminPanel() {
} }
}; };
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
const toggleSupervisor = async (u: UserRow) => {
setBusyId(u.id);
const target = u.role === "supervisor" ? null : "supervisor";
try {
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
toast.success(
target === "supervisor"
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
);
await load();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
if (!isAdmin) { if (!isAdmin) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -129,12 +150,13 @@ export function AdminPanel() {
<th className="py-2 pe-3">نام کامل</th> <th className="py-2 pe-3">نام کامل</th>
<th className="py-2 pe-3">شناسه Casdoor</th> <th className="py-2 pe-3">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th> <th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت مدیر تدارکات</th> <th className="py-2">مدیریت نقش‌ها</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => { {users.map((u) => {
const isPMgr = u.role === "procurement_manager"; const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return ( return (
<tr key={u.id} className="border-b border-line/50"> <tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold"> <td className="py-2 pe-3 font-semibold">
@@ -147,17 +169,30 @@ export function AdminPanel() {
</span> </span>
</td> </td>
<td className="py-2"> <td className="py-2">
<button <div className="flex flex-wrap items-center gap-1.5">
disabled={busyId === u.id} <button
onClick={() => void toggleProcurementManager(u)} disabled={busyId === u.id}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${ onClick={() => void toggleProcurementManager(u)}
isPMgr className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
? "border-red/30 bg-red/5 text-red hover:bg-red/10" isPMgr
: "border-green/30 bg-green/5 text-green hover:bg-green/10" ? "border-red/30 bg-red/5 text-red hover:bg-red/10"
} disabled:opacity-50`} : "border-green/30 bg-green/5 text-green hover:bg-green/10"
> } disabled:opacity-50`}
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"} >
</button> {busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button>
<button
disabled={busyId === u.id}
onClick={() => void toggleSupervisor(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isSup
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
</button>
</div>
</td> </td>
</tr> </tr>
); );
+39 -14
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react"; import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
@@ -33,8 +33,6 @@ export function LoginPage() {
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
const setDevUser = useAuthStore((s) => s.setDevUser); const setDevUser = useAuthStore((s) => s.setDevUser);
const navigate = useNavigate(); const navigate = useNavigate();
const [params] = useSearchParams();
const returnUrl = params.get("returnUrl");
// ---- فرم اصلی (Casdoor) ---- // ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -79,17 +77,34 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return; return;
} }
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (user.role === "supervisor" && !hasProjects) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
setToken(access_token); setToken(access_token);
setUser(user); setUser(user);
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*) // تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
identifyUser({ id: user.id, name: user.display_name, role: user.role }); identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role }); track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`); // هدایت پس از ورود بر اساس پنل‌ها یا نقش:
if (returnUrl) { const panels = user.panels ?? {};
navigate(decodeURIComponent(returnUrl)); if (panels.procurement === "full") {
navigate("/");
} else if (panels.procurement === "limited") {
navigate("/requester");
} else if (panels.inventory) {
navigate("/inventory");
} else if (panels.pm) {
navigate("/panel/pm");
} else if (user.role === "supervisor") {
navigate("/panel/pm");
} else if (user.role === "pm") {
navigate("/inventory");
} else { } else {
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید navigate("/");
navigate(user.role === "pm" ? "/inventory" : "/");
} }
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود"); toast.error(err instanceof Error ? err.message : "خطا در ورود");
@@ -131,9 +146,8 @@ export function LoginPage() {
} }
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null); setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`); toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
if (returnUrl) { // ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار
navigate(decodeURIComponent(returnUrl)); if (devRole === "admin") navigate("/admin");
} else if (devRole === "admin") navigate("/admin");
else if (devRole === "pm") navigate("/inventory"); else if (devRole === "pm") navigate("/inventory");
else navigate("/"); else navigate("/");
}; };
@@ -280,10 +294,21 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} /> <ClipboardList className="h-6 w-6" strokeWidth={2} />
</span> </span>
<div> <div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1> <h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
<p className="mt-1 text-[12.5px] leading-6 text-muted"> <p className="mt-1.5 text-[12.5px] leading-6 text-muted">
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
</p> </p>
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
مدیریت پروژه
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
تدارکات
</span>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
انبارداری
</span>
</div>
</div> </div>
</div> </div>
+24 -15
View File
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal"; import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
import { useAuthStore } from "../../store/auth";
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
const user = useAuthStore((s) => s.user);
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
@@ -85,7 +89,7 @@ export function DashboardPage() {
return ( return (
<div className="min-h-screen pb-20 md:pb-6"> <div className="min-h-screen pb-20 md:pb-6">
<Header <Header
title="دفتر پیشنهادهای خرید" title="مدیریت تدارکات"
brand brand
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
@@ -114,13 +118,15 @@ export function DashboardPage() {
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
<Link {canWrite && (
to="/wizard" <Link
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline" to="/wizard"
> className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
<Plus className="h-4 w-4" /> >
<span>افزودن پیشنهاد جدید</span> <Plus className="h-4 w-4" />
</Link> <span>افزودن پیشنهاد جدید</span>
</Link>
)}
</div> </div>
} }
/> />
@@ -148,17 +154,20 @@ export function DashboardPage() {
onToggleExpand={toggleExpand} onToggleExpand={toggleExpand}
onAccept={setAcceptId} onAccept={setAcceptId}
onEdit={setEditId} onEdit={setEditId}
canWrite={canWrite}
/> />
</PageLayout> </PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */} {/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
<Link {canWrite && (
to="/wizard" <Link
aria-label="افزودن پیشنهاد جدید" to="/wizard"
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform" aria-label="افزودن پیشنهاد جدید"
> className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
<Plus className="h-7 w-7" /> >
</Link> <Plus className="h-7 w-7" />
</Link>
)}
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} /> <AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} /> <EditModal quoteId={editId} onClose={() => setEditId(null)} />
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
onAccept: (id: string) => void; onAccept: (id: string) => void;
onEdit: (id: string) => void; onEdit: (id: string) => void;
canWrite?: boolean;
} }
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل) // دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
@@ -20,6 +21,7 @@ export function LedgerTable({
onToggleExpand, onToggleExpand,
onAccept, onAccept,
onEdit, onEdit,
canWrite = true,
}: LedgerTableProps) { }: LedgerTableProps) {
if (!quotes.length) { if (!quotes.length) {
return ( return (
@@ -40,6 +42,7 @@ export function LedgerTable({
onToggleExpand={() => onToggleExpand(q.id)} onToggleExpand={() => onToggleExpand(q.id)}
onAccept={() => onAccept(q.id)} onAccept={() => onAccept(q.id)}
onEdit={() => onEdit(q.id)} onEdit={() => onEdit(q.id)}
canWrite={canWrite}
/> />
))} ))}
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]"> <div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
+20 -15
View File
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
onToggleExpand: () => void; onToggleExpand: () => void;
onAccept: () => void; onAccept: () => void;
onEdit: () => void; onEdit: () => void;
canWrite?: boolean;
} }
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
@@ -35,7 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
); );
} }
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id); const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q); const expired = isExpired(q);
const total = quoteTotal(q); const total = quoteTotal(q);
@@ -46,7 +47,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
// ستون / دکمه پذیرش // ستون / دکمه پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
if (q.status === "offered") { if (q.status === "offered") {
acceptedCell = ( acceptedCell = canWrite ? (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -57,6 +58,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
<span>پذیرش</span> <span>پذیرش</span>
</button> </button>
) : (
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
); );
} else if (q.accepted_date) { } else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>; acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
@@ -185,19 +188,21 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
)} )}
{/* دکمه ویرایش */} {/* دکمه ویرایش */}
<button {canWrite && (
type="button" <button
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5" type="button"
onClick={(e) => { className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
e.stopPropagation(); onClick={(e) => {
onEdit(); e.stopPropagation();
}} onEdit();
title="ویرایش پیشنهاد" }}
aria-label="ویرایش" title="ویرایش پیشنهاد"
> aria-label="ویرایش"
<Pencil className="h-3 w-3 text-muted" /> >
<span className="hidden sm:inline">ویرایش</span> <Pencil className="h-3 w-3 text-muted" />
</button> <span className="hidden sm:inline">ویرایش</span>
</button>
)}
{/* دکمه فلش اکسپند در هدر */} {/* دکمه فلش اکسپند در هدر */}
<button <button
@@ -0,0 +1,106 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { FilePlus2, Info } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { useAuthStore } from "../../store/auth";
import { useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژه‌های تخصیص‌یافته
export function RequesterHome() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
// پروژه‌های کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژه‌های عمومی)
const myProjects = useMemo(() => {
const list: { id: string; name: string }[] = [];
const seen = new Set<string>();
// ۱. پروژه‌های موجود در آرایه user.projects
for (const p of user?.projects ?? []) {
if (p.id && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۲. پروژه‌ی اختصاصی فعال (project_id)
if (user?.project_id && !seen.has(user.project_id)) {
seen.add(user.project_id);
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیص‌یافته" });
}
// ۳. پروژه‌های عضویت member_project_ids تطبیق‌یافته با لیست پروژه‌ها
const memberIdSet = new Set(user?.member_project_ids ?? []);
for (const p of projects) {
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
if (list.length === 0 && user?.project_name) {
const match = projects.find((p) => p.name === user.project_name);
if (match && !seen.has(match.id)) {
list.push({ id: match.id, name: match.name });
}
}
return list;
}, [user, projects]);
return (
<div className="min-h-screen">
<Header
title="افزودن استعلام"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
{myProjects.length === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
</div>
) : (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{myProjects.map((p) => (
<Link
key={p.id}
to={`/wizard?project=${encodeURIComponent(p.name)}`}
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
</div>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<FilePlus2 className="h-5 w-5" />
</span>
</Link>
))}
</div>
<Link
to="/wizard"
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
>
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
</>
)}
</PageLayout>
</div>
);
}
@@ -23,6 +23,8 @@ export function ArrivalsPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canAccess = isAdmin || isPM || isSupervisor;
const [items, setItems] = useState<PendingArrival[]>([]); const [items, setItems] = useState<PendingArrival[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
@@ -33,9 +35,9 @@ export function ArrivalsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const activeProject = isPM ? user?.project_id || undefined : undefined; const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([ const [arr, whs] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM / سرپرست را بررسی می‌کند
apiV26.pm.onTheWay(activeProject), apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject), apiV26.warehouses.list(activeProject),
]); ]);
@@ -46,13 +48,13 @@ export function ArrivalsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isPM, user?.project_id]); }, [isPM, isSupervisor, user?.project_id]);
useEffect(() => { useEffect(() => {
if (!isAdmin && !isPM) return; if (!canAccess) return;
setLoading(true); setLoading(true);
void load(); void load();
}, [isAdmin, isPM, load]); }, [canAccess, load]);
// سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص) // سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص)
const orders = useMemo(() => { const orders = useMemo(() => {
@@ -212,12 +214,12 @@ export function ArrivalsPage() {
} }
}; };
if (!isAdmin && !isPM) { if (!canAccess) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout> <PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." /> <EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
</PageLayout> </PageLayout>
</div> </div>
); );
@@ -76,8 +76,9 @@ export function ChangeInventoryPage() {
} }
}, [params]); }, [params]);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isAdmin = user?.role === "admin";
const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const canModify = isSupervisor || isAdmin;
const warehouses = data.warehouses; const warehouses = data.warehouses;
const whOpts = useMemo( const whOpts = useMemo(
@@ -401,7 +402,7 @@ export function ChangeInventoryPage() {
const meta = FORM_TYPES.find((f) => f.t === type)!; const meta = FORM_TYPES.find((f) => f.t === type)!;
if (isAdmin) { if (!canModify) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
@@ -409,22 +410,12 @@ export function ChangeInventoryPage() {
<div className="rounded-xl border border-line bg-panel p-8 text-center"> <div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div> <div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقط‌خواندنی</h3> <h3 className="mt-2 text-[15px] font-bold">دسترسی فقط‌خواندنی</h3>
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p> <p className="mt-1 text-[12.5px] text-muted">امکان تغییر موجودی انبار منحصراً در اختیار سرپرست پروژه و ادمین است.</p>
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
); );
} }
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const isCount = type === "STOCK_COUNT"; const isCount = type === "STOCK_COUNT";
const isTransfer = type === "TRANSFER"; const isTransfer = type === "TRANSFER";
@@ -41,10 +41,12 @@ export function DailyControlPage() {
const txByDay = useMemo(() => { const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>(); const map = new Map<string, InventoryTransaction[]>();
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
const projWhIds = projFilter const projWhIds = projFilter
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id)) ? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
: null; : null;
data.transactions.forEach((t) => { data.transactions.forEach((t) => {
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
if (projWhIds && !projWhIds.has(t.warehouse_id)) return; if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
const k = gregKey(new Date(t.transaction_date)); const k = gregKey(new Date(t.transaction_date));
const arr = map.get(k) || []; const arr = map.get(k) || [];
@@ -53,7 +55,7 @@ export function DailyControlPage() {
}); });
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime())); map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
return map; return map;
}, [data.transactions, data.warehouses, projFilter]); }, [data.transactions, data.warehouses, projFilter, isAdmin]);
const cells = useMemo(() => { const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
@@ -172,11 +174,15 @@ export function DailyControlPage() {
))} ))}
</select> </select>
) : ( ) : (
<span className="font-semibold text-slate-800">پروژه فعال</span> <span className="font-semibold text-slate-800">
{user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"}
</span>
)}
{isAdmin && (
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span>
)} )}
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span>
</div> </div>
<Link <Link
+16 -13
View File
@@ -51,8 +51,11 @@ export function InventoryPage() {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData(); const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const isAdmin = user?.role === "admin";
// پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -67,10 +70,10 @@ export function InventoryPage() {
const [expBrand, setExpBrand] = useState<string | null>(null); const [expBrand, setExpBrand] = useState<string | null>(null);
const visibleWarehouses = useMemo(() => { const visibleWarehouses = useMemo(() => {
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project); if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isAdmin) return warehouses; if (isManager) return warehouses;
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || "")); return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
}, [warehouses, isAdmin, filters.project, user?.project_id]); }, [warehouses, isManager, filters.project, user?.project_id]);
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]); const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
@@ -133,12 +136,12 @@ export function InventoryPage() {
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project)); const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) { if (!user) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
<PageLayout> <PageLayout>
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." /> <EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
</PageLayout> </PageLayout>
</div> </div>
); );
@@ -150,10 +153,10 @@ export function InventoryPage() {
title="سامانه مدیریت انبار" title="سامانه مدیریت انبار"
sub={inventorySubheader(user)} sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />} menu={<AppSwitcherMenu activeModule="warehouse" />}
actions={ actions={
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} /> <InventoryTopNav pathname={location.pathname} />
</div> </div>
} }
/> />
@@ -246,8 +249,8 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */} {/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && ( {showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5"> <div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* پروژه‌ها (مخصوص ادمین) */} {/* پروژه‌ها (مدیران و ادمین) */}
{isAdmin && lookup.projects.length > 0 && ( {isManager && lookup.projects.length > 0 && (
<div> <div>
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div> <div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -415,7 +418,7 @@ export function InventoryPage() {
</PageLayout> </PageLayout>
{/* دکمه شناور تغییر موجودی */} {/* دکمه شناور تغییر موجودی */}
{isPM && ( {canModify && (
<Link <Link
to="/inventory/change" to="/inventory/change"
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8" className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
@@ -1,26 +1,27 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها // ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها
export function InventoryTopNav({ export function InventoryTopNav({
pathname, pathname,
variant = "pm",
}: { }: {
pathname: string; pathname: string;
variant?: "pm" | "admin";
}) { }) {
const items = const user = useAuthStore((s) => s.user);
variant === "admin" const isAdmin = user?.role === "admin";
? [ const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }, const isSupervisor = user?.role === "supervisor";
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, const canViewTabs = isManager || isSupervisor;
{ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") },
] const items: { label: string; to: string; active: boolean }[] = [];
: [ // برای تغییر موجودی از همان دکمه شناور دایره‌ای آبی (+) استفاده می‌شود و دکمه مستطیلی در بالای صفحه حذف شد
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }, if (canViewTabs) {
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }, items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
]; items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
return ( return (
<> <>
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks";
import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
type Role = "pm" | "procurement_manager" | "admin" | null;
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
};
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
export function OrganizationPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [syncing, setSyncing] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [newWh, setNewWh] = useState<Record<string, string>>({});
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
// پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند.
const canChangeRoles = user?.role === "admin";
const loadUsers = useCallback(async () => {
try {
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
const syncUsers = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0
? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
// پروژه‌های متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تک‌پروژه‌ای
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
// همگام‌سازی چند-انتخابیِ پروژه‌ها: افزودن/حذف هر اختلاف با یک PATCH
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین می‌شود)
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id);
try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
const names = nextIds
.map((id) => data.lookup.projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers();
await data.refresh();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers(); // وضعیت واقعی سرور را برگردان
} finally {
setBusyId(null);
}
};
// ---------- actions: warehouses ----------
const addWarehouse = async (projectId: string) => {
const name = (newWh[projectId] || "").trim();
if (!name) return toast.error("نام انبار الزامی است");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
setNewWh((s) => ({ ...s, [projectId]: "" }));
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
toast.success(`انبار «${name}» اضافه شد ✓`);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
}
};
// انبار برای همیشه به پروژه‌ی والدش تعلق دارد — انتقال به پروژه‌ی دیگر وجود ندارد؛
// انبار فقط با حذف و ساخت دوباره زیر پروژه‌ی موردنظر جابه‌جا می‌شود.
const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
await apiV26.warehouses.delete(id);
toast.success("انبار حذف شد");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
}
};
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
const pmOf = (projectId: string) =>
users.find(
(u) =>
u.role === "pm" &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
) || null;
return (
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران از Casdoor همگام می‌شوند و نقش‌ها اینجا (فقط توسط ادمین) تعیین می‌شوند؛
مدیر تدارکات فقط پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند. پروژه‌ها از سیستم تدارکات می‌آیند و
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
</div>
{/* ---------- کاربران و مدیران پروژه ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) به‌صورت خودکار ساخته می‌شوند؛
یا دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">نقش</th>
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
<th className="py-2">عملیات</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-1.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
</td>
<td className="py-1.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="admin">ادمین</option>
</select>
) : (
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
</span>
)}
{!canChangeRoles && u.role && (
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
)}
</td>
<td className="min-w-[240px] py-1.5 pe-3 align-top">
{u.role === "pm" ? (
<ProjectMultiSelect
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(ids) => void syncUserProjects(u, ids)}
disabled={busyId === u.id}
/>
) : (
<span className="text-[11.5px] text-muted">—</span>
)}
</td>
<td className="py-1.5">
{u.role === "pm" && u.project_id && (
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-1 text-[11px] text-muted">
«بدون نقش» = حذف مدیر پروژه (از همه‌ی پروژه‌ها هم جدا می‌شود). هر مدیر پروژه می‌تواند به
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا می‌کند و اتصال به یک پروژه، مدیرِ
قبلیِ آن پروژه را جابه‌جا می‌کند.
</div>
</div>
)}
</div>
{/* ---------- پروژه‌ها و انبارها ---------- */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.lookup.projects.map((p) => {
const whs = data.warehouses.filter((w) => w.project_id === p.id);
const pm = pmOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
{faNum(whs.length)} انبار
</span>
</div>
<div className="mb-3">
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
{pm ? (
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
</div>
) : (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
</div>
)}
</div>
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
{!whs.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
) : (
<div className="mb-2 flex flex-col gap-1.5">
{whs.map((w) => (
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<div className="min-w-0 flex-1">
<b>{w.name}</b>
<small className="block truncate text-muted">{w.description || ""}</small>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
title="داشبورد این انبار"
>
📊 داشبورد
</button>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
placeholder="نام انبار جدید"
value={newWh[p.id] || ""}
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<input
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
placeholder="توضیحات (اختیاری)"
value={newWhDesc[p.id] || ""}
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
+ افزودن انبار
</button>
</div>
</div>
);
})}
</div>
</PageLayout>
</div>
);
}
+26 -23
View File
@@ -9,52 +9,42 @@ import { useInventoryData, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد) // انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
export function WarehousesPage() { export function WarehousesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const data = useInventoryData(); const data = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isSupervisor = user?.role === "supervisor";
const isPM = user?.role === "pm"; const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState(""); const [newDesc, setNewDesc] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [editId, setEditId] = useState<string | null>(null); const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState(""); const [editDesc, setEditDesc] = useState("");
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازمان‌دهی» if (!canManage) {
if (isAdmin) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout> <PageLayout>
<div className="rounded-xl border border-line bg-panel p-8 text-center"> <div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div> <div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی ندارد</h3> <h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
<p className="mt-1 text-[12.5px] text-muted"> <p className="mt-1 text-[12.5px] text-muted">
بخش «انبارها» مخصوص مدیران پروژه است. مدیریت انبارها از بخش «سازمان‌دهی» انجام می‌شود. دسترسی به این بخش برای حساب کاربری شما فعال نیست.
</p> </p>
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
); );
} }
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const addWarehouse = async () => { const addWarehouse = async () => {
const name = newName.trim(); const name = newName.trim();
if (!name) return toast.error("نام انبار الزامی است"); if (!name) return toast.error("نام انبار الزامی است");
const projectId = user?.project_id; const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id);
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید"); if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
try { try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null }); await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
@@ -99,13 +89,26 @@ export function WarehousesPage() {
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
{isPM && ( {canManage && (
<div className="rounded-xl border border-line bg-panel p-3"> <div className="rounded-xl border border-line bg-panel p-3">
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3> <h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap items-center gap-2">
{!isSupervisor && data.lookup.projects.length > 1 && (
<select
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
onChange={(e) => setSelectedProjectId(e.target.value)}
>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>
پروژه: {p.name}
</option>
))}
</select>
)}
<input <input
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm" className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام انبار (مثلاً انبار موقت)" placeholder="نام انبار (مثلاً انبار مرکزی)"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
/> />
@@ -148,7 +151,7 @@ export function WarehousesPage() {
> >
📊 مشاهده داشبورد انبار 📊 مشاهده داشبورد انبار
</button> </button>
{isPM && ( {canManage && (
<> <>
<button <button
className="rounded-lg border border-line px-3 py-1 text-[11.5px]" className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
@@ -162,7 +165,7 @@ export function WarehousesPage() {
</> </>
)} )}
</div> </div>
{editId === w.id && isPM && ( {editId === w.id && canManage && (
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}> <div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
<input <input
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm" className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
+21 -29
View File
@@ -10,20 +10,22 @@ export function useLogout() {
} }
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین", admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
}; };
// زیرعنوان پویای سربرگ پنل انبار: // زیرعنوان پویای سربرگ پنل انبار:
// [نام کاربر] | [نقش] – [پروژه / همه پروژه‌ها] // [نام کاربر] | [نقش] – [پروژه / همه پروژه‌ها]
// مدیر پروژه → نام پروژه‌ی خودش؛ مدیر تدارکات/ادمین → «همه پروژه‌ها». // سرپرست پروژه → نام پروژه‌ی خودش؛ مدیران/ادمین → «همه پروژه‌ها».
export function inventorySubheader(user: { display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string { export function inventorySubheader(user: { username?: string; casdoor_id?: string; display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
if (!user) return "در حال بارگذاری…"; if (!user) return "در حال بارگذاری…";
const name = user.display_name || `${user.first_name} ${user.last_name}`.trim() || "کاربر"; const name = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const uname = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش"); const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژه‌ها"; const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${name} | ${role} – ${proj}`; return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
} }
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و // منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
return items; return items;
} }
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب» // منوی همبرگری سرپرست پروژه در پنل انبار
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمه‌ی خروجِ تکی هم حذف شده).
export function pmMenuItems(onLogout?: () => void): MenuItem[] { export function pmMenuItems(onLogout?: () => void): MenuItem[] {
const items: MenuItem[] = []; const items: MenuItem[] = [];
if (onLogout) { if (onLogout) {
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
return items; return items;
} }
// آیتم‌های منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج // آیتم‌های بخش‌های داخلی انبار
//
// دسترسی‌ها (v2.6):
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازمان‌دهی —
// بدون «تغییر موجودی» و بدون «انبارها».
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
// بدون «سازمان‌دهی».
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] { export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
const isAdmin = role === "admin" || role === "procurement_manager"; const isAdmin = role === "admin";
const isPM = role === "pm"; const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
const isSupervisor = role === "supervisor" || isAdmin;
const items: MenuItem[] = [ const items: MenuItem[] = [
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" }, { label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
]; ];
if (isPM) { // فقط سرپرست پروژه و ادمین می‌توانند تغییر موجودی بدهند
if (isSupervisor) {
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") }); items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
} }
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }); // کنترل روزانه و انبارها و کالاهای در راه برای مدیران و سرپرست پروژه
if (isPM) { if (isManager || isSupervisor) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }); items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
} items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
if (isAdmin) {
items.push({ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
}
if (isAdmin) {
items.push({ divider: true });
items.push({ label: "بازگشت به دفتر", to: "/" });
} }
if (onLogout) { if (onLogout) {
items.push({ divider: true }); items.push({ divider: true });
@@ -26,8 +26,8 @@ export interface InventoryData {
export function useInventoryData(): InventoryData { export function useInventoryData(): InventoryData {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const projectId = isPM ? user.project_id || undefined : undefined; const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [inventory, setInventory] = useState<InventoryRow[]>([]); const [inventory, setInventory] = useState<InventoryRow[]>([]);
@@ -0,0 +1,596 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { api, apiV26, type UserRow } from "../../lib/api";
import type { Project } from "../../lib/types";
import { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
type Role = "pm" | "procurement_manager" | "admin" | "supervisor" | null;
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها و پنل‌ها
export function OrganizationPage() {
const user = useAuthStore((s) => s.user);
const storeProjects = useProjects();
const [directProjects, setDirectProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [syncing, setSyncing] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const canChangeRoles = canManage;
const fetchProjects = useCallback(async () => {
try {
setLoadingProjects(true);
const list = await api.projects.list();
if (list && Array.isArray(list)) {
setDirectProjects(list);
}
} catch {
// fallback silently
} finally {
setLoadingProjects(false);
}
}, []);
const loadUsers = useCallback(async () => {
try {
setLoadingUsers(true);
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) {
void loadUsers();
void fetchProjects();
}
}, [canManage, loadUsers, fetchProjects]);
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
const syncUsers = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0
? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
void fetchProjects();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین، مدیر تدارکات و مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
// ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ----------
const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
setBusyId(u.id);
const next = { ...(u.panels ?? {}) };
if (level) next[panel] = level;
else delete next[panel];
try {
await apiV26.users.setPanels(u.id, next);
toast.success(`دسترسی «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت دسترسی پنل");
} finally {
setBusyId(null);
}
};
const setMemberProjects = async (u: UserRow, nextIds: string[]) => {
setBusyId(u.id);
try {
await apiV26.users.setPanelProjects(u.id, nextIds);
toast.success(`عضویت پروژه‌ای «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت عضویت پروژه‌ها");
} finally {
setBusyId(null);
}
};
const panelLabel = (level: string | undefined) =>
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
// سرپرستان و مسئولان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) =>
users.filter(
(u) =>
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
(u.projects?.some((p) => p.id === projectId) ||
u.project_id === projectId ||
u.member_project_ids?.includes(projectId)),
);
return (
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
brand
menu={<AppSwitcherMenu activeModule="organization" />}
/>
<PageLayout className="flex flex-col gap-5">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌های سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین می‌گردد.
</div>
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقش‌ها و سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[14px] font-bold">👥 کاربران، نقش‌ها و سطوح دسترسی پنل‌ها</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش سازمانی، تخصیص پنل‌ها (تدارکات، انبار، PM) و مشخص نمودن پروژه‌های عضویت کاربر
</p>
</div>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2.5 pe-3">نام و مشخصات</th>
<th className="py-2.5 pe-3">نقش سازمانی</th>
<th className="py-2.5 pe-3">پنل تدارکات</th>
<th className="py-2.5 pe-3">پنل انبار</th>
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
<th className="py-2.5">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
{/* ۱. نام و نام کاربری */}
<td className="py-2.5 pe-3">
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">
{u.first_name} {u.last_name}
</span>
<span className="font-mono text-[10px] text-muted">
({u.username || "—"})
</span>
</div>
{busyId === u.id && (
<span className="text-[10px] text-accent animate-pulse font-medium">
در حال ذخیره تغییرات…
</span>
)}
</div>
</td>
{/* ۲. نقش سازمانی */}
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
{/* ۳. پنل تدارکات */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
{/* ۴. پنل انبار */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
{/* ۵. پنل مدیریت پروژه (PM) */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.pm ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
)}
</td>
{/* ۶. پروژه‌های عضویت */}
<td className="py-2.5">
{isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
<div>
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<p className="text-[11.5px] text-muted">
مشاهده وضعیت پروژه‌ها و سرپرستان یا اعضای تخصیص‌یافته به هر پروژه
</p>
</div>
{loadingProjects && (
<span className="text-[11px] text-muted animate-pulse">در حال به‌روزرسانی پروژه‌ها…</span>
)}
</div>
{loadingProjects && !projects.length ? (
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
در حال دریافت اطلاعات پروژه‌ها…
</div>
) : !projects.length ? (
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
<span>پروژه‌ای برای نمایش یافت نشد یا در دسترس نیست.</span>
<button
type="button"
onClick={() => void fetchProjects()}
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
>
🔄 تلاش مجدد برای دریافت پروژه‌ها
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => {
const sups = supervisorsOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
{p.status || "پروژه فعال"}
</span>
</div>
{p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)}
</div>
<div className="pt-2 border-t border-line/50">
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
{!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
</div>
) : (
<div className="flex flex-col gap-1">
{sups.map((s) => (
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-5">
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<div>
<h3 className="flex items-center gap-2 text-[15px] font-bold">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
📖
</span>
توضیحات پنل‌ها و راهنمای سطوح دسترسی
</h3>
<p className="mt-1 text-[12px] text-muted">
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنل‌های سامانه
</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
مدل دسترسی ماژولار مبتنی بر پنل
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* کارت پنل تدارکات */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
🛒
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
</div>
</div>
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
خرید و بازرگانی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی کامل مدیریتی به تمامی بخش‌های تدارکات</li>
<li>ثبت و ویرایش پیش‌فاکتورها، شرایط پرداخت و فاکتورها</li>
<li>مدیریت تأمین‌کنندگان، اقلام و تصمیم‌گیری خرید در همه پروژه‌ها</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>فقط امکان ثبت درخواست استعلام قیمت و پیش‌فاکتور اولیه</li>
<li>محدود منحصراً به پروژه‌هایی که کاربر در آن‌ها عضویت دارد</li>
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمت‌ها یا پروژه‌های غیرعضو</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل انبار */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
📦
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
</div>
</div>
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
انبارداری و موجودی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژه‌ها</li>
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارش‌گیری کلان</li>
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی فقط به انبارهای پروژه‌های عضویت کاربر</li>
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
<li>تفکیک دقیق موجودی و تراکنش‌های انبار بر اساس پروژه</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل مدیریت پروژه (PM) */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
📊
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
</div>
</div>
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
کنترل پروژه و اجرا
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مدیریت جامع ساختار شکست کار (WBS)، زمان‌بندی و گانت چارت تعاملی</li>
<li>تعریف و ویرایش فعالیت‌ها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
<li>پایش شاخص‌های پیشرفت زمانی و مالی (EVM) و گزارش‌های تحلیلی</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده فعالیت‌ها و تسک‌های مربوط به پروژه‌های عضویت کاربر</li>
<li>به‌روزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسک‌ها</li>
<li>تکمیل چک‌لیست‌های کنترل کیفیت (QC) و ثبت گزارش‌های اجرایی روزانه</li>
</ul>
</div>
</div>
</div>
</div>
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">👑</span>
<div>
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
<p className="mt-0.5 text-muted leading-relaxed">
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنل‌ها و پروژه‌ها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنل‌ها ندارد.
</p>
</div>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">🎯</span>
<div>
<span className="font-bold text-foreground">کارکرد «پروژه‌های عضویت»:</span>
<p className="mt-0.5 text-muted leading-relaxed">
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه داده‌ها و اختیارات وی دقیقاً به پروژه‌های انتخابی در ستون «پروژه‌های عضویت» محدود خواهد شد.
</p>
</div>
</div>
</div>
</div>
</PageLayout>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More