Compare commits
4
Commits
1b5fc589fd
..
pm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df3ff4ea14 | ||
|
|
e9fef5a104 | ||
|
|
4a3c226518 | ||
|
|
c7d860f431 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# ---- Env / secrets ----
|
|
||||||
.env
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# ---- Python ----
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# ---- OS junk ----
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""ERP API client — the only way the bot touches procurement data."""
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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 "—",
|
|
||||||
))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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)))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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))
|
|
||||||
@@ -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")]])
|
|
||||||
@@ -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
|
|
||||||
@@ -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())
|
|
||||||
@@ -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
|
|
||||||
@@ -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", [])
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
@@ -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.
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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("تعریف ویژگی یافت نشد")
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("ارتباط برند-متریال یافت نشد")
|
||||||
|
|||||||
@@ -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("برند یافت نشد")
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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("دستهبندی یافت نشد")
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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("پروژه یافت نشد")
|
||||||
|
|||||||
@@ -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("پیشنهاد (استعلام) یافت نشد")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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("تبدیل استاندارد یافت نشد")
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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("تأمینکننده یافت نشد")
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 === "/" ||
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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 (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحلهی پرداخت
|
// مرحلهی پرداخت
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user