Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3931baadd5 | ||
|
|
1bf55ba8a6 | ||
|
|
5458a2f99f | ||
|
|
af092fe4c8 | ||
|
|
d85ede7d11 | ||
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
da883a28c0 | ||
|
|
b6a1ce2ae7 | ||
|
|
64d80e15f8 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f |
@@ -26,8 +26,6 @@ frontend/.env
|
|||||||
|
|
||||||
# ---- Local session / backups ----
|
# ---- Local session / backups ----
|
||||||
.zcode/
|
.zcode/
|
||||||
.zcodeignore
|
|
||||||
scratch/
|
|
||||||
v1.2 - Copy/
|
v1.2 - Copy/
|
||||||
inv dash/
|
inv dash/
|
||||||
|
|
||||||
|
|||||||
@@ -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,175 +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 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,77 +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),
|
|
||||||
review_message_id BIGINT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)",
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)",
|
|
||||||
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS bot_messages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
chat_id BIGINT NOT NULL,
|
|
||||||
direction VARCHAR(4) NOT NULL,
|
|
||||||
kind VARCHAR(16) NOT NULL DEFAULT 'text',
|
|
||||||
body TEXT,
|
|
||||||
payload JSONB,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)",
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS bot_meta (
|
|
||||||
key VARCHAR(64) PRIMARY KEY,
|
|
||||||
value TEXT,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_bot_tables(session: AsyncSession) -> None:
|
|
||||||
for stmt in DDL:
|
|
||||||
await session.execute(text(stmt))
|
|
||||||
await session.commit()
|
|
||||||
logger.info("bot tables ready")
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_schema() -> None:
|
|
||||||
from app.db import SessionLocal
|
|
||||||
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
await ensure_bot_tables(session)
|
|
||||||
@@ -1,443 +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) -> BotDraft:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
# one active draft per (chat, kind)
|
|
||||||
await session.execute(
|
|
||||||
update(BotDraft)
|
|
||||||
.where(BotDraft.chat_id == chat_id, BotDraft.kind == kind, BotDraft.status == "active")
|
|
||||||
.values(status="superseded")
|
|
||||||
)
|
|
||||||
draft = BotDraft(chat_id=chat_id, kind=kind, payload=payload, source=source)
|
|
||||||
session.add(draft)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(draft)
|
|
||||||
return draft
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
|
|
||||||
if kind:
|
|
||||||
stmt = stmt.where(BotDraft.kind == kind)
|
|
||||||
stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1)
|
|
||||||
return (await session.execute(stmt)).scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None:
|
|
||||||
"""Resolve the short id used in callback_data (first 8 hex of the UUID)."""
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%"))
|
|
||||||
if chat_id is not None:
|
|
||||||
stmt = stmt.where(BotDraft.chat_id == chat_id)
|
|
||||||
return (await session.execute(stmt)).scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def save_payload(draft: BotDraft, missing: dict | None = None,
|
|
||||||
review_message_id: int | None = None) -> None:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
await session.execute(
|
|
||||||
update(BotDraft)
|
|
||||||
.where(BotDraft.id == draft.id)
|
|
||||||
.values(payload=draft.payload, missing=missing, review_message_id=review_message_id or draft.review_message_id)
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def set_status(draft_id: str, status: str) -> None:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def set_bale_updated_at(draft_id) -> None:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
await session.execute(
|
|
||||||
update(BotDraft)
|
|
||||||
.where(BotDraft.id == draft_id)
|
|
||||||
.values(last_bale_update_at=func.now())
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_pending_bale_updates() -> list[BotDraft]:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
stmt = (
|
|
||||||
select(BotDraft)
|
|
||||||
.where(
|
|
||||||
BotDraft.review_message_id.is_not(None),
|
|
||||||
(BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at),
|
|
||||||
)
|
|
||||||
.order_by(BotDraft.updated_at.asc())
|
|
||||||
.limit(10)
|
|
||||||
)
|
|
||||||
return list((await session.execute(stmt)).scalars().all())
|
|
||||||
|
|
||||||
|
|
||||||
async def expire_stale(ttl_hours: int) -> int:
|
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours)
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
update(BotDraft)
|
|
||||||
.where(BotDraft.status == "active", BotDraft.updated_at < cutoff)
|
|
||||||
.values(status="expired")
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return result.rowcount or 0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Quote payload
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def new_quote_payload() -> dict:
|
|
||||||
return {
|
|
||||||
"supplier_name": None, "supplier_id": None,
|
|
||||||
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
|
|
||||||
"is_official": True, "notes": None, "expiry_date": None,
|
|
||||||
"items": [], "termsheets": [],
|
|
||||||
"options": {}, "proforma_file": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def new_item(material_name: str | None = None) -> dict:
|
|
||||||
return {
|
|
||||||
"material_name": material_name, "material_id": None,
|
|
||||||
"quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False,
|
|
||||||
"unit_price": None, "brand_name": None, "specs": [], "notes": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _similarity(a: str | None, b: str | None) -> float:
|
|
||||||
na, nb = normalize_text(a or ""), normalize_text(b or "")
|
|
||||||
if not na or not nb:
|
|
||||||
return 0.0
|
|
||||||
ratio = difflib.SequenceMatcher(None, na, nb).ratio()
|
|
||||||
if na in nb or nb in na:
|
|
||||||
ratio += 0.3
|
|
||||||
return ratio
|
|
||||||
|
|
||||||
|
|
||||||
def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
|
||||||
"""Merge an NLU patch into the payload. Returns True if anything changed."""
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
def set_field(key: str, value) -> None:
|
|
||||||
nonlocal changed
|
|
||||||
if value is None:
|
|
||||||
return
|
|
||||||
if payload.get(key) != value:
|
|
||||||
payload[key] = value
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if patch.get("supplier_name"):
|
|
||||||
payload["supplier_name"] = str(patch["supplier_name"]).strip()
|
|
||||||
payload["supplier_id"] = None # needs re-resolution
|
|
||||||
changed = True
|
|
||||||
if patch.get("project_names"):
|
|
||||||
payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p]
|
|
||||||
if payload["project_names"] and not payload.get("project_name"):
|
|
||||||
payload["project_name"] = payload["project_names"][0]
|
|
||||||
payload["project_id"] = None
|
|
||||||
changed = True
|
|
||||||
elif patch.get("project_name"):
|
|
||||||
payload["project_name"] = str(patch["project_name"]).strip()
|
|
||||||
payload["project_id"] = None
|
|
||||||
if not payload.get("project_names"):
|
|
||||||
payload["project_names"] = [payload["project_name"]]
|
|
||||||
changed = True
|
|
||||||
set_field("is_official", patch.get("is_official"))
|
|
||||||
if patch.get("notes"):
|
|
||||||
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
|
||||||
changed = True
|
|
||||||
if patch.get("expiry_date"):
|
|
||||||
set_field("expiry_date", patch["expiry_date"])
|
|
||||||
|
|
||||||
for raw in patch.get("items") or []:
|
|
||||||
name = (raw.get("material_name") or "").strip() or None
|
|
||||||
target = None
|
|
||||||
if name:
|
|
||||||
best, best_ratio = None, 0.0
|
|
||||||
for item in payload["items"]:
|
|
||||||
# If both items have distinct specs, they are different line items
|
|
||||||
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
|
|
||||||
continue
|
|
||||||
# If both items have distinct brands, they are different line items
|
|
||||||
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
|
|
||||||
continue
|
|
||||||
ratio = _similarity(name, item.get("material_name"))
|
|
||||||
if ratio > best_ratio:
|
|
||||||
best, best_ratio = item, ratio
|
|
||||||
if best is not None and best_ratio >= 0.55:
|
|
||||||
target = best
|
|
||||||
if target is None:
|
|
||||||
target = new_item(name)
|
|
||||||
payload["items"].append(target)
|
|
||||||
changed = True
|
|
||||||
for key in ("quantity", "unit_price"):
|
|
||||||
if raw.get(key) is not None:
|
|
||||||
try:
|
|
||||||
value = float(raw[key])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if target.get(key) != value:
|
|
||||||
target[key] = value
|
|
||||||
changed = True
|
|
||||||
if raw.get("specs"):
|
|
||||||
target["specs"] = [str(s).strip() for s in raw["specs"] if s]
|
|
||||||
spec_str = "، ".join(target["specs"])
|
|
||||||
if spec_str and not target.get("notes"):
|
|
||||||
target["notes"] = spec_str
|
|
||||||
changed = True
|
|
||||||
for key in ("unit_name", "brand_name", "notes"):
|
|
||||||
if raw.get(key):
|
|
||||||
val = str(raw[key]).strip()
|
|
||||||
if target.get(key) != val:
|
|
||||||
target[key] = val
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
for raw in patch.get("payment_terms") or []:
|
|
||||||
label = (raw.get("label") or "شرایط پرداخت").strip()
|
|
||||||
milestones = []
|
|
||||||
for m in raw.get("milestones") or []:
|
|
||||||
if m.get("due_date") or m.get("amount"):
|
|
||||||
milestones.append({
|
|
||||||
"due_date": m.get("due_date"),
|
|
||||||
"amount": float(m.get("amount") or 0),
|
|
||||||
"note": m.get("note"),
|
|
||||||
})
|
|
||||||
entry = {
|
|
||||||
"label": label,
|
|
||||||
"discount_type": raw.get("discount_type") or "none",
|
|
||||||
"discount_value": float(raw.get("discount_value") or 0),
|
|
||||||
"milestones": milestones,
|
|
||||||
"description": (raw.get("description") or "").strip() or None,
|
|
||||||
}
|
|
||||||
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
|
|
||||||
payload["termsheets"].append(entry)
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def compute_quote_missing(payload: dict) -> list[dict]:
|
|
||||||
"""What is still empty — the user's explicit ask («یادآوری چیزهای خالی»)."""
|
|
||||||
out: list[dict] = []
|
|
||||||
|
|
||||||
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
|
|
||||||
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
|
|
||||||
|
|
||||||
sup_ok = bool(payload.get("supplier_id"))
|
|
||||||
add("supplier", "تأمینکننده", sup_ok, True)
|
|
||||||
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
|
|
||||||
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
|
|
||||||
|
|
||||||
items = payload.get("items") or []
|
|
||||||
add("items", "قلم (کالا/مقدار)", bool(items), True)
|
|
||||||
for i, item in enumerate(items, start=1):
|
|
||||||
name = item.get("material_name") or f"قلم {i}"
|
|
||||||
if not item.get("material_id"):
|
|
||||||
add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True)
|
|
||||||
if not item.get("quantity"):
|
|
||||||
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
|
|
||||||
if not (item.get("unit_id") or item.get("unit_name")):
|
|
||||||
add(f"item:{i}:unit", f"واحد «{name}»", False, True)
|
|
||||||
if not item.get("unit_price"):
|
|
||||||
add(f"item:{i}:price", f"قیمت واحد «{name}»", False, False)
|
|
||||||
if items and not payload.get("termsheets"):
|
|
||||||
add("terms", "شرایط پرداخت", False, False)
|
|
||||||
if not payload.get("notes"):
|
|
||||||
add("notes", "یادداشت", False, False)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def quote_ready(payload: dict) -> tuple[bool, list[str]]:
|
|
||||||
"""Blocking check before commit; returns (ok, blocking_labels)."""
|
|
||||||
missing = compute_quote_missing(payload)
|
|
||||||
return all(not (m["blocking"] and not m["ok"]) for m in missing), [
|
|
||||||
m["label"] for m in missing if m["blocking"] and not m["ok"]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]:
|
|
||||||
"""Convert the payload into a QuoteCreate body. Returns (body, problems)."""
|
|
||||||
problems: list[str] = []
|
|
||||||
if not payload.get("supplier_id"):
|
|
||||||
return None, ["تأمینکننده مشخص نیست"]
|
|
||||||
|
|
||||||
items = payload.get("items") or []
|
|
||||||
line_items: list[dict] = []
|
|
||||||
for i, item in enumerate(items, start=1):
|
|
||||||
if not item.get("material_id"):
|
|
||||||
if only_resolved_items:
|
|
||||||
continue
|
|
||||||
problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد")
|
|
||||||
continue
|
|
||||||
if not item.get("quantity"):
|
|
||||||
problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
|
|
||||||
continue
|
|
||||||
unit_id = item.get("unit_id")
|
|
||||||
if not unit_id:
|
|
||||||
problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
|
|
||||||
continue
|
|
||||||
notes_parts = []
|
|
||||||
if item.get("brand_name"):
|
|
||||||
notes_parts.append(f"برند: {item['brand_name']}")
|
|
||||||
if item.get("specs"):
|
|
||||||
notes_parts.append("، ".join(item["specs"]))
|
|
||||||
if item.get("notes"):
|
|
||||||
if item["notes"] not in notes_parts:
|
|
||||||
notes_parts.append(item["notes"])
|
|
||||||
notes_str = " | ".join(notes_parts) if notes_parts else None
|
|
||||||
|
|
||||||
line_items.append({
|
|
||||||
"temp_id": f"t{i}",
|
|
||||||
"material_id": item["material_id"],
|
|
||||||
"unit_id": int(unit_id),
|
|
||||||
"quantity": item["quantity"],
|
|
||||||
"notes": notes_str,
|
|
||||||
})
|
|
||||||
|
|
||||||
termsheets: list[dict] = []
|
|
||||||
item_termsheets: list[dict] = []
|
|
||||||
priced = [it for it in items if it.get("unit_price") and it.get("material_id")]
|
|
||||||
if priced:
|
|
||||||
raw_sheets = payload.get("termsheets") or []
|
|
||||||
if not raw_sheets:
|
|
||||||
raw_sheets = [{"label": "پیشنهاد قیمت"}]
|
|
||||||
for j, ts in enumerate(raw_sheets, start=1):
|
|
||||||
steps = []
|
|
||||||
for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1):
|
|
||||||
steps.append({
|
|
||||||
"step_no": s_idx,
|
|
||||||
"due_date": m.get("due_date"),
|
|
||||||
"amount": float(m.get("amount") or 0),
|
|
||||||
"note": m.get("note"),
|
|
||||||
})
|
|
||||||
termsheets.append({
|
|
||||||
"temp_id": f"p{j}",
|
|
||||||
"label": ts.get("label") or f"شرایط {j}",
|
|
||||||
"discount_type": ts.get("discount_type") or "none",
|
|
||||||
"discount_value": float(ts.get("discount_value") or 0),
|
|
||||||
"schedule_steps": steps,
|
|
||||||
})
|
|
||||||
first = termsheets[0]["temp_id"]
|
|
||||||
for i, item in enumerate(items, start=1):
|
|
||||||
if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"):
|
|
||||||
item_termsheets.append({
|
|
||||||
"line_item_temp_id": f"t{i}",
|
|
||||||
"termsheet_temp_id": first,
|
|
||||||
"unit_price": item["unit_price"],
|
|
||||||
})
|
|
||||||
|
|
||||||
body: dict = {
|
|
||||||
"supplier_id": payload["supplier_id"],
|
|
||||||
"is_official": bool(payload.get("is_official", True)),
|
|
||||||
"line_items": line_items,
|
|
||||||
"termsheets": termsheets,
|
|
||||||
"item_termsheets": item_termsheets,
|
|
||||||
}
|
|
||||||
if payload.get("project_ids"):
|
|
||||||
body["project_ids"] = payload["project_ids"]
|
|
||||||
elif payload.get("project_id"):
|
|
||||||
body["project_ids"] = [payload["project_id"]]
|
|
||||||
if payload.get("expiry_date"):
|
|
||||||
body["expiry_date"] = payload["expiry_date"]
|
|
||||||
if payload.get("notes"):
|
|
||||||
body["notes"] = payload["notes"]
|
|
||||||
if not line_items and problems:
|
|
||||||
return None, problems
|
|
||||||
return body, problems
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Supplier / material payloads
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def new_supplier_payload() -> dict:
|
|
||||||
return {"name": None, "category_id": None, "category_name": None,
|
|
||||||
"phone": None, "location": None, "contact_person": None, "options": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def merge_supplier_patch(payload: dict, patch: dict) -> bool:
|
|
||||||
src = patch.get("supplier") or patch
|
|
||||||
changed = False
|
|
||||||
for key in ("name", "phone", "location", "contact_person"):
|
|
||||||
if src.get(key) and payload.get(key) != str(src[key]).strip():
|
|
||||||
payload[key] = str(src[key]).strip()
|
|
||||||
changed = True
|
|
||||||
if src.get("category_name"):
|
|
||||||
payload["category_name"] = str(src["category_name"]).strip()
|
|
||||||
payload["category_id"] = None
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def supplier_missing(payload: dict) -> list[dict]:
|
|
||||||
return [
|
|
||||||
{"key": "name", "label": "نام تأمینکننده", "ok": bool(payload.get("name")), "blocking": True},
|
|
||||||
{"key": "category", "label": "دستهبندی", "ok": bool(payload.get("category_id")), "blocking": True},
|
|
||||||
{"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False},
|
|
||||||
{"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def new_material_payload() -> dict:
|
|
||||||
return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def merge_material_patch(payload: dict, patch: dict) -> bool:
|
|
||||||
src = patch.get("material") or patch
|
|
||||||
changed = False
|
|
||||||
if src.get("name") and payload.get("name") != str(src["name"]).strip():
|
|
||||||
payload["name"] = str(src["name"]).strip()
|
|
||||||
changed = True
|
|
||||||
if src.get("unit_name"):
|
|
||||||
payload["unit_name"] = str(src["unit_name"]).strip()
|
|
||||||
payload["canonical_unit_id"] = None
|
|
||||||
changed = True
|
|
||||||
if src.get("topic") and payload.get("topic") != str(src["topic"]).strip():
|
|
||||||
payload["topic"] = str(src["topic"]).strip()
|
|
||||||
changed = True
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def material_missing(payload: dict) -> list[dict]:
|
|
||||||
return [
|
|
||||||
{"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True},
|
|
||||||
{"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True},
|
|
||||||
{"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def short_id() -> str:
|
|
||||||
return uuid.uuid4().hex[:8]
|
|
||||||
@@ -1,192 +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
|
|
||||||
|
|
||||||
draft = await draft_store.get_active(chat_id)
|
|
||||||
draft_summary = _draft_summary(draft)
|
|
||||||
result = await understand(settings, text, draft_summary)
|
|
||||||
intent, patch = result.get("intent"), result.get("patch") or {}
|
|
||||||
logger.info("chat %s intent=%s conf=%s", chat_id, intent, result.get("confidence"))
|
|
||||||
|
|
||||||
if intent in ("register_quote", "update_draft"):
|
|
||||||
await quote.handle_entities(ctx, chat_id, user, patch, source)
|
|
||||||
elif intent == "commit_draft":
|
|
||||||
active = await draft_store.get_active(chat_id)
|
|
||||||
if active is None:
|
|
||||||
await ctx.send(chat_id, NOTHING_TO_COMMIT)
|
|
||||||
elif active.kind == draft_store.KIND_QUOTE:
|
|
||||||
await quote.commit(ctx, chat_id, user, active)
|
|
||||||
elif active.kind == draft_store.KIND_SUPPLIER:
|
|
||||||
await supplier.commit(ctx, chat_id, user, active)
|
|
||||||
elif active.kind == draft_store.KIND_MATERIAL:
|
|
||||||
await material.commit(ctx, chat_id, user, active)
|
|
||||||
elif intent == "discard_draft":
|
|
||||||
active = await draft_store.get_active(chat_id)
|
|
||||||
if active:
|
|
||||||
await draft_store.set_status(active.id, "cancelled")
|
|
||||||
ctx.pending.pop(chat_id)
|
|
||||||
await ctx.send(chat_id, CANCELLED)
|
|
||||||
elif intent == "show_draft":
|
|
||||||
active = await draft_store.get_active(chat_id)
|
|
||||||
if active is None:
|
|
||||||
await ctx.send(chat_id, NOTHING_TO_COMMIT)
|
|
||||||
elif active.kind == draft_store.KIND_QUOTE:
|
|
||||||
await quote.render(ctx, chat_id, active, fresh=False)
|
|
||||||
elif active.kind == draft_store.KIND_SUPPLIER:
|
|
||||||
await supplier.render(ctx, chat_id, active)
|
|
||||||
elif active.kind == draft_store.KIND_MATERIAL:
|
|
||||||
await material.render(ctx, chat_id, active)
|
|
||||||
elif intent == "add_supplier":
|
|
||||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
|
||||||
elif intent == "add_material":
|
|
||||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
|
||||||
elif intent == "status_query":
|
|
||||||
await queries.handle_query(ctx, chat_id, user, patch)
|
|
||||||
elif intent == "accept_quote":
|
|
||||||
await decisions.start_accept(ctx, chat_id, user, patch)
|
|
||||||
elif intent == "arrive_items":
|
|
||||||
await decisions.start_arrive(ctx, chat_id, user, patch)
|
|
||||||
else:
|
|
||||||
await ctx.send(chat_id, UNKNOWN_HINT)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
|
|
||||||
"""Link or admin gate. Returns None when the message was consumed by linking."""
|
|
||||||
from . import links
|
|
||||||
|
|
||||||
link = await ctx.links.get(chat_id)
|
|
||||||
if link and link.user_id:
|
|
||||||
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
|
||||||
if chat_id in ctx.admin_chat_ids:
|
|
||||||
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
|
||||||
|
|
||||||
flow = ctx.pending.get(chat_id)
|
|
||||||
if flow and flow.kind == "link_password":
|
|
||||||
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
|
|
||||||
return None
|
|
||||||
if flow and flow.kind == "link_username":
|
|
||||||
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
|
|
||||||
return None
|
|
||||||
await ctx.send(chat_id, LINK_START)
|
|
||||||
ctx.pending.set(chat_id, "link_username", {})
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool:
|
|
||||||
from . import links, decisions
|
|
||||||
|
|
||||||
if flow.kind == "link_username":
|
|
||||||
await links.finish_username_step(ctx, chat_id, flow, text)
|
|
||||||
return True
|
|
||||||
if flow.kind == "link_password":
|
|
||||||
await links.finish_link(ctx, chat_id, flow, text=text, password=text)
|
|
||||||
return True
|
|
||||||
if flow.kind == "quote_pick":
|
|
||||||
await decisions.handle_quote_pick_text(ctx, chat_id, flow, text)
|
|
||||||
return True
|
|
||||||
if flow.kind in ("accept", "arrive_qty", "arrive_confirm"):
|
|
||||||
return await decisions.handle_flow_text(ctx, chat_id, flow, text)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _draft_summary(draft) -> str | None:
|
|
||||||
if draft is None:
|
|
||||||
return None
|
|
||||||
import json
|
|
||||||
|
|
||||||
payload = draft.payload or {}
|
|
||||||
if draft.kind == draft_store.KIND_QUOTE:
|
|
||||||
items = ", ".join(
|
|
||||||
f"{it.get('material_name') or '?'}"
|
|
||||||
+ (f" {it.get('quantity')}" if it.get("quantity") else "")
|
|
||||||
+ (f" {it.get('unit_name')}" if it.get("unit_name") else "")
|
|
||||||
for it in payload.get("items") or []
|
|
||||||
)
|
|
||||||
summary = (
|
|
||||||
f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; "
|
|
||||||
f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}"
|
|
||||||
)
|
|
||||||
elif draft.kind == draft_store.KIND_SUPPLIER:
|
|
||||||
summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}"
|
|
||||||
elif draft.kind == draft_store.KIND_MATERIAL:
|
|
||||||
summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}"
|
|
||||||
else:
|
|
||||||
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
|
||||||
return summary
|
|
||||||
@@ -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,280 +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) -> None:
|
|
||||||
draft = await draft_store.get_active(chat_id, KIND_QUOTE)
|
|
||||||
if draft is not None:
|
|
||||||
payload = draft.payload
|
|
||||||
merge_quote_patch(payload, patch)
|
|
||||||
else:
|
|
||||||
payload = new_quote_payload()
|
|
||||||
merge_quote_patch(payload, patch)
|
|
||||||
draft = await draft_store.create(chat_id, KIND_QUOTE, payload, source=source)
|
|
||||||
await resolve_payload(ctx, payload)
|
|
||||||
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
|
|
||||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# resolution
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def resolve_payload(ctx, payload: dict) -> None:
|
|
||||||
options = payload.setdefault("options", {})
|
|
||||||
|
|
||||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
|
||||||
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
|
|
||||||
if res.resolved:
|
|
||||||
payload["supplier_id"] = res.candidates[0].id
|
|
||||||
payload["supplier_name"] = res.candidates[0].name
|
|
||||||
options.pop("sup", None)
|
|
||||||
elif res.candidates:
|
|
||||||
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
|
||||||
else:
|
|
||||||
options.pop("sup", None)
|
|
||||||
|
|
||||||
if not payload.get("project_id") and payload.get("project_name"):
|
|
||||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
|
||||||
if res.resolved:
|
|
||||||
payload["project_id"] = res.candidates[0].id
|
|
||||||
payload["project_name"] = res.candidates[0].name
|
|
||||||
if payload["project_id"] not in payload.get("project_ids", []):
|
|
||||||
payload.setdefault("project_ids", []).append(payload["project_id"])
|
|
||||||
options.pop("prj", None)
|
|
||||||
elif res.candidates:
|
|
||||||
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
|
||||||
else:
|
|
||||||
options.pop("prj", None)
|
|
||||||
|
|
||||||
for p_name in payload.get("project_names") or []:
|
|
||||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
|
||||||
if res.resolved:
|
|
||||||
p_id = res.candidates[0].id
|
|
||||||
if p_id not in payload.get("project_ids", []):
|
|
||||||
payload.setdefault("project_ids", []).append(p_id)
|
|
||||||
if not payload.get("project_id"):
|
|
||||||
payload["project_id"] = p_id
|
|
||||||
|
|
||||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
|
||||||
if not item.get("material_id") and item.get("material_name"):
|
|
||||||
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
|
|
||||||
if res.resolved:
|
|
||||||
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
|
|
||||||
options.pop(f"mat{i}", None)
|
|
||||||
elif res.candidates:
|
|
||||||
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
|
||||||
else:
|
|
||||||
options.pop(f"mat{i}", None)
|
|
||||||
if not item.get("unit_id"):
|
|
||||||
await _resolve_unit(ctx, item)
|
|
||||||
|
|
||||||
|
|
||||||
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
|
|
||||||
item["material_id"] = material_id
|
|
||||||
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_unit(ctx, item: dict) -> None:
|
|
||||||
if item.get("unit_id"):
|
|
||||||
return
|
|
||||||
if item.get("unit_name"):
|
|
||||||
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
|
|
||||||
if res.resolved:
|
|
||||||
item["unit_id"] = res.candidates[0].id
|
|
||||||
item["unit_explicit"] = True
|
|
||||||
item["unit_name"] = res.candidates[0].name
|
|
||||||
return
|
|
||||||
canonical = ((item.get("material_row") or {}).get("canonical_unit_id"))
|
|
||||||
if canonical:
|
|
||||||
item["unit_id"] = str(canonical)
|
|
||||||
item["unit_explicit"] = False
|
|
||||||
|
|
||||||
|
|
||||||
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
|
||||||
payload = draft.payload
|
|
||||||
options = payload.setdefault("options", {})
|
|
||||||
options.pop(field, None)
|
|
||||||
|
|
||||||
if field == "sup":
|
|
||||||
payload["supplier_id"] = choice["id"]
|
|
||||||
payload["supplier_name"] = choice["name"]
|
|
||||||
elif field == "prj":
|
|
||||||
payload["project_id"] = choice["id"]
|
|
||||||
payload["project_name"] = choice["name"]
|
|
||||||
elif field.startswith("mat"):
|
|
||||||
index = int(field[3:]) - 1
|
|
||||||
items = payload.get("items") or []
|
|
||||||
if 0 <= index < len(items):
|
|
||||||
items[index]["material_name"] = choice["name"]
|
|
||||||
_set_material(items[index], choice["id"], choice["name"])
|
|
||||||
await _resolve_unit(ctx, items[index])
|
|
||||||
elif field.startswith("unit"):
|
|
||||||
index = int(field[4:]) - 1
|
|
||||||
items = payload.get("items") or []
|
|
||||||
if 0 <= index < len(items):
|
|
||||||
items[index]["unit_id"] = choice["id"]
|
|
||||||
items[index]["unit_explicit"] = True
|
|
||||||
items[index]["unit_name"] = choice["name"]
|
|
||||||
|
|
||||||
await resolve_payload(ctx, payload)
|
|
||||||
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
|
|
||||||
await render(ctx, chat_id, draft, fresh=False)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# rendering
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _pick_rows(payload: dict) -> list[list[str]]:
|
|
||||||
"""Field labels shown as pick-button rows (via keyboards.pick_buttons)."""
|
|
||||||
rows: list[list[str]] = []
|
|
||||||
options = payload.get("options") or {}
|
|
||||||
if options.get("sup"):
|
|
||||||
rows.append(["sup"] + [o["name"] for o in options["sup"]])
|
|
||||||
if options.get("prj"):
|
|
||||||
rows.append(["prj"] + [o["name"] for o in options["prj"]])
|
|
||||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
|
||||||
if options.get(f"mat{i}"):
|
|
||||||
rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]])
|
|
||||||
if options.get(f"unit{i}"):
|
|
||||||
rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]])
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict:
|
|
||||||
from app.keyboards import pick_buttons
|
|
||||||
|
|
||||||
prefix = str(draft_id)[:8]
|
|
||||||
pick_rows: list[list[dict]] = []
|
|
||||||
for row in _pick_rows(payload):
|
|
||||||
field, names = row[0], row[1:]
|
|
||||||
pick_rows += pick_buttons(prefix, field, names[:6])
|
|
||||||
ready_items = sum(
|
|
||||||
1 for it in payload.get("items") or []
|
|
||||||
if it.get("material_id") and it.get("quantity") and it.get("unit_id")
|
|
||||||
)
|
|
||||||
has_partial = 0 < ready_items < len(payload.get("items") or [])
|
|
||||||
return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url)
|
|
||||||
|
|
||||||
|
|
||||||
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None) -> None:
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
payload = draft.payload
|
|
||||||
missing = compute_quote_missing(payload)
|
|
||||||
wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}"
|
|
||||||
text = quote_review_text(payload, missing, status_note=note)
|
|
||||||
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
|
|
||||||
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
|
|
||||||
if fresh:
|
|
||||||
message = await ctx.send(chat_id, text, reply_markup=keyboard)
|
|
||||||
draft.review_message_id = (message or {}).get("message_id")
|
|
||||||
await draft_store.save_payload(draft, missing={"fields": missing},
|
|
||||||
review_message_id=draft.review_message_id)
|
|
||||||
await draft_store.set_bale_updated_at(draft.id)
|
|
||||||
else:
|
|
||||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
|
||||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
|
||||||
await draft_store.set_bale_updated_at(draft.id)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# commit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
|
|
||||||
payload = draft.payload
|
|
||||||
await resolve_payload(ctx, payload)
|
|
||||||
|
|
||||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
|
||||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
|
||||||
cat_id = payload.get("supplier_category_id")
|
|
||||||
if not cat_id:
|
|
||||||
cats = await ctx.resolver.categories()
|
|
||||||
if cats:
|
|
||||||
cat_id = cats[0].id
|
|
||||||
if cat_id:
|
|
||||||
try:
|
|
||||||
sup_body = {
|
|
||||||
"name": payload["supplier_name"],
|
|
||||||
"category_id": cat_id,
|
|
||||||
}
|
|
||||||
if payload.get("supplier_phone"):
|
|
||||||
sup_body["phone"] = payload["supplier_phone"]
|
|
||||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
|
||||||
payload["supplier_id"] = created_sup["id"]
|
|
||||||
ctx.resolver.invalidate("suppliers")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not auto-create supplier: %s", e)
|
|
||||||
|
|
||||||
# 2. Sequential pre-creation for new materials
|
|
||||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
|
||||||
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
|
||||||
try:
|
|
||||||
created_mat = await ctx.api.create_material({
|
|
||||||
"name": item["material_name"],
|
|
||||||
"canonical_unit_id": int(item["unit_id"]),
|
|
||||||
}, actor_id=user.actor_id)
|
|
||||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
|
||||||
ctx.resolver.invalidate("materials")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not auto-create material: %s", e)
|
|
||||||
|
|
||||||
ready, blockers = quote_ready(payload)
|
|
||||||
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
|
||||||
if body is None:
|
|
||||||
lines = ["ثبت ممکن نیست؛ اینها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems]
|
|
||||||
await ctx.send(chat_id, "\n".join(lines))
|
|
||||||
return
|
|
||||||
if not ready and not only_resolved:
|
|
||||||
lines = ["برای ثبت کامل، اول اینها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers]
|
|
||||||
lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."]
|
|
||||||
await ctx.send(chat_id, "\n".join(lines))
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = await ctx.api.create_quote(body, actor_id=user.actor_id)
|
|
||||||
except ApiError as exc:
|
|
||||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
logger.exception("create_quote failed")
|
|
||||||
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه"))
|
|
||||||
return
|
|
||||||
|
|
||||||
await draft_store.set_status(draft.id, "committed")
|
|
||||||
ctx.pending.pop(chat_id, None)
|
|
||||||
ctx.resolver.invalidate()
|
|
||||||
|
|
||||||
if draft.review_message_id:
|
|
||||||
try:
|
|
||||||
quote_id = (created or {}).get("id") or ""
|
|
||||||
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
|
|
||||||
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
|
|
||||||
await draft_store.set_bale_updated_at(draft.id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
remaining = [
|
|
||||||
m["label"] for m in compute_quote_missing(payload)
|
|
||||||
if not m["blocking"] and not m["ok"]
|
|
||||||
]
|
|
||||||
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
|
||||||
if dropped > 0:
|
|
||||||
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
|
||||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
|
|
||||||
@@ -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,80 +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"),)
|
|
||||||
|
|
||||||
id: Mapped[str] = _uuid_pk()
|
|
||||||
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
|
||||||
kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material
|
|
||||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
|
||||||
missing: Mapped[dict | None] = mapped_column(JSONB)
|
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed
|
|
||||||
source: Mapped[str | None] = mapped_column(String(16)) # text|voice
|
|
||||||
review_message_id: Mapped[int | None] = mapped_column(BigInteger)
|
|
||||||
last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
created_at: Mapped[datetime] = _timestamptz()
|
|
||||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
|
||||||
|
|
||||||
|
|
||||||
class BotMessage(Base):
|
|
||||||
"""Audit log of every inbound/outbound message (who asked for what)."""
|
|
||||||
|
|
||||||
__tablename__ = "bot_messages"
|
|
||||||
|
|
||||||
id: Mapped[str] = _uuid_pk()
|
|
||||||
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
|
||||||
direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out
|
|
||||||
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error
|
|
||||||
body: Mapped[str | None] = mapped_column(Text)
|
|
||||||
payload: Mapped[dict | None] = mapped_column(JSONB)
|
|
||||||
created_at: Mapped[datetime] = _timestamptz()
|
|
||||||
|
|
||||||
|
|
||||||
class BotMeta(Base):
|
|
||||||
"""Tiny KV store (e.g. last processed Bale update_id across restarts)."""
|
|
||||||
|
|
||||||
__tablename__ = "bot_meta"
|
|
||||||
|
|
||||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
||||||
value: Mapped[str | None] = mapped_column(Text)
|
|
||||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
|
||||||
@@ -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,159 +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) -> dict:
|
|
||||||
"""Send + audit-log every outbound message."""
|
|
||||||
try:
|
|
||||||
message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("failed to send to chat %s", chat_id)
|
|
||||||
message = {}
|
|
||||||
await self._log(chat_id, "out", "text", text)
|
|
||||||
return message
|
|
||||||
|
|
||||||
async def edit(self, chat_id: int, message_id: int | None, text: str,
|
|
||||||
reply_markup: dict | None = None) -> None:
|
|
||||||
if not message_id:
|
|
||||||
await self.send(chat_id, text, reply_markup=reply_markup)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup)
|
|
||||||
except Exception:
|
|
||||||
await self.send(chat_id, text, reply_markup=reply_markup)
|
|
||||||
|
|
||||||
async def _log(self, chat_id: int, direction: str, kind: str, body: str | None = None) -> None:
|
|
||||||
try:
|
|
||||||
async with SessionLocal() as session:
|
|
||||||
session.add(BotMessage(chat_id=chat_id, direction=direction, kind=kind, body=body))
|
|
||||||
await session.commit()
|
|
||||||
except Exception:
|
|
||||||
logger.exception("failed to log bot message")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LinkedUser:
|
|
||||||
chat_id: int
|
|
||||||
user_id: str | None
|
|
||||||
display_name: str | None
|
|
||||||
role: str | None
|
|
||||||
is_admin: bool
|
|
||||||
|
|
||||||
@property
|
|
||||||
def actor_id(self) -> str | None:
|
|
||||||
return self.user_id
|
|
||||||
@@ -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()))
|
|
||||||
+37
-18
@@ -247,6 +247,7 @@ _V26_DDL = [
|
|||||||
physical_qty NUMERIC(18,6),
|
physical_qty NUMERIC(18,6),
|
||||||
transfer_pair_id UUID,
|
transfer_pair_id UUID,
|
||||||
performed_by TEXT,
|
performed_by TEXT,
|
||||||
|
delivered_to TEXT,
|
||||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -258,6 +259,9 @@ _V26_DDL = [
|
|||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
|
||||||
|
""",
|
||||||
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
||||||
"""
|
"""
|
||||||
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
||||||
@@ -434,31 +438,46 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
|
|||||||
logger.info("audit_log table ensured")
|
logger.info("audit_log table ensured")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
|
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
|
||||||
"""Ensure bot_drafts table and indexes exist for Bale bot quote sync."""
|
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
|
||||||
ddl = """
|
ddl = """
|
||||||
CREATE TABLE IF NOT EXISTS bot_drafts (
|
CREATE TABLE IF NOT EXISTS project_transfers (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
chat_id BIGINT NOT NULL,
|
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
kind VARCHAR(16) NOT NULL,
|
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
missing JSONB,
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||||
source VARCHAR(16),
|
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
review_message_id BIGINT,
|
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||||||
last_bale_update_at TIMESTAMPTZ,
|
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
note TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
|
||||||
|
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
|
||||||
|
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
transferred_by TEXT,
|
||||||
|
received_at TIMESTAMPTZ,
|
||||||
|
received_by TEXT,
|
||||||
|
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
|
||||||
|
receipt_allocations JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
indexes = (
|
indexes = (
|
||||||
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts(chat_id)",
|
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts(chat_id, status)",
|
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
|
||||||
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
|
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER trg_project_transfers_updated_at
|
||||||
|
BEFORE UPDATE ON project_transfers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||||
|
""",
|
||||||
)
|
)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(text(ddl))
|
await conn.execute(text(ddl))
|
||||||
for stmt in indexes:
|
for stmt in indexes:
|
||||||
await conn.execute(text(stmt))
|
await conn.execute(text(stmt))
|
||||||
logger.info("bot_drafts table ensured")
|
logger.info("project_transfers table ensured")
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -27,11 +27,11 @@ 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,
|
||||||
ensure_quote_projects,
|
ensure_quote_projects,
|
||||||
|
ensure_project_transfers_table,
|
||||||
ensure_v26,
|
ensure_v26,
|
||||||
)
|
)
|
||||||
from app.observability import RequestContextMiddleware, setup_logging
|
from app.observability import RequestContextMiddleware, setup_logging
|
||||||
@@ -108,7 +108,7 @@ 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)
|
await ensure_project_transfers_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 +235,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)])
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from app.models.procurement import (
|
|||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.models.project_transfer import ProjectTransfer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"Unit",
|
"Unit",
|
||||||
@@ -60,4 +62,5 @@ __all__ = [
|
|||||||
"ItemDecision",
|
"ItemDecision",
|
||||||
"ItemReceipt",
|
"ItemReceipt",
|
||||||
"QuoteAttachment",
|
"QuoteAttachment",
|
||||||
|
"ProjectTransfer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,20 +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))
|
|
||||||
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())
|
|
||||||
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
|
|||||||
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
||||||
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
||||||
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
||||||
|
delivered_to: Mapped[str | None] = mapped_column(Text)
|
||||||
transaction_date: Mapped[datetime] = mapped_column(
|
transaction_date: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Numeric,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.models.catalog import _timestamptz, _uuid_pk
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransfer(Base):
|
||||||
|
"""project_transfers — Inter-project (inter-site) material transfer ledger.
|
||||||
|
|
||||||
|
Records the movement of materials from a source project warehouse to a
|
||||||
|
destination project. Stock is deducted from the source warehouse immediately
|
||||||
|
upon creation (status = 'in_transit') via out_transaction_id.
|
||||||
|
When the destination project PM confirms receipt in 'Arrivals', stock is
|
||||||
|
added to the destination warehouse (status = 'received').
|
||||||
|
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
|
||||||
|
Once 'received', the transfer is permanently locked from edit or cancellation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "project_transfers"
|
||||||
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
from_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
to_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
material_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
brand_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("brands.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
unit_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
|
||||||
|
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
|
||||||
|
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
|
||||||
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
|
||||||
|
|
||||||
|
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
transfer_date: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
transferred_by: Mapped[str | None] = mapped_column(Text)
|
||||||
|
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
received_by: Mapped[str | None] = mapped_column(Text)
|
||||||
|
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("warehouses.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
|
||||||
|
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
|
||||||
|
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
|
||||||
|
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
|
||||||
|
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
|
||||||
|
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
|
||||||
|
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
|
||||||
|
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
|
||||||
|
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
@@ -15,6 +14,7 @@ from app.routers import (
|
|||||||
material_categories,
|
material_categories,
|
||||||
materials,
|
materials,
|
||||||
pm,
|
pm,
|
||||||
|
project_transfers,
|
||||||
projects,
|
projects,
|
||||||
quote_events,
|
quote_events,
|
||||||
quotes,
|
quotes,
|
||||||
@@ -51,6 +51,6 @@ ROUTERS = [
|
|||||||
standard_conversions.router,
|
standard_conversions.router,
|
||||||
inventory.router,
|
inventory.router,
|
||||||
pm.router,
|
pm.router,
|
||||||
|
project_transfers.router,
|
||||||
users.router,
|
users.router,
|
||||||
drafts.router,
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
@@ -12,6 +12,7 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
@@ -197,6 +198,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
|
|||||||
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||||
"performed_by": tx.performed_by,
|
"performed_by": tx.performed_by,
|
||||||
|
"delivered_to": tx.delivered_to,
|
||||||
"transaction_date": to_iso(tx.transaction_date),
|
"transaction_date": to_iso(tx.transaction_date),
|
||||||
"note": tx.note,
|
"note": tx.note,
|
||||||
}
|
}
|
||||||
@@ -397,9 +399,18 @@ async def list_transactions(
|
|||||||
if transaction_type is not None:
|
if transaction_type is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||||
|
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||||
|
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.engine import get_db
|
||||||
|
from app.routers.pm import ArrivalScope, arrivals_scope
|
||||||
|
from app.schemas.project_transfers import (
|
||||||
|
ProjectTransferCreate,
|
||||||
|
ProjectTransferOut,
|
||||||
|
ProjectTransferReceive,
|
||||||
|
ProjectTransferUpdate,
|
||||||
|
)
|
||||||
|
from app.services import project_transfer_service
|
||||||
|
from app.utils.errors import ForbiddenError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["project-transfers"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_transfer_out(t) -> dict:
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"from_project": (
|
||||||
|
{"id": t.from_project.id, "name": t.from_project.name}
|
||||||
|
if t.from_project
|
||||||
|
else {"id": t.from_project_id, "name": ""}
|
||||||
|
),
|
||||||
|
"from_warehouse": (
|
||||||
|
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
|
||||||
|
if t.from_warehouse
|
||||||
|
else {"id": t.from_warehouse_id, "name": ""}
|
||||||
|
),
|
||||||
|
"to_project": (
|
||||||
|
{"id": t.to_project.id, "name": t.to_project.name}
|
||||||
|
if t.to_project
|
||||||
|
else {"id": t.to_project_id, "name": ""}
|
||||||
|
),
|
||||||
|
"to_warehouse": (
|
||||||
|
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
|
||||||
|
if t.to_warehouse
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"material": (
|
||||||
|
{"id": t.material.id, "name": t.material.name}
|
||||||
|
if t.material
|
||||||
|
else {"id": t.material_id, "name": ""}
|
||||||
|
),
|
||||||
|
"brand": (
|
||||||
|
{"id": t.brand.id, "name": t.brand.name}
|
||||||
|
if t.brand
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"unit": (
|
||||||
|
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
|
||||||
|
if t.unit
|
||||||
|
else {"id": t.unit_id, "code": "", "name_fa": None}
|
||||||
|
),
|
||||||
|
"quantity": t.quantity,
|
||||||
|
"specs": t.specs or {},
|
||||||
|
"specs_hash": t.specs_hash,
|
||||||
|
"note": t.note,
|
||||||
|
"status": t.status,
|
||||||
|
"transfer_date": t.transfer_date,
|
||||||
|
"transferred_by": t.transferred_by,
|
||||||
|
"received_at": t.received_at,
|
||||||
|
"received_by": t.received_by,
|
||||||
|
"receipt_allocations": t.receipt_allocations,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"updated_at": t.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /inventory/project-transfers — Create inter-project transfer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/inventory/project-transfers", status_code=201)
|
||||||
|
async def create_transfer(
|
||||||
|
payload: ProjectTransferCreate,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.create_project_transfer(
|
||||||
|
db=db,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
transferred_by=ctx.display_name,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /inventory/project-transfers — List transfers with direction and filters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/inventory/project-transfers")
|
||||||
|
async def list_transfers(
|
||||||
|
project_id: uuid.UUID | None = Query(default=None),
|
||||||
|
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
target_pid = project_id or ctx.project_id
|
||||||
|
transfers = await project_transfer_service.list_project_transfers(
|
||||||
|
db=db,
|
||||||
|
project_id=target_pid,
|
||||||
|
direction=direction,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /inventory/project-transfers/{id} — Single transfer details
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def get_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
|
||||||
|
if ctx.role == "pm" and ctx.project_id is not None:
|
||||||
|
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||||
|
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.patch("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def update_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferUpdate,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.update_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.delete("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def delete_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
res = await project_transfer_service.delete_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
)
|
||||||
|
return {"data": res}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/pm/transfers/on-the-way")
|
||||||
|
async def pm_incoming_transfers(
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if ctx.role == "pm" and ctx.project_id is None:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید")
|
||||||
|
|
||||||
|
transfers = await project_transfer_service.list_project_transfers(
|
||||||
|
db=db,
|
||||||
|
project_id=ctx.project_id,
|
||||||
|
direction="incoming",
|
||||||
|
status="in_transit",
|
||||||
|
)
|
||||||
|
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/inventory/project-transfers/{transfer_id}/receive")
|
||||||
|
async def receive_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferReceive,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.receive_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
received_by=ctx.display_name,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
specs: dict | None = None
|
specs: dict | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
delivered_to: str | None = None
|
||||||
transaction_date: datetime | None = None
|
transaction_date: datetime | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -135,6 +135,11 @@ class AcceptTermsheetIn(APIModel):
|
|||||||
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAllocationIn(APIModel):
|
||||||
|
project_id: UUID
|
||||||
|
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
|
||||||
|
|
||||||
|
|
||||||
class ItemDecisionIn(APIModel):
|
class ItemDecisionIn(APIModel):
|
||||||
line_item_id: UUID
|
line_item_id: UUID
|
||||||
status: Literal["chosen", "rejected"]
|
status: Literal["chosen", "rejected"]
|
||||||
@@ -142,6 +147,7 @@ class ItemDecisionIn(APIModel):
|
|||||||
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
||||||
adjusted_notes: str | None = None
|
adjusted_notes: str | None = None
|
||||||
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
||||||
|
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
|
||||||
|
|
||||||
|
|
||||||
class ItemProjectsIn(APIModel):
|
class ItemProjectsIn(APIModel):
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.schemas.common import APIModel
|
||||||
|
|
||||||
|
|
||||||
|
class WarehouseAllocationIn(APIModel):
|
||||||
|
warehouse_id: UUID
|
||||||
|
quantity: Decimal = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferCreate(APIModel):
|
||||||
|
from_warehouse_id: UUID
|
||||||
|
to_project_id: UUID
|
||||||
|
material_id: UUID
|
||||||
|
brand_id: UUID | None = None
|
||||||
|
unit_id: int
|
||||||
|
quantity: Decimal = Field(gt=0)
|
||||||
|
specs: dict = Field(default_factory=dict)
|
||||||
|
note: str | None = None
|
||||||
|
transfer_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferUpdate(APIModel):
|
||||||
|
to_project_id: UUID | None = None
|
||||||
|
quantity: Decimal | None = Field(default=None, gt=0)
|
||||||
|
note: str | None = None
|
||||||
|
transfer_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferReceive(APIModel):
|
||||||
|
allocations: list[WarehouseAllocationIn] = Field(
|
||||||
|
min_length=1,
|
||||||
|
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
|
||||||
|
)
|
||||||
|
received_at: datetime | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdNameOut(APIModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UnitBriefOut(APIModel):
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
name_fa: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTransferOut(APIModel):
|
||||||
|
id: UUID
|
||||||
|
from_project: IdNameOut
|
||||||
|
from_warehouse: IdNameOut
|
||||||
|
to_project: IdNameOut
|
||||||
|
to_warehouse: IdNameOut | None = None
|
||||||
|
material: IdNameOut
|
||||||
|
brand: IdNameOut | None = None
|
||||||
|
unit: UnitBriefOut
|
||||||
|
quantity: Decimal
|
||||||
|
specs: dict
|
||||||
|
specs_hash: str
|
||||||
|
note: str | None = None
|
||||||
|
status: str
|
||||||
|
transfer_date: datetime
|
||||||
|
transferred_by: str | None = None
|
||||||
|
received_at: datetime | None = None
|
||||||
|
received_by: str | None = None
|
||||||
|
receipt_allocations: list[dict] | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
@@ -290,6 +290,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
@@ -319,6 +320,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=-quantity,
|
quantity=-quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.catalog import Brand, Material, Unit
|
||||||
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
|
from app.models.procurement import Project
|
||||||
|
from app.models.project_transfer import ProjectTransfer
|
||||||
|
from app.schemas.project_transfers import (
|
||||||
|
ProjectTransferCreate,
|
||||||
|
ProjectTransferReceive,
|
||||||
|
ProjectTransferUpdate,
|
||||||
|
)
|
||||||
|
from app.services.inventory_service import (
|
||||||
|
_find_inventory,
|
||||||
|
_get_or_create_inventory,
|
||||||
|
canonical_specs_hash,
|
||||||
|
)
|
||||||
|
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectTransfer)
|
||||||
|
.where(ProjectTransfer.id == transfer_id)
|
||||||
|
.options(
|
||||||
|
selectinload(ProjectTransfer.from_project),
|
||||||
|
selectinload(ProjectTransfer.to_project),
|
||||||
|
selectinload(ProjectTransfer.from_warehouse),
|
||||||
|
selectinload(ProjectTransfer.to_warehouse),
|
||||||
|
selectinload(ProjectTransfer.material),
|
||||||
|
selectinload(ProjectTransfer.brand),
|
||||||
|
selectinload(ProjectTransfer.unit),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
transfer = await db.scalar(stmt)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
|
||||||
|
return transfer
|
||||||
|
|
||||||
|
|
||||||
|
async def create_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
payload: ProjectTransferCreate,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
transferred_by: str | None = None,
|
||||||
|
) -> ProjectTransfer:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
# Validate source warehouse
|
||||||
|
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
|
||||||
|
if from_wh is None:
|
||||||
|
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
|
||||||
|
|
||||||
|
# RBAC: PM can only send from their own project
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if from_wh.project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
|
||||||
|
|
||||||
|
from_proj = await db.get(Project, from_wh.project_id)
|
||||||
|
to_proj = await db.get(Project, payload.to_project_id)
|
||||||
|
if to_proj is None:
|
||||||
|
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||||
|
|
||||||
|
if from_wh.project_id == payload.to_project_id:
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه مقصد نمیتواند همان پروژه مبدأ باشد (از انتقال بینانباری استفاده کنید)",
|
||||||
|
field="to_project_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate material, brand, unit
|
||||||
|
mat = await db.get(Material, payload.material_id)
|
||||||
|
if mat is None:
|
||||||
|
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
|
||||||
|
|
||||||
|
if payload.brand_id is not None:
|
||||||
|
brand = await db.get(Brand, payload.brand_id)
|
||||||
|
if brand is None:
|
||||||
|
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||||
|
|
||||||
|
unit = await db.get(Unit, payload.unit_id)
|
||||||
|
if unit is None:
|
||||||
|
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||||
|
|
||||||
|
# Requirement 2: Canonical specs hash from inventory_service
|
||||||
|
specs = payload.specs or {}
|
||||||
|
specs_hash = canonical_specs_hash(specs)
|
||||||
|
|
||||||
|
# Check stock in source warehouse with SELECT ... FOR UPDATE
|
||||||
|
source_item = await _find_inventory(
|
||||||
|
db,
|
||||||
|
payload.from_warehouse_id,
|
||||||
|
payload.material_id,
|
||||||
|
payload.brand_id,
|
||||||
|
specs_hash,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
|
||||||
|
if source_item is None:
|
||||||
|
candidates_res = await db.execute(
|
||||||
|
select(InventoryItem)
|
||||||
|
.where(
|
||||||
|
InventoryItem.warehouse_id == payload.from_warehouse_id,
|
||||||
|
InventoryItem.material_id == payload.material_id,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
candidates = candidates_res.scalars().all()
|
||||||
|
|
||||||
|
if len(candidates) == 1:
|
||||||
|
source_item = candidates[0]
|
||||||
|
elif len(candidates) > 1:
|
||||||
|
best_cand = None
|
||||||
|
best_score = -1
|
||||||
|
p_keys = set(specs.keys())
|
||||||
|
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
|
||||||
|
|
||||||
|
for cand in candidates:
|
||||||
|
score = 0
|
||||||
|
if cand.brand_id == payload.brand_id:
|
||||||
|
score += 10
|
||||||
|
elif cand.brand_id is None and payload.brand_id is None:
|
||||||
|
score += 10
|
||||||
|
elif cand.brand_id is None:
|
||||||
|
score += 4
|
||||||
|
|
||||||
|
c_specs = cand.specs or {}
|
||||||
|
c_keys = set(c_specs.keys())
|
||||||
|
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
|
||||||
|
|
||||||
|
for pk in p_keys:
|
||||||
|
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
|
||||||
|
score += 5
|
||||||
|
for pv in p_vals:
|
||||||
|
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
|
||||||
|
score += 5
|
||||||
|
|
||||||
|
if cand.quantity >= payload.quantity:
|
||||||
|
score += 2
|
||||||
|
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_cand = cand
|
||||||
|
|
||||||
|
source_item = best_cand
|
||||||
|
|
||||||
|
if source_item is None or source_item.quantity < payload.quantity:
|
||||||
|
avail = source_item.quantity if source_item else Decimal("0")
|
||||||
|
raise ValidationError(
|
||||||
|
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
|
||||||
|
field="quantity",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct from source warehouse immediately
|
||||||
|
source_item.quantity -= payload.quantity
|
||||||
|
|
||||||
|
transfer_pair_id = uuid.uuid4()
|
||||||
|
now_dt = payload.transfer_date or datetime.now(timezone.utc)
|
||||||
|
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
|
||||||
|
if payload.note:
|
||||||
|
note_text += f" — {payload.note}"
|
||||||
|
|
||||||
|
out_tx = InventoryTransaction(
|
||||||
|
inventory_id=source_item.id,
|
||||||
|
warehouse_id=from_wh.id,
|
||||||
|
transaction_type="TRANSFER_OUT",
|
||||||
|
quantity=-payload.quantity,
|
||||||
|
transfer_pair_id=transfer_pair_id,
|
||||||
|
performed_by=transferred_by,
|
||||||
|
transaction_date=now_dt,
|
||||||
|
note=note_text,
|
||||||
|
)
|
||||||
|
db.add(out_tx)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
actual_brand_id = source_item.brand_id
|
||||||
|
actual_specs = source_item.specs if source_item.specs is not None else specs
|
||||||
|
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
|
||||||
|
|
||||||
|
transfer = ProjectTransfer(
|
||||||
|
from_project_id=from_wh.project_id,
|
||||||
|
from_warehouse_id=from_wh.id,
|
||||||
|
to_project_id=payload.to_project_id,
|
||||||
|
material_id=payload.material_id,
|
||||||
|
brand_id=actual_brand_id,
|
||||||
|
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
|
||||||
|
quantity=payload.quantity,
|
||||||
|
specs=actual_specs,
|
||||||
|
specs_hash=actual_specs_hash,
|
||||||
|
note=payload.note,
|
||||||
|
status="in_transit",
|
||||||
|
out_transaction_id=out_tx.id,
|
||||||
|
transfer_date=now_dt,
|
||||||
|
transferred_by=transferred_by,
|
||||||
|
)
|
||||||
|
db.add(transfer)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await get_project_transfer_or_404(db, transfer.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferUpdate,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
) -> ProjectTransfer:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
transfer = await db.scalar(
|
||||||
|
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||||
|
)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||||
|
|
||||||
|
# RBAC (Requirement 4)
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if transfer.from_project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به ویرایش انتقالهای پروژه مبدأ خود هستید")
|
||||||
|
|
||||||
|
# Status Lock Guard (Requirement 4)
|
||||||
|
if transfer.status == "received":
|
||||||
|
raise ConflictError(
|
||||||
|
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
|
||||||
|
)
|
||||||
|
if transfer.status != "in_transit":
|
||||||
|
raise ConflictError("فقط انتقالهای در راه (in_transit) قابل ویرایش هستند")
|
||||||
|
|
||||||
|
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
|
||||||
|
new_to_proj = await db.get(Project, payload.to_project_id)
|
||||||
|
if new_to_proj is None:
|
||||||
|
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||||
|
if payload.to_project_id == transfer.from_project_id:
|
||||||
|
raise ValidationError("پروژه مقصد نمیتواند همان پروژه مبدأ باشد", field="to_project_id")
|
||||||
|
transfer.to_project_id = payload.to_project_id
|
||||||
|
|
||||||
|
# Quantity adjustment (reconciles source stock difference)
|
||||||
|
if payload.quantity is not None and payload.quantity != transfer.quantity:
|
||||||
|
delta = payload.quantity - transfer.quantity
|
||||||
|
source_item = await _find_inventory(
|
||||||
|
db,
|
||||||
|
transfer.from_warehouse_id,
|
||||||
|
transfer.material_id,
|
||||||
|
transfer.brand_id,
|
||||||
|
transfer.specs_hash,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if delta > 0:
|
||||||
|
if source_item is None or source_item.quantity < delta:
|
||||||
|
avail = source_item.quantity if source_item else Decimal("0")
|
||||||
|
raise ValidationError(
|
||||||
|
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
|
||||||
|
field="quantity",
|
||||||
|
)
|
||||||
|
source_item.quantity -= delta
|
||||||
|
else:
|
||||||
|
if source_item is not None:
|
||||||
|
source_item.quantity += abs(delta)
|
||||||
|
|
||||||
|
transfer.quantity = payload.quantity
|
||||||
|
|
||||||
|
# Update out_transaction
|
||||||
|
if transfer.out_transaction_id is not None:
|
||||||
|
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if out_tx is not None:
|
||||||
|
out_tx.quantity = -payload.quantity
|
||||||
|
|
||||||
|
if payload.note is not None:
|
||||||
|
transfer.note = payload.note
|
||||||
|
|
||||||
|
if payload.transfer_date is not None:
|
||||||
|
transfer.transfer_date = payload.transfer_date
|
||||||
|
if transfer.out_transaction_id is not None:
|
||||||
|
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if out_tx is not None:
|
||||||
|
out_tx.transaction_date = payload.transfer_date
|
||||||
|
|
||||||
|
return await get_project_transfer_or_404(db, transfer_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
) -> dict:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
transfer = await db.scalar(
|
||||||
|
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||||
|
)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||||
|
|
||||||
|
# RBAC (Requirement 4)
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if transfer.from_project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به لغو انتقالهای پروژه مبدأ خود هستید")
|
||||||
|
|
||||||
|
# Status Lock Guard (Requirement 4)
|
||||||
|
if transfer.status == "received":
|
||||||
|
raise ConflictError(
|
||||||
|
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reversibility (Requirement 4): Restore stock to source warehouse
|
||||||
|
source_item = await _find_inventory(
|
||||||
|
db,
|
||||||
|
transfer.from_warehouse_id,
|
||||||
|
transfer.material_id,
|
||||||
|
transfer.brand_id,
|
||||||
|
transfer.specs_hash,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if source_item is not None:
|
||||||
|
source_item.quantity += transfer.quantity
|
||||||
|
|
||||||
|
# Delete out_transaction
|
||||||
|
if transfer.out_transaction_id is not None:
|
||||||
|
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if out_tx is not None:
|
||||||
|
await db.delete(out_tx)
|
||||||
|
|
||||||
|
await db.delete(transfer)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
|
||||||
|
|
||||||
|
|
||||||
|
async def receive_project_transfer(
|
||||||
|
db: AsyncSession,
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferReceive,
|
||||||
|
caller_project_id: uuid.UUID | None,
|
||||||
|
caller_role: str,
|
||||||
|
received_by: str | None = None,
|
||||||
|
) -> ProjectTransfer:
|
||||||
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
|
transfer = await db.scalar(
|
||||||
|
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||||
|
)
|
||||||
|
if transfer is None:
|
||||||
|
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||||
|
|
||||||
|
# RBAC (Requirement 4)
|
||||||
|
if caller_role == "pm" and caller_project_id is not None:
|
||||||
|
if transfer.to_project_id != caller_project_id:
|
||||||
|
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
|
||||||
|
|
||||||
|
# Lock Guard
|
||||||
|
if transfer.status == "received":
|
||||||
|
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
|
||||||
|
if transfer.status != "in_transit":
|
||||||
|
raise ConflictError("این انتقال در وضعیت در راه نیست")
|
||||||
|
|
||||||
|
# Requirement 1: 100% full delivery logic (no partial receipt)
|
||||||
|
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
|
||||||
|
if alloc_sum != transfer.quantity:
|
||||||
|
raise ValidationError(
|
||||||
|
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
|
||||||
|
field="allocations",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate destination warehouses belong to destination project
|
||||||
|
wh_ids = [a.warehouse_id for a in payload.allocations]
|
||||||
|
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
|
||||||
|
wh_map = {w.id: w for w in wh_rows}
|
||||||
|
|
||||||
|
for a in payload.allocations:
|
||||||
|
wh = wh_map.get(a.warehouse_id)
|
||||||
|
if wh is None or wh.project_id != transfer.to_project_id:
|
||||||
|
raise ValidationError(
|
||||||
|
"انبار انتخابشده معتبر نیست یا متعلق به پروژه مقصد نمیباشد",
|
||||||
|
field="allocations.warehouse_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
from_proj = await db.get(Project, transfer.from_project_id)
|
||||||
|
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
|
||||||
|
|
||||||
|
out_tx = (
|
||||||
|
await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||||
|
if transfer.out_transaction_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
|
||||||
|
now_dt = payload.received_at or datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Add stock to destination warehouse(s) via TRANSFER_IN
|
||||||
|
for a in payload.allocations:
|
||||||
|
dest_item = await _get_or_create_inventory(
|
||||||
|
db,
|
||||||
|
a.warehouse_id,
|
||||||
|
transfer.material_id,
|
||||||
|
transfer.brand_id,
|
||||||
|
transfer.unit_id,
|
||||||
|
transfer.specs,
|
||||||
|
transfer.specs_hash,
|
||||||
|
)
|
||||||
|
dest_item.quantity += a.quantity
|
||||||
|
|
||||||
|
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
|
||||||
|
if payload.note:
|
||||||
|
note_text += f" — {payload.note}"
|
||||||
|
|
||||||
|
in_tx = InventoryTransaction(
|
||||||
|
inventory_id=dest_item.id,
|
||||||
|
warehouse_id=a.warehouse_id,
|
||||||
|
transaction_type="TRANSFER_IN",
|
||||||
|
quantity=a.quantity,
|
||||||
|
transfer_pair_id=pair_id,
|
||||||
|
performed_by=received_by,
|
||||||
|
transaction_date=now_dt,
|
||||||
|
note=note_text,
|
||||||
|
)
|
||||||
|
db.add(in_tx)
|
||||||
|
|
||||||
|
transfer.status = "received"
|
||||||
|
transfer.received_at = now_dt
|
||||||
|
transfer.received_by = received_by
|
||||||
|
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
|
||||||
|
transfer.receipt_allocations = [
|
||||||
|
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
|
||||||
|
for a in payload.allocations
|
||||||
|
]
|
||||||
|
|
||||||
|
return await get_project_transfer_or_404(db, transfer_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_project_transfers(
|
||||||
|
db: AsyncSession,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
|
||||||
|
status: str | None = None,
|
||||||
|
) -> list[ProjectTransfer]:
|
||||||
|
stmt = (
|
||||||
|
select(ProjectTransfer)
|
||||||
|
.options(
|
||||||
|
selectinload(ProjectTransfer.from_project),
|
||||||
|
selectinload(ProjectTransfer.to_project),
|
||||||
|
selectinload(ProjectTransfer.from_warehouse),
|
||||||
|
selectinload(ProjectTransfer.to_warehouse),
|
||||||
|
selectinload(ProjectTransfer.material),
|
||||||
|
selectinload(ProjectTransfer.brand),
|
||||||
|
selectinload(ProjectTransfer.unit),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if project_id is not None:
|
||||||
|
if direction == "outgoing":
|
||||||
|
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
|
||||||
|
elif direction == "incoming":
|
||||||
|
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(
|
||||||
|
(ProjectTransfer.from_project_id == project_id)
|
||||||
|
| (ProjectTransfer.to_project_id == project_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(ProjectTransfer.status == status)
|
||||||
|
|
||||||
|
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
|
||||||
|
res = await db.execute(stmt)
|
||||||
|
return list(res.scalars().all())
|
||||||
@@ -7,6 +7,7 @@ same transaction.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
@@ -18,6 +19,7 @@ from app.models.procurement import (
|
|||||||
ItemTermsheet,
|
ItemTermsheet,
|
||||||
LineItem,
|
LineItem,
|
||||||
LineItemProjects,
|
LineItemProjects,
|
||||||
|
LineItemSpec,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
QuoteEvent,
|
QuoteEvent,
|
||||||
@@ -418,6 +420,78 @@ async def replace_quote_children(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
|
||||||
|
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
|
||||||
|
exists = await db.scalar(
|
||||||
|
select(QuoteProjects).where(
|
||||||
|
QuoteProjects.quote_id == quote_id,
|
||||||
|
QuoteProjects.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
|
||||||
|
"""Consolidate split line items when reverting a quote back to offered.
|
||||||
|
|
||||||
|
Line items sharing the exact same material, brand_material, unit, notes,
|
||||||
|
and identical specs are merged into a single consolidated row whose quantity
|
||||||
|
is the sum of all split rows. Secondary split rows are safely deleted.
|
||||||
|
"""
|
||||||
|
line_items = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if len(line_items) <= 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load all specs for these line items to compare canonical signatures
|
||||||
|
specs_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
specs_by_li: dict[UUID, list[tuple]] = {}
|
||||||
|
for sp in specs_rows:
|
||||||
|
sig = (
|
||||||
|
str(sp.attribute_definition_id),
|
||||||
|
str(sp.value_number) if sp.value_number is not None else "",
|
||||||
|
(sp.value_string or "").strip(),
|
||||||
|
str(sp.value_boolean) if sp.value_boolean is not None else "",
|
||||||
|
str(sp.value_date) if sp.value_date is not None else "",
|
||||||
|
)
|
||||||
|
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
|
||||||
|
|
||||||
|
# Group line items by identity
|
||||||
|
groups: dict[tuple, list[LineItem]] = {}
|
||||||
|
for li in line_items:
|
||||||
|
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
|
||||||
|
key = (
|
||||||
|
li.material_id,
|
||||||
|
li.brand_material_id,
|
||||||
|
li.unit_id,
|
||||||
|
(li.notes or "").strip(),
|
||||||
|
specs_sig,
|
||||||
|
)
|
||||||
|
groups.setdefault(key, []).append(li)
|
||||||
|
|
||||||
|
for key, items in groups.items():
|
||||||
|
if len(items) > 1:
|
||||||
|
primary = items[0]
|
||||||
|
total_qty = sum(Decimal(str(it.quantity)) for it in items)
|
||||||
|
primary.quantity = total_qty
|
||||||
|
|
||||||
|
secondary_ids = [it.id for it in items[1:]]
|
||||||
|
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||||
current = quote.status
|
current = quote.status
|
||||||
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
)
|
)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
|
# Requirement 2: Merge split line items back into single rows
|
||||||
|
await _merge_split_line_items_on_reopen(db, quote.id)
|
||||||
# LineItemProjects (candidate projects) are preserved untouched!
|
# LineItemProjects (candidate projects) are preserved untouched!
|
||||||
elif new_status == "accepted": # arrived -> accepted
|
elif new_status == "accepted": # arrived -> accepted
|
||||||
quote.arrived_date = None
|
quote.arrived_date = None
|
||||||
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
||||||
)
|
)
|
||||||
termsheet_id = ts_ids_by_index[index]
|
termsheet_id = ts_ids_by_index[index]
|
||||||
|
|
||||||
|
li_row = await db.scalar(
|
||||||
|
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
|
||||||
|
)
|
||||||
|
if li_row is None:
|
||||||
|
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
|
||||||
|
|
||||||
|
allocations = decision_in.allocations or []
|
||||||
|
if allocations:
|
||||||
|
# Requirement 3: Validate no duplicate projects within allocations of this item
|
||||||
|
alloc_pids = [a.project_id for a in allocations]
|
||||||
|
if len(alloc_pids) != len(set(alloc_pids)):
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه تکراری برای یک قلم کالا در ردیفهای تخصیص انتخاب شده است",
|
||||||
|
field="item_decisions.allocations",
|
||||||
|
)
|
||||||
|
# Requirement 5: Decimal precision check for sum of allocation quantities
|
||||||
|
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
|
||||||
|
target_qty = Decimal(str(li_row.quantity))
|
||||||
|
if alloc_sum != target_qty:
|
||||||
|
raise ValidationError(
|
||||||
|
f"مجموع مقادیر تخصیصیافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
|
||||||
|
field="item_decisions.allocations",
|
||||||
|
)
|
||||||
|
# Validate all projects exist
|
||||||
|
for a in allocations:
|
||||||
|
if await db.get(Project, a.project_id) is None:
|
||||||
|
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
|
||||||
|
|
||||||
|
if len(allocations) >= 2:
|
||||||
|
# 1. Update the original line item to the first allocation quantity
|
||||||
|
first_alloc = allocations[0]
|
||||||
|
li_row.quantity = first_alloc.quantity
|
||||||
|
decision_in.project_id = first_alloc.project_id
|
||||||
|
|
||||||
|
# Query all existing item_termsheets and line_item_specs for copying
|
||||||
|
orig_termsheets = (
|
||||||
|
await db.execute(
|
||||||
|
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
orig_specs = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
|
||||||
|
for split_alloc in allocations[1:]:
|
||||||
|
new_li = LineItem(
|
||||||
|
quote_id=quote.id,
|
||||||
|
material_id=li_row.material_id,
|
||||||
|
brand_material_id=li_row.brand_material_id,
|
||||||
|
unit_id=li_row.unit_id,
|
||||||
|
quantity=split_alloc.quantity,
|
||||||
|
notes=li_row.notes,
|
||||||
|
)
|
||||||
|
db.add(new_li)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Copy LineItemSpec
|
||||||
|
for sp in orig_specs:
|
||||||
|
db.add(
|
||||||
|
LineItemSpec(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
attribute_definition_id=sp.attribute_definition_id,
|
||||||
|
value_number=sp.value_number,
|
||||||
|
value_string=sp.value_string,
|
||||||
|
value_boolean=sp.value_boolean,
|
||||||
|
value_date=sp.value_date,
|
||||||
|
unit_id_at_time=sp.unit_id_at_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Requirement 1: Copy ALL payment options (ItemTermsheet)
|
||||||
|
chosen_new_it: ItemTermsheet | None = None
|
||||||
|
for it_orig in orig_termsheets:
|
||||||
|
price = (
|
||||||
|
decision_in.adjusted_unit_price
|
||||||
|
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
|
||||||
|
else it_orig.unit_price
|
||||||
|
)
|
||||||
|
copied_it = ItemTermsheet(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
termsheet_id=it_orig.termsheet_id,
|
||||||
|
unit_price=price,
|
||||||
|
min_order_qty=it_orig.min_order_qty,
|
||||||
|
)
|
||||||
|
db.add(copied_it)
|
||||||
|
if it_orig.termsheet_id == termsheet_id:
|
||||||
|
chosen_new_it = copied_it
|
||||||
|
|
||||||
|
if chosen_new_it is None:
|
||||||
|
chosen_new_it = ItemTermsheet(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
termsheet_id=termsheet_id,
|
||||||
|
unit_price=decision_in.adjusted_unit_price,
|
||||||
|
)
|
||||||
|
db.add(chosen_new_it)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create ItemDecision for the split line item
|
||||||
|
new_decision = ItemDecision(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
status="chosen",
|
||||||
|
chosen_item_termsheet_id=chosen_new_it.id,
|
||||||
|
adjusted_unit_price=decision_in.adjusted_unit_price,
|
||||||
|
adjusted_notes=decision_in.adjusted_notes,
|
||||||
|
project_id=split_alloc.project_id,
|
||||||
|
decided_at=utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_decision)
|
||||||
|
|
||||||
|
# Requirement 4: sync QuoteProjects & LineItemProjects
|
||||||
|
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
|
||||||
|
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
|
||||||
|
elif len(allocations) == 1:
|
||||||
|
decision_in.project_id = allocations[0].project_id
|
||||||
|
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(
|
select(ItemTermsheet).where(
|
||||||
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
||||||
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
if decision.project_id is not None:
|
if decision.project_id is not None:
|
||||||
if await db.get(Project, decision.project_id) is None:
|
if await db.get(Project, decision.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
||||||
|
# Requirement 4: sync QuoteProjects
|
||||||
|
await _ensure_quote_project(db, quote.id, decision.project_id)
|
||||||
chosen_ts_ids.add(termsheet_id)
|
chosen_ts_ids.add(termsheet_id)
|
||||||
else: # rejected
|
else: # rejected
|
||||||
if decision is None:
|
if decision is None:
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
|
|||||||
|
|
||||||
USER_PLAN = [
|
USER_PLAN = [
|
||||||
# (casdoor username, first, last, role)
|
# (casdoor username, first, last, role)
|
||||||
|
("ali.mohaghegh", "علی", "محقق", "admin"),
|
||||||
("razaei", "علی", "رضایی", "admin"),
|
("razaei", "علی", "رضایی", "admin"),
|
||||||
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
||||||
("mohaghegh", "محمد", "محقق", "pm"),
|
("mohaghegh", "محمد", "محقق", "pm"),
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||||
|
|
||||||
|
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||||
|
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||||
|
|
||||||
|
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||||
|
|
||||||
|
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||||
|
(`backend/app/routers/inventory.py` — list_transactions)
|
||||||
|
|
||||||
|
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||||
|
|
||||||
|
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||||
|
|
||||||
|
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||||
|
|
||||||
|
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||||
|
|
||||||
|
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||||
|
|
||||||
|
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||||
|
|
||||||
|
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||||
|
|
||||||
|
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||||
|
|
||||||
|
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||||
|
|
||||||
|
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||||
|
|
||||||
|
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||||
|
|
||||||
|
## ۵. 🟡 Audit Trail ناقص است
|
||||||
|
|
||||||
|
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||||
|
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||||
|
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||||
|
- CRUD انبارها و تغییرات کاربران
|
||||||
|
|
||||||
|
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||||
|
|
||||||
|
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||||
|
|
||||||
|
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||||
|
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||||
|
|
||||||
|
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||||
|
|
||||||
|
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||||
|
|
||||||
|
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||||
|
|
||||||
|
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||||
|
|
||||||
|
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||||
|
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||||
|
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||||
|
|
||||||
|
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||||
|
|
||||||
|
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||||
|
|
||||||
|
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||||
|
|
||||||
|
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||||
|
|
||||||
|
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||||
|
|
||||||
|
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||||
|
|
||||||
|
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||||
|
|
||||||
|
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||||
|
|
||||||
|
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چیزهایی که بررسی شد و درست است ✅
|
||||||
|
|
||||||
|
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||||
|
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||||
|
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||||
|
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||||
|
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||||
|
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||||
+3
-15
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { useAuthStore } from "./store/auth";
|
import { useAuthStore } from "./store/auth";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { RequirePmProjects } from "./components/RequirePmProjects";
|
import { RequirePmProjects } from "./components/RequirePmProjects";
|
||||||
@@ -52,22 +52,10 @@ 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();
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
|
||||||
// در صورت داشتن شناسه پیشنویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
|
|
||||||
const searchParams = new URLSearchParams(location.search);
|
|
||||||
const hasDraft = searchParams.has("draft") || searchParams.has("draft_id");
|
|
||||||
if (location.pathname === "/wizard" && hasDraft) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
|
||||||
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
|
||||||
}
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||||
import { normalize } from "../lib/normalize";
|
import { normalize } from "../lib/normalize";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
@@ -17,6 +17,7 @@ export interface ComboboxProps {
|
|||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,6 +26,8 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
openOnFocus?: boolean;
|
openOnFocus?: boolean;
|
||||||
@@ -44,6 +47,7 @@ export function Combobox({
|
|||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
@@ -52,6 +56,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
inputRef,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
@@ -125,6 +130,7 @@ export function Combobox({
|
|||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
@@ -182,9 +188,15 @@ export function Combobox({
|
|||||||
onMouseEnter={() => setHighlight(filtered.length)}
|
onMouseEnter={() => setHighlight(filtered.length)}
|
||||||
>
|
>
|
||||||
<span className="font-bold">+</span>
|
<span className="font-bold">+</span>
|
||||||
<span>
|
{addPrefix ? (
|
||||||
«<b>{text.trim()}</b>» {addSuffix}
|
<span>
|
||||||
</span>
|
{addPrefix} «<b>{text.trim()}</b>»
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
«<b>{text.trim()}</b>» {addSuffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
|
|||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 w-full my-2">
|
<div className="flex items-start gap-2 w-full my-2">
|
||||||
{/* کادر انتخاب تاریخ */}
|
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||||
<div className="w-36 shrink-0">
|
<div className="w-28 shrink-0">
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={milestone.when_iso}
|
value={milestone.when_iso}
|
||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export interface NumberInputProps {
|
|||||||
onChange: (n: number | null) => void;
|
onChange: (n: number | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیشفرض: راستچین)
|
||||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||||
inputMode?: "numeric" | "decimal" | undefined;
|
inputMode?: "numeric" | "decimal" | undefined;
|
||||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
maxDecimals?: number; // حداکثر رقم اعشار
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export function NumberInput({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
|
inputClassName,
|
||||||
|
align = "right",
|
||||||
showWords = false,
|
showWords = false,
|
||||||
inputMode = "numeric",
|
inputMode = "numeric",
|
||||||
maxDecimals,
|
maxDecimals,
|
||||||
@@ -36,8 +40,6 @@ export function NumberInput({
|
|||||||
|
|
||||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||||
|
|
||||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
|
||||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cur = parseNum(text);
|
const cur = parseNum(text);
|
||||||
if (value != null && value !== cur) {
|
if (value != null && value !== cur) {
|
||||||
@@ -60,19 +62,28 @@ export function NumberInput({
|
|||||||
|
|
||||||
const n = parseNum(text);
|
const n = parseNum(text);
|
||||||
|
|
||||||
|
const alignClass =
|
||||||
|
align === "center"
|
||||||
|
? "!text-center text-center"
|
||||||
|
: align === "left"
|
||||||
|
? "!text-left text-left"
|
||||||
|
: "!text-right text-right";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-w-0", className)}>
|
<div className={cn("min-w-0 w-full", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
className={cn(
|
||||||
|
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
alignClass,
|
||||||
|
inputClassName,
|
||||||
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value;
|
const raw = e.target.value;
|
||||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
|
||||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
|
||||||
const parsed = parseNum(raw);
|
const parsed = parseNum(raw);
|
||||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||||
if (parsed != null && !trailingSep) {
|
if (parsed != null && !trailingSep) {
|
||||||
@@ -85,17 +96,12 @@ export function NumberInput({
|
|||||||
}}
|
}}
|
||||||
onBlur={() => commit(text)}
|
onBlur={() => commit(text)}
|
||||||
/>
|
/>
|
||||||
{showWords && (
|
{showWords && n != null && (
|
||||||
<span className={cn("num-pill", n == null && "empty")}>
|
<div className="mt-1 block text-right text-[11px] text-muted">
|
||||||
{n != null ? (
|
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
|
||||||
<>
|
<span className="text-muted">({toWords(n)})</span>
|
||||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+92
-50
@@ -30,6 +30,10 @@ import type {
|
|||||||
Supplier,
|
Supplier,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
|
ProjectTransfer,
|
||||||
|
ProjectTransferCreateIn,
|
||||||
|
ProjectTransferUpdateIn,
|
||||||
|
ProjectTransferReceiveIn,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
@@ -239,20 +243,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getData<T>(path: string): Promise<T> {
|
async function getData<T>(path: string): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path);
|
const res = await request<any>(path);
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function postData<T>(path: string, body: unknown): Promise<T> {
|
async function postData<T>(path: string, body: unknown): Promise<T> {
|
||||||
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
|
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
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<any>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
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<any>(path, { method: "DELETE" });
|
||||||
return res.data;
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
|
return res.data as T;
|
||||||
|
}
|
||||||
|
return res as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
||||||
@@ -927,13 +943,28 @@ export const api = {
|
|||||||
|
|
||||||
if (becomingAccepted) {
|
if (becomingAccepted) {
|
||||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||||
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
// پروژهی واحدِ انتخابشده یا تخصیصهای چندپروژهای برای هر قلم
|
||||||
const targetProjByLine = new Map<string, string>();
|
const targetProjByLine = new Map<string, string>();
|
||||||
|
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
|
||||||
for (const li of patch.line_items || []) {
|
for (const li of patch.line_items || []) {
|
||||||
const tp = li.target_project;
|
if (li.allocations && li.allocations.length > 0) {
|
||||||
if (tp && tp.name) {
|
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
|
||||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
for (const a of li.allocations) {
|
||||||
if (pid) targetProjByLine.set(li.id, pid);
|
const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
|
||||||
|
if (pid && a.quantity > 0) {
|
||||||
|
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvedAllocs.length) {
|
||||||
|
allocationsByLine.set(li.id, resolvedAllocs);
|
||||||
|
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tp = li.target_project;
|
||||||
|
if (tp && tp.name) {
|
||||||
|
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||||
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,11 +984,13 @@ export const api = {
|
|||||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||||
const chosen = !li.rejected && idx >= 0;
|
const chosen = !li.rejected && idx >= 0;
|
||||||
const chosenProjId = targetProjByLine.get(li.id);
|
const chosenProjId = targetProjByLine.get(li.id);
|
||||||
|
const lineAllocs = allocationsByLine.get(li.id);
|
||||||
return {
|
return {
|
||||||
line_item_id: li.id,
|
line_item_id: li.id,
|
||||||
status: chosen ? "chosen" : "rejected",
|
status: chosen ? "chosen" : "rejected",
|
||||||
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
||||||
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
||||||
|
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
|
||||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -1020,41 +1053,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}`);
|
||||||
@@ -1704,16 +1702,23 @@ export const apiV26 = {
|
|||||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||||
return data.map(rawInventory);
|
return data.map(rawInventory);
|
||||||
},
|
},
|
||||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||||
},
|
},
|
||||||
|
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||||
|
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||||
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
|
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||||
|
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||||
|
},
|
||||||
createTransaction: async (body: {
|
createTransaction: async (body: {
|
||||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||||
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
||||||
note?: string | null; transaction_date?: string | null;
|
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
|
||||||
}): Promise<InventoryTransaction[]> => {
|
}): Promise<InventoryTransaction[]> => {
|
||||||
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
||||||
},
|
},
|
||||||
@@ -1764,4 +1769,41 @@ export const apiV26 = {
|
|||||||
attributes: [],
|
attributes: [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
projectTransfers: {
|
||||||
|
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (projectId) q.set("project_id", projectId);
|
||||||
|
if (direction) q.set("direction", direction);
|
||||||
|
if (status) q.set("status", status);
|
||||||
|
const qs = q.toString() ? `?${q.toString()}` : "";
|
||||||
|
try {
|
||||||
|
const res = await getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
|
||||||
|
return Array.isArray(res) ? res : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch project transfers:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||||
|
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
|
||||||
|
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
|
||||||
|
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
|
||||||
|
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||||
|
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
|
||||||
|
try {
|
||||||
|
const res = await getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`);
|
||||||
|
return Array.isArray(res) ? res : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch on-the-way transfers:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||||
|
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface LineItem {
|
|||||||
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||||
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||||
target_project_id?: string | null;
|
target_project_id?: string | null;
|
||||||
|
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژهای در پذیرش
|
||||||
}
|
}
|
||||||
|
|
||||||
// پیشنهاد خرید (اصلیترین entity)
|
// پیشنهاد خرید (اصلیترین entity)
|
||||||
@@ -163,6 +164,7 @@ export interface InventoryTransaction {
|
|||||||
physical_qty?: number | null;
|
physical_qty?: number | null;
|
||||||
transfer_pair_id?: string | null;
|
transfer_pair_id?: string | null;
|
||||||
performed_by?: string | null;
|
performed_by?: string | null;
|
||||||
|
delivered_to?: string | null;
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
material?: { id: string; name: string } | null;
|
material?: { id: string; name: string } | null;
|
||||||
@@ -199,3 +201,52 @@ export interface ConfirmedReceipt {
|
|||||||
quality_note: string | null;
|
quality_note: string | null;
|
||||||
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransfer {
|
||||||
|
id: string;
|
||||||
|
from_project: { id: string; name: string };
|
||||||
|
from_warehouse: { id: string; name: string };
|
||||||
|
to_project: { id: string; name: string };
|
||||||
|
to_warehouse?: { id: string; name: string } | null;
|
||||||
|
material: { id: string; name: string };
|
||||||
|
brand?: { id: string; name: string } | null;
|
||||||
|
unit: { id: number; code: string; name_fa?: string | null };
|
||||||
|
quantity: number;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
specs_hash: string;
|
||||||
|
note?: string | null;
|
||||||
|
status: "in_transit" | "received" | "cancelled";
|
||||||
|
transfer_date: string;
|
||||||
|
transferred_by?: string | null;
|
||||||
|
received_at?: string | null;
|
||||||
|
received_by?: string | null;
|
||||||
|
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferCreateIn {
|
||||||
|
from_warehouse_id: string;
|
||||||
|
to_project_id: string;
|
||||||
|
material_id: string;
|
||||||
|
brand_id?: string | null;
|
||||||
|
unit_id: number;
|
||||||
|
quantity: number;
|
||||||
|
specs?: Record<string, string>;
|
||||||
|
note?: string | null;
|
||||||
|
transfer_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferUpdateIn {
|
||||||
|
to_project_id?: string;
|
||||||
|
quantity?: number;
|
||||||
|
note?: string | null;
|
||||||
|
transfer_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferReceiveIn {
|
||||||
|
allocations: { warehouse_id: string; quantity: number }[];
|
||||||
|
received_at?: string | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("");
|
||||||
@@ -85,12 +83,8 @@ export function LoginPage() {
|
|||||||
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}`);
|
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||||
if (returnUrl) {
|
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||||
navigate(decodeURIComponent(returnUrl));
|
navigate(user.role === "pm" ? "/inventory" : "/");
|
||||||
} else {
|
|
||||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
|
||||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -131,9 +125,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("/");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,12 +23,134 @@ import {
|
|||||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||||
import { FileList } from "../../components/FileList";
|
import { FileList } from "../../components/FileList";
|
||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||||
|
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
|
||||||
|
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||||||
|
const TEENS = [
|
||||||
|
"ده",
|
||||||
|
"یازده",
|
||||||
|
"دوازده",
|
||||||
|
"سیزده",
|
||||||
|
"چهارده",
|
||||||
|
"پانزده",
|
||||||
|
"شانزده",
|
||||||
|
"هفده",
|
||||||
|
"هجده",
|
||||||
|
"نوزده",
|
||||||
|
];
|
||||||
|
const TENS = [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"بیست",
|
||||||
|
"سی",
|
||||||
|
"چهل",
|
||||||
|
"پنجاه",
|
||||||
|
"شصت",
|
||||||
|
"هفتاد",
|
||||||
|
"هشتاد",
|
||||||
|
"نود",
|
||||||
|
];
|
||||||
|
const HUNDREDS = [
|
||||||
|
"",
|
||||||
|
"صد",
|
||||||
|
"دویست",
|
||||||
|
"سیصد",
|
||||||
|
"چهارصد",
|
||||||
|
"پانصد",
|
||||||
|
"ششصد",
|
||||||
|
"هفتصد",
|
||||||
|
"هشتصد",
|
||||||
|
"نهصد",
|
||||||
|
];
|
||||||
|
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
|
||||||
|
const DECIMAL_SCALES: Record<number, string> = {
|
||||||
|
1: "دهم",
|
||||||
|
2: "صدم",
|
||||||
|
3: "هزارم",
|
||||||
|
4: "دههزارم",
|
||||||
|
};
|
||||||
|
|
||||||
|
function convertThreeDigits(n: number): string {
|
||||||
|
if (n === 0) return "";
|
||||||
|
const parts: string[] = [];
|
||||||
|
const h = Math.floor(n / 100);
|
||||||
|
const rem = n % 100;
|
||||||
|
if (h > 0) parts.push(HUNDREDS[h]);
|
||||||
|
if (rem >= 10 && rem <= 19) {
|
||||||
|
parts.push(TEENS[rem - 10]);
|
||||||
|
} else {
|
||||||
|
const t = Math.floor(rem / 10);
|
||||||
|
const u = rem % 10;
|
||||||
|
if (t > 0) parts.push(TENS[t]);
|
||||||
|
if (u > 0) parts.push(ONES[u]);
|
||||||
|
}
|
||||||
|
return parts.join(" و ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEnglishDigits(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberToPersianWords(val: number | string | null | undefined): string {
|
||||||
|
if (val == null || val === "") return "";
|
||||||
|
const numStr = toEnglishDigits(String(val).trim());
|
||||||
|
const num = parseFloat(numStr);
|
||||||
|
if (isNaN(num)) return "";
|
||||||
|
if (num === 0) return "صفر";
|
||||||
|
|
||||||
|
const isNeg = num < 0;
|
||||||
|
const parts = Math.abs(num).toString().split(".");
|
||||||
|
const intStr = parts[0];
|
||||||
|
const decStr = parts[1];
|
||||||
|
|
||||||
|
let intNum = parseInt(intStr, 10);
|
||||||
|
let intResult = "";
|
||||||
|
|
||||||
|
if (intNum === 0) {
|
||||||
|
intResult = "صفر";
|
||||||
|
} else {
|
||||||
|
const chunks: number[] = [];
|
||||||
|
while (intNum > 0) {
|
||||||
|
chunks.push(intNum % 1000);
|
||||||
|
intNum = Math.floor(intNum / 1000);
|
||||||
|
}
|
||||||
|
const chunkWords: string[] = [];
|
||||||
|
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||||
|
const c = chunks[i];
|
||||||
|
if (c > 0) {
|
||||||
|
const w = convertThreeDigits(c);
|
||||||
|
const s = SCALES[i];
|
||||||
|
chunkWords.push(s ? `${w} ${s}` : w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intResult = chunkWords.join(" و ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decStr && parseInt(decStr, 10) > 0) {
|
||||||
|
const cleanDecStr = decStr.slice(0, 4);
|
||||||
|
const decNum = parseInt(cleanDecStr, 10);
|
||||||
|
const decWord = numberToPersianWords(decNum);
|
||||||
|
const scale = DECIMAL_SCALES[cleanDecStr.length];
|
||||||
|
if (scale) {
|
||||||
|
if (intNum === 0) {
|
||||||
|
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
|
||||||
|
}
|
||||||
|
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
|
||||||
|
}
|
||||||
|
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNeg ? `منفی ${intResult}` : intResult;
|
||||||
|
}
|
||||||
|
|
||||||
interface CardDraft {
|
interface CardDraft {
|
||||||
domId: string;
|
domId: string;
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
label: string | null; // null = پیشفرض «گزینه N»
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AcceptModalProps {
|
export interface AcceptModalProps {
|
||||||
@@ -51,6 +173,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||||
|
const [itemAllocations, setItemAllocations] = useState<
|
||||||
|
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
|
||||||
|
>({});
|
||||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
@@ -67,17 +192,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setRemovedItems(rem);
|
setRemovedItems(rem);
|
||||||
setAssignments({});
|
setAssignments({});
|
||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
|
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||||
projSel[li.id] = li.target_project?.name
|
const defaultProj = li.target_project?.name
|
||||||
? li.target_project.name
|
? li.target_project.name
|
||||||
: names.length
|
: names.length
|
||||||
? names[0]
|
? names[0]
|
||||||
: q.projects && q.projects.length
|
: q.projects && q.projects.length
|
||||||
? q.projects[0]
|
? q.projects[0]
|
||||||
: q.project || "";
|
: q.project || "";
|
||||||
|
projSel[li.id] = defaultProj;
|
||||||
|
if (li.allocations && li.allocations.length) {
|
||||||
|
allocs[li.id] = li.allocations.map((a, idx) => ({
|
||||||
|
rowId: `alloc-${li.id}-${idx}`,
|
||||||
|
project_name: a.project_name,
|
||||||
|
quantity: a.quantity,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
allocs[li.id] = [
|
||||||
|
{
|
||||||
|
rowId: `alloc-${li.id}-0`,
|
||||||
|
project_name: defaultProj,
|
||||||
|
quantity: li.quantity ?? 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setItemProjectsSel(projSel);
|
setItemProjectsSel(projSel);
|
||||||
|
setItemAllocations(allocs);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
@@ -137,6 +280,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
setAssignments((a) => ({ ...a, [liId]: domId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addAllocationRow = (liId: string, totalQty: number) => {
|
||||||
|
setItemAllocations((prev) => {
|
||||||
|
const currentList = prev[liId] || [];
|
||||||
|
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||||
|
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
|
||||||
|
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
|
||||||
|
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
|
||||||
|
const newRow = {
|
||||||
|
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
|
project_name: firstAvailable,
|
||||||
|
quantity: remaining > 0 ? remaining : "",
|
||||||
|
};
|
||||||
|
return { ...prev, [liId]: [...currentList, newRow] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAllocationRow = (liId: string, rowId: string) => {
|
||||||
|
setItemAllocations((prev) => {
|
||||||
|
const currentList = prev[liId] || [];
|
||||||
|
if (currentList.length <= 1) return prev;
|
||||||
|
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAllocationRow = (
|
||||||
|
liId: string,
|
||||||
|
rowId: string,
|
||||||
|
patch: Partial<{ project_name: string; quantity: number | string }>,
|
||||||
|
) => {
|
||||||
|
setItemAllocations((prev) => {
|
||||||
|
const currentList = prev[liId] || [];
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
@@ -162,6 +343,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (removedItems.has(li.id)) li.rejected = true;
|
if (removedItems.has(li.id)) li.rejected = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const active = activeItems(next);
|
const active = activeItems(next);
|
||||||
if (!active.length) {
|
if (!active.length) {
|
||||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||||
@@ -206,13 +388,60 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
for (const li of active) {
|
||||||
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
const allocs = itemAllocations[li.id] || [];
|
||||||
|
const m = materialById(materials, li.material_id);
|
||||||
|
const title = formatMaterialTitle(m);
|
||||||
|
const targetQty = Number(li.quantity ?? 0);
|
||||||
|
|
||||||
|
if (!allocs.length) {
|
||||||
|
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of allocs) {
|
||||||
|
if (!a.project_name.trim()) {
|
||||||
|
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیفهای تخصیص را مشخص کنید.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qVal = parseFloat(String(a.quantity));
|
||||||
|
if (isNaN(qVal) || qVal <= 0) {
|
||||||
|
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگتر از صفر باشد.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pNames = allocs.map((a) => a.project_name.trim());
|
||||||
|
if (new Set(pNames).size !== pNames.length) {
|
||||||
|
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||||
|
const diff = Math.abs(sumQty - targetQty);
|
||||||
|
if (diff > 0.0001) {
|
||||||
|
toast.error(
|
||||||
|
`مجموع مقادیر تخصیصیافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const allocs = itemAllocations[li.id];
|
||||||
if (name === undefined) return;
|
if (allocs && allocs.length) {
|
||||||
li.target_project = name ? { id: "", name } : null;
|
li.allocations = allocs.map((a) => ({
|
||||||
|
project_name: a.project_name.trim(),
|
||||||
|
quantity: parseFloat(String(a.quantity)) || 0,
|
||||||
|
}));
|
||||||
|
li.target_project = { id: "", name: allocs[0].project_name.trim() };
|
||||||
|
} else {
|
||||||
|
const name = itemProjectsSel[li.id];
|
||||||
|
if (name !== undefined) {
|
||||||
|
li.target_project = name ? { id: "", name } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -232,7 +461,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||||
<div className="flex flex-col max-h-[85vh] w-full">
|
<div className="flex flex-col max-h-[85vh] w-full">
|
||||||
|
|
||||||
{/* هدر ثابت و همتراز */}
|
{/* هدر ثابت */}
|
||||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||||
<DialogHeader className="p-0 text-right">
|
<DialogHeader className="p-0 text-right">
|
||||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||||
@@ -343,53 +572,196 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
{/* بخش ۳: تخصیص اقلام به پروژهها */}
|
||||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||||
|
</div>
|
||||||
<div className="mb-4 text-[12px] text-muted">
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده میشود. در صورت خرید یک کالا برای چند پروژه، میتوانید آن را به چند سطر تفکیک کنید.
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{allItems
|
{projectOptions.length === 0 ? (
|
||||||
.filter((li) => !removedItems.has(li.id))
|
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
|
||||||
.map((li) => {
|
⚠️ هیچ پروژهای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژهها، حداقل یک پروژه ایجاد کنید.
|
||||||
const m = materialById(materials, li.material_id);
|
</div>
|
||||||
return (
|
) : (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
{allItems
|
||||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
.filter((li) => !removedItems.has(li.id))
|
||||||
{formatMaterialTitle(m)}
|
.map((li) => {
|
||||||
</span>
|
const m = materialById(materials, li.material_id);
|
||||||
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
const targetQty = Number(li.quantity ?? 0);
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
const allocs = itemAllocations[li.id] || [];
|
||||||
</span>
|
const allocSum = allocs.reduce(
|
||||||
|
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
|
||||||
|
const isComplete = Math.abs(diff) < 0.0001;
|
||||||
|
const isOver = diff < -0.0001;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={li.id}
|
||||||
|
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
|
||||||
|
>
|
||||||
|
{/* هدر کارت قلم */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
<span className="text-[13.5px] font-bold text-slate-800">
|
||||||
|
{formatMaterialTitle(m)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
|
||||||
|
کل: {fmtNum(targetQty)} {li.unit}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isComplete ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
|
||||||
|
</span>
|
||||||
|
) : isOver ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
باقیمانده: {fmtNum(diff)} {li.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||||||
|
<div className="flex flex-col gap-2.5 mb-3">
|
||||||
|
{allocs.map((alloc) => {
|
||||||
|
const otherUsedProjects = new Set(
|
||||||
|
allocs
|
||||||
|
.filter((a) => a.rowId !== alloc.rowId)
|
||||||
|
.map((a) => a.project_name.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const words = numberToPersianWords(alloc.quantity);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.rowId}
|
||||||
|
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||||||
|
>
|
||||||
|
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||||||
|
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<select
|
||||||
|
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
|
||||||
|
value={alloc.project_name}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
|
project_name: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">انتخاب پروژه...</option>
|
||||||
|
{projectOptions.map((p) => {
|
||||||
|
const isUsed = otherUsedProjects.has(p);
|
||||||
|
return (
|
||||||
|
<option key={p} value={p} disabled={isUsed}>
|
||||||
|
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف موبایل */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={allocs.length <= 1}
|
||||||
|
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||||
|
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
|
||||||
|
<div className="flex flex-col w-full sm:w-44 shrink-0">
|
||||||
|
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
placeholder="مقدار"
|
||||||
|
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ MozAppearance: "textfield" }}
|
||||||
|
dir="ltr"
|
||||||
|
value={alloc.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAllocationRow(li.id, alloc.rowId, {
|
||||||
|
quantity: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
|
||||||
|
title={`واحد: ${li.unit}`}
|
||||||
|
>
|
||||||
|
{li.unit}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{words ? (
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
|
||||||
|
{words} {li.unit}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپداون و اینپوت */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={allocs.length <= 1}
|
||||||
|
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||||
|
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* فوتر کارت قلم */}
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addAllocationRow(li.id, targetQty)}
|
||||||
|
disabled={
|
||||||
|
allocs.length >= projectOptions.length && projectOptions.length > 0
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{projectOptions.length ? (
|
);
|
||||||
<select
|
})}
|
||||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
</div>
|
||||||
value={itemProjectsSel[li.id] ?? ""}
|
)}
|
||||||
onChange={(e) =>
|
|
||||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
|
||||||
<option key={i} value="">
|
|
||||||
انتخاب پروژه...
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{projectOptions.map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{n}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||||
@@ -441,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر ثابت */}
|
{/* فوتر ثابت */}
|
||||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||||
ثبت معامله بستهشده
|
ثبت معامله بستهشده
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { FileList } from "../../components/FileList";
|
import { FileList } from "../../components/FileList";
|
||||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||||
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||||
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
|
|
||||||
// دسته روی تأمینکننده است و دیگر برچسبِ این فیلد نیست)
|
|
||||||
cat: m.name || "",
|
cat: m.name || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
.list(quoteId, "invoice")
|
.list(quoteId, "invoice")
|
||||||
.then((list) => alive && setInvoices(list))
|
.then((list) => alive && setInvoices(list))
|
||||||
.catch(() => alive && setInvoices([]));
|
.catch(() => alive && setInvoices([]));
|
||||||
|
void api.files
|
||||||
|
.list(quoteId, "proforma")
|
||||||
|
.then((list) => alive && setProformas(list))
|
||||||
|
.catch(() => alive && setProformas([]));
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [quoteId]);
|
}, [quoteId]);
|
||||||
|
|
||||||
if (!q || !ready) return null;
|
const itemOptions = useMemo(() => {
|
||||||
|
return itemRows
|
||||||
|
.filter((r) => !r.rejected || r.restore)
|
||||||
|
.map((r, idx) => ({
|
||||||
|
tempId: r.key,
|
||||||
|
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
|
||||||
|
}));
|
||||||
|
}, [itemRows]);
|
||||||
|
|
||||||
const tsLabel = (card: EditTsCard, idx: number) => {
|
const getCoveredIds = (card: EditTsCard) => {
|
||||||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
const cardId = card.domId || card.key;
|
||||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
const explicit = itemRows
|
||||||
|
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
|
||||||
|
.map((r) => r.key);
|
||||||
|
|
||||||
|
if (!explicit.length && tsCards[0]?.key === card.key) {
|
||||||
|
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
|
||||||
|
}
|
||||||
|
return explicit;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
|
||||||
|
const card = tsCards.find((c) => c.key === cardKey);
|
||||||
|
const cardId = card?.domId || cardKey;
|
||||||
|
setItemRows((rows) =>
|
||||||
|
rows.map((row) => {
|
||||||
|
const shouldHave = newCoveredKeys.includes(row.key);
|
||||||
|
const exists = row.tsDomIds.includes(cardId);
|
||||||
|
if (shouldHave === exists) return row;
|
||||||
|
const nextTsDomIds = shouldHave
|
||||||
|
? [...row.tsDomIds, cardId]
|
||||||
|
: row.tsDomIds.filter((id) => id !== cardId);
|
||||||
|
return { ...row, tsDomIds: nextTsDomIds };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!q || !ready) return null;
|
||||||
|
|
||||||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addItemRow = () => {
|
const addItemRow = () => {
|
||||||
|
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
|
||||||
|
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
|
||||||
setItemRows((rows) => [
|
setItemRows((rows) => [
|
||||||
...rows,
|
...rows,
|
||||||
{
|
{
|
||||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
key: newKey,
|
||||||
cat: "",
|
cat: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
specs: [],
|
specs: [],
|
||||||
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
unit: "",
|
unit: "",
|
||||||
price: null,
|
price: null,
|
||||||
total: null,
|
total: null,
|
||||||
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
tsDomIds: defaultTsId ? [defaultTsId] : [],
|
||||||
acceptedTsId: null,
|
acceptedTsId: null,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
restore: true,
|
restore: true,
|
||||||
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const addTsCard = () => {
|
const addTsCard = () => {
|
||||||
setTsCards((cards) => [
|
setTsCards((cards) => [
|
||||||
...cards,
|
...cards,
|
||||||
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
{
|
||||||
|
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
|
||||||
|
domId: "",
|
||||||
|
milestones: [{ when_iso: null, amount: null }],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTsCard = (key: string) => {
|
const removeTsCard = (key: string) => {
|
||||||
|
if (tsCards.length <= 1) {
|
||||||
|
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
||||||
|
const card = tsCards.find((c) => c.key === key);
|
||||||
|
const cardId = card?.domId || key;
|
||||||
|
setItemRows((rows) =>
|
||||||
|
rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
|
||||||
|
})),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
next.projects = [...selectedProjects];
|
next.projects = [...selectedProjects];
|
||||||
next.notes = notes.trim();
|
next.notes = notes.trim();
|
||||||
next.invoices = invoices;
|
next.invoices = invoices;
|
||||||
|
next.proformas = proformas;
|
||||||
|
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
? domId
|
? domId
|
||||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||||
tsIdMap[domId] = realId;
|
tsIdMap[domId] = realId;
|
||||||
|
tsIdMap[card.key] = realId;
|
||||||
const orig = next.termsheets.find((t) => t.id === realId);
|
const orig = next.termsheets.find((t) => t.id === realId);
|
||||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||||
const st = orig?.status || "offered";
|
const st = orig?.status || "offered";
|
||||||
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const total = row.total;
|
const total = row.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
// هویت متریال = نام + برند + مشخصه (دیگر دستهای برای تطبیق وجود ندارد)
|
|
||||||
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material) {
|
||||||
@@ -322,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
if (!rejected) {
|
if (!rejected) {
|
||||||
row.tsDomIds.forEach((domId) => {
|
row.tsDomIds.forEach((domId) => {
|
||||||
const realId = tsIdMap[domId] || domId;
|
const realId = tsIdMap[domId] || domId;
|
||||||
if (realId) termsheet_ids.push(realId);
|
if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
|
||||||
});
|
});
|
||||||
if (!termsheet_ids.length && next.termsheets.length) {
|
if (!termsheet_ids.length && next.termsheets.length) {
|
||||||
termsheet_ids.push(next.termsheets[0].id);
|
termsheet_ids.push(next.termsheets[0].id);
|
||||||
@@ -359,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
const referencedTs = new Set<string>();
|
const referencedTs = new Set<string>();
|
||||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
@@ -384,7 +438,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
li.accepted_ts_id = null;
|
li.accepted_ts_id = null;
|
||||||
li.target_project = null;
|
li.target_project = null;
|
||||||
li.target_project_id = null;
|
li.target_project_id = null;
|
||||||
|
li.allocations = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ادغام سطرهای تفکیکشده حاصل از تخصیص چندپروژهای (الزام ۲)
|
||||||
|
const mergedMap = new Map<string, LineItem>();
|
||||||
|
for (const li of next.line_items) {
|
||||||
|
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
|
||||||
|
const existing = mergedMap.get(key);
|
||||||
|
if (existing) {
|
||||||
|
const q1 = Number(existing.quantity ?? 0);
|
||||||
|
const q2 = Number(li.quantity ?? 0);
|
||||||
|
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
|
||||||
|
if (existing.unit_price != null && existing.quantity != null) {
|
||||||
|
existing.total_price = existing.unit_price * existing.quantity;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mergedMap.set(key, { ...li });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.line_items = Array.from(mergedMap.values());
|
||||||
} else if (next.status === "accepted") {
|
} else if (next.status === "accepted") {
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
@@ -406,7 +479,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<>
|
<>
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
||||||
{/* هدر سفید و تمیز */}
|
{/* هدر */}
|
||||||
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||||
@@ -421,7 +494,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تببار سفید و استاندارد */}
|
{/* تببار */}
|
||||||
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -492,12 +565,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
{/* محتوا */}
|
||||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||||
{/* تب ۱: اقلام و مشخصات */}
|
{/* تب ۱: اقلام */}
|
||||||
{activeTab === "items" && (
|
{activeTab === "items" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* کارت تأمینکننده و پروژه */}
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
@@ -536,7 +608,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لیست اقلام */}
|
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||||
@@ -573,23 +645,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
{/* ردیف ۱: متریال و برند */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">متریال</label>
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
placeholder="نام متریال"
|
placeholder="نام متریال"
|
||||||
value={row.cat}
|
value={row.cat}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
placeholder="برند"
|
placeholder="برند"
|
||||||
value={row.brand}
|
value={row.brand}
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
@@ -597,65 +670,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2.5">
|
{/* ردیف ۲: مشخصه فنی (تمامعرض با فضای کافی برای تگها) */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
values={row.specs}
|
values={row.specs}
|
||||||
onChange={(specs) => patchItem(row.key, { specs })}
|
onChange={(specs) => patchItem(row.key, { specs })}
|
||||||
options={specOptions}
|
options={specOptions}
|
||||||
placeholder="مشخصه"
|
placeholder="انتخاب مشخصات..."
|
||||||
/>
|
className="min-w-0"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
|
||||||
<input
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
|
||||||
placeholder="واحد سنجش"
|
|
||||||
value={row.unit}
|
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جعبه ۳ عددی مقادیر */}
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
<NumberInput
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
disabled={hasReceipts}
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
|
||||||
value={row.qty}
|
<NumberInput
|
||||||
onChange={(qty) => {
|
disabled={hasReceipts}
|
||||||
patchItem(row.key, {
|
value={row.qty}
|
||||||
qty,
|
onChange={(qty) => {
|
||||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
patchItem(row.key, {
|
||||||
});
|
qty,
|
||||||
}}
|
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||||
placeholder="تعداد"
|
});
|
||||||
/>
|
}}
|
||||||
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
|
||||||
|
<input
|
||||||
|
disabled={hasReceipts}
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
|
value={row.unit}
|
||||||
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۴: قیمت هر واحد (تمامعرض با نمایش حروف) */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
|
||||||
|
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.price}
|
value={row.price}
|
||||||
onChange={(price) => {
|
onChange={(price) => {
|
||||||
patchItem(row.key, {
|
patchItem(row.key, {
|
||||||
price,
|
price,
|
||||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="واحد"
|
placeholder="قیمت هر واحد"
|
||||||
|
showWords
|
||||||
maxDecimals={2}
|
maxDecimals={2}
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
{/* ردیف ۵: قیمت کل این قلم (تمامعرض با نمایش حروف) */}
|
||||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
@@ -666,14 +749,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="جمع"
|
placeholder="قیمت کل قلم"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* شرایط پرداخت برای این قلم */}
|
{row.rejected && (
|
||||||
<div className="pt-2 border-t border-gray-100">
|
<div className="pt-2 border-t border-gray-100">
|
||||||
{row.rejected ? (
|
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -683,44 +767,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
بازگرداندن این قلم به معامله
|
بازگرداندن این قلم به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
)}
|
||||||
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{tsCards.map((card, cIdx) => {
|
|
||||||
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={card.key}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
|
||||||
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
|
||||||
hasReceipts && "cursor-not-allowed opacity-60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
disabled={hasReceipts}
|
|
||||||
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = new Set(row.tsDomIds);
|
|
||||||
const domId = card.domId || card.key;
|
|
||||||
if (e.target.checked) next.add(domId);
|
|
||||||
else next.delete(domId);
|
|
||||||
patchItem(row.key, { tsDomIds: [...next] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{tsLabel(card, cIdx)}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -741,11 +789,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* تب ۲: شرایط پرداخت */}
|
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
|
||||||
{activeTab === "terms" && (
|
{activeTab === "terms" && (
|
||||||
<div className="space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
<div>
|
||||||
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||||
|
<div className="text-[11.5px] text-gray-500 mt-0.5">
|
||||||
|
روشهای پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -762,70 +815,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, idx) => (
|
||||||
<div
|
<TermsheetCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
index={idx + 1}
|
||||||
>
|
milestones={card.milestones}
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
|
||||||
<div className="flex items-center gap-1.5">
|
items={itemOptions}
|
||||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
coveredIds={getCoveredIds(card)}
|
||||||
{faNum(idx + 1)}
|
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
|
||||||
</span>
|
onRemove={() => removeTsCard(card.key)}
|
||||||
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
|
/>
|
||||||
<span className="text-[11px] text-gray-500 font-medium">
|
|
||||||
({faNum(card.milestones.length)} مرحله)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!hasReceipts && tsCards.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
|
||||||
title="حذف گزینه"
|
|
||||||
onClick={() => removeTsCard(card.key)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[11px]">حذف</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
|
||||||
{card.milestones.map((ms, i) => (
|
|
||||||
<MilestoneRow
|
|
||||||
key={i}
|
|
||||||
milestone={ms}
|
|
||||||
disabled={hasReceipts}
|
|
||||||
onChange={(nextMs) =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onRemove={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: card.milestones.filter((_, j) => j !== i),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hasReceipts && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="line"
|
|
||||||
size="sm"
|
|
||||||
className="w-full sm:w-auto text-xs text-gray-700"
|
|
||||||
onClick={() =>
|
|
||||||
updateTs(card.key, {
|
|
||||||
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 me-1" />
|
|
||||||
افزودن مرحله پرداخت
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,11 +890,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* پیشفاکتورها — در همهی مراحل چرخهی پیشنهاد قابل افزودن/ویرایش است */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||||
|
<div className="text-xs font-bold text-gray-900">پیشفاکتورها</div>
|
||||||
|
<FileList
|
||||||
|
files={proformas}
|
||||||
|
onChange={setProformas}
|
||||||
|
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||||
|
onDelete={(a) =>
|
||||||
|
a.id &&
|
||||||
|
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
||||||
|
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
|
||||||
|
label="افزودن پیشفاکتور"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فوتر سفید، محکم و چسبان */}
|
{/* فوتر */}
|
||||||
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
||||||
{q.status === "offered" ? (
|
{q.status === "offered" ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -924,7 +942,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* مودال تایید حذف سفید */}
|
{/* مودال تایید حذف */}
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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 }: 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);
|
||||||
@@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
if (q.status === "offered") {
|
if (q.status === "offered") {
|
||||||
acceptedCell = (
|
acceptedCell = (
|
||||||
<button
|
<button
|
||||||
onClick={onAccept}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAccept();
|
||||||
|
}}
|
||||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
@@ -134,53 +136,81 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* سربرگ کارت */}
|
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند میشود */}
|
||||||
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
|
<div
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
|
||||||
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||||
{sup.name.slice(0, 1)}
|
{sup.name.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
{/* خط ۱: فقط خود اسم تأمینکننده لینک است */}
|
||||||
|
<div className="flex items-center">
|
||||||
<Link
|
<Link
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
to={`/suppliers/${q.supplier_id}`}
|
||||||
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
|
||||||
>
|
>
|
||||||
{sup.name}
|
{sup.name}
|
||||||
</Link>
|
</Link>
|
||||||
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
|
||||||
q.is_official
|
|
||||||
? "bg-green-bg text-green"
|
|
||||||
: "bg-slate-bg text-muted",
|
|
||||||
)}
|
|
||||||
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
|
||||||
>
|
|
||||||
{q.is_official ? "رسمی" : "غیررسمی"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
|
||||||
|
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||||
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||||
|
{sup.location && (
|
||||||
|
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
||||||
|
{sup.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.is_official && (
|
||||||
|
<span
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||||
|
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
|
||||||
|
<span>فاکتور رسمی</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
|
||||||
|
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||||
{fulfillmentBadge ?? (
|
{fulfillmentBadge ?? (
|
||||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||||
{STATUS_LABEL[q.status]}
|
{STATUS_LABEL[q.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* دکمه ویرایش */}
|
||||||
<button
|
<button
|
||||||
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
type="button"
|
||||||
onClick={onEdit}
|
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"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
title="ویرایش پیشنهاد"
|
title="ویرایش پیشنهاد"
|
||||||
aria-label="ویرایش"
|
aria-label="ویرایش"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3 text-muted" />
|
||||||
<span className="hidden sm:inline">ویرایش</span>
|
<span className="hidden sm:inline">ویرایش</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* دکمه فلش اکسپند در هدر */}
|
||||||
<button
|
<button
|
||||||
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
type="button"
|
||||||
onClick={onToggleExpand}
|
className={cn(
|
||||||
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
|
||||||
|
expanded && "bg-accent-bg text-accent-dark border-accent/40",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleExpand();
|
||||||
|
}}
|
||||||
|
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||||
@@ -188,9 +218,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند میکند */}
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
<div
|
||||||
{/* ۱. پروژه — تمامعرض در موبایل */}
|
onClick={onToggleExpand}
|
||||||
|
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
|
||||||
|
>
|
||||||
|
{/* ۱. پروژه */}
|
||||||
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
@@ -200,7 +233,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
{/* ۲. اقلام */}
|
||||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||||
{activeItems.length > 0 ? (
|
{activeItems.length > 0 ? (
|
||||||
@@ -221,6 +254,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<Link
|
<Link
|
||||||
key={w.unit}
|
key={w.unit}
|
||||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||||
>
|
>
|
||||||
@@ -230,6 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
|
{/* ۳. مجموع مبلغ */}
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -286,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جزئیات تکمیلی بازشونده */}
|
{/* دکمه تمامعرض توضیحات بیشتر */}
|
||||||
{expanded && <QuoteDetail store={store} q={q} />}
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
|
||||||
|
>
|
||||||
|
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
|
||||||
|
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته میشود */}
|
||||||
|
{expanded && (
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<QuoteDetail store={store} q={q} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
|
|||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
import { apiV26 } from "../../lib/api";
|
import { apiV26 } from "../../lib/api";
|
||||||
import { refreshProcurementStore } from "../../hooks";
|
import { refreshProcurementStore } from "../../hooks";
|
||||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
|
||||||
import { faNum, fmtDate } from "../../lib/formatters";
|
import { faNum, fmtDate } from "../../lib/formatters";
|
||||||
|
|
||||||
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
||||||
@@ -25,24 +25,31 @@ export function ArrivalsPage() {
|
|||||||
const isPM = user?.role === "pm";
|
const isPM = user?.role === "pm";
|
||||||
|
|
||||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||||
|
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
||||||
|
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
||||||
|
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||||
const [arr, whs] = await Promise.all([
|
const [arr, whs, incoming] = await Promise.all([
|
||||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
||||||
apiV26.pm.onTheWay(activeProject),
|
apiV26.pm.onTheWay(activeProject),
|
||||||
apiV26.warehouses.list(activeProject),
|
apiV26.warehouses.list(activeProject),
|
||||||
|
apiV26.projectTransfers.onTheWay(activeProject),
|
||||||
]);
|
]);
|
||||||
setItems(arr);
|
setItems(Array.isArray(arr) ? arr : []);
|
||||||
setWarehouses(whs);
|
setWarehouses(Array.isArray(whs) ? whs : []);
|
||||||
|
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||||
|
setItems([]);
|
||||||
|
setIncomingTransfers([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -212,6 +219,53 @@ export function ArrivalsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- transfer helpers ----------
|
||||||
|
|
||||||
|
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
|
||||||
|
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
|
||||||
|
return transferDrafts[t.id];
|
||||||
|
}
|
||||||
|
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
|
||||||
|
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
|
||||||
|
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
|
||||||
|
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||||
|
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||||
|
const targetQty = parseFloat(String(transfer.quantity));
|
||||||
|
|
||||||
|
if (allocSum !== targetQty) {
|
||||||
|
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
|
||||||
|
toast.error("لطفاً برای تمامی ردیفهای دارای مقدار، انبار مقصد را انتخاب کنید.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransferSaving(transfer.id);
|
||||||
|
try {
|
||||||
|
const payloadAllocs = allocs
|
||||||
|
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||||
|
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||||
|
|
||||||
|
await apiV26.projectTransfers.receive(transfer.id, {
|
||||||
|
allocations: payloadAllocs,
|
||||||
|
}, activeProject);
|
||||||
|
|
||||||
|
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
|
||||||
|
} finally {
|
||||||
|
setTransferSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isAdmin && !isPM) {
|
if (!isAdmin && !isPM) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -233,19 +287,171 @@ export function ArrivalsPage() {
|
|||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<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">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
🚚 این بخش به مرحله «رسید» سفارشهای خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژهها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده میشوند؛ سفارشهای خرید نیز قابلیت رسید کامل یا جزیی دارند.
|
||||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
|
||||||
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
|
||||||
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
|
||||||
ماندهی سفارش بیشتر نشود.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
||||||
) : !orders.length ? (
|
) : !(orders || []).length && !(incomingTransfers || []).length ? (
|
||||||
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
||||||
) : (
|
) : (
|
||||||
orders.map(([quoteId, its]) => {
|
<>
|
||||||
|
{/* بخش انتقالهای بین کارگاهی در راه */}
|
||||||
|
{(incomingTransfers || []).length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقالهای بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
|
||||||
|
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژهها</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(incomingTransfers || []).map((t) => {
|
||||||
|
const draftAllocs = transferDraftOf(t);
|
||||||
|
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||||
|
const targetQty = parseFloat(String(t.quantity));
|
||||||
|
const isExactMatch = allocSum === targetQty;
|
||||||
|
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
|
||||||
|
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
|
||||||
|
const days = daysElapsed(t.transfer_date);
|
||||||
|
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
|
||||||
|
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||||
|
{/* سربرگ انتقال */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
|
||||||
|
<Badge variant="offered">در راه</Badge>
|
||||||
|
{days !== null && (
|
||||||
|
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||||
|
روز گذشته: {faNum(days)} روز
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
|
||||||
|
<span>ارسالکننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
|
||||||
|
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
|
||||||
|
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[13px] font-bold">{t.material?.name}</span>
|
||||||
|
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
|
||||||
|
<Badge variant="outline">
|
||||||
|
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
|
||||||
|
</Badge>
|
||||||
|
{t.specs && Object.keys(t.specs).length > 0 && (
|
||||||
|
<span className="text-[11.5px] text-muted">
|
||||||
|
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t.note && (
|
||||||
|
<div className="mt-1 text-[11.5px] text-muted">
|
||||||
|
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
|
||||||
|
<div className="text-[12px] font-semibold text-muted mb-2">
|
||||||
|
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{draftAllocs.map((a) => (
|
||||||
|
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
|
placeholder="تعداد"
|
||||||
|
value={a.qty}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
|
||||||
|
value={a.whId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— انتخاب انبار پروژه مقصد —</option>
|
||||||
|
{destWhOptions.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>{w.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{draftAllocs.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-8 px-2 text-muted hover:text-red transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
const next = draftAllocs.filter((x) => x.key !== a.key);
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
title="حذف این ردیف تخصیص"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
|
||||||
|
setTransferAlloc(t.id, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ افزودن انبار دیگر برای تقسیم بار
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
|
||||||
|
<div>
|
||||||
|
{isExactMatch ? (
|
||||||
|
<span className="font-semibold text-emerald-600">
|
||||||
|
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-rose-600">
|
||||||
|
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasEmptyWh && (
|
||||||
|
<span className="ms-2 font-bold text-rose-600">
|
||||||
|
— لطفاً انبار مقصد را انتخاب کنید
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="green"
|
||||||
|
disabled={!isValid || transferSaving === t.id}
|
||||||
|
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
|
||||||
|
>
|
||||||
|
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* بخش سفارشهای خرید در راه */}
|
||||||
|
{orders.map(([quoteId, its]) => {
|
||||||
const first = its[0];
|
const first = its[0];
|
||||||
const open = expanded.has(quoteId);
|
const open = expanded.has(quoteId);
|
||||||
const hasSubmittable = orderHasSubmittable(quoteId);
|
const hasSubmittable = orderHasSubmittable(quoteId);
|
||||||
@@ -401,7 +607,8 @@ export function ArrivalsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -129,9 +129,10 @@ export function DailyControlPage() {
|
|||||||
const wGroups = useMemo(() => {
|
const wGroups = useMemo(() => {
|
||||||
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
||||||
withdrawals.forEach((t) => {
|
withdrawals.forEach((t) => {
|
||||||
const g = gmap.get(t.inventory_id);
|
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
|
||||||
|
const g = gmap.get(gKey);
|
||||||
if (!g) {
|
if (!g) {
|
||||||
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||||
} else {
|
} else {
|
||||||
g.q += Math.abs(t.quantity_number || 0);
|
g.q += Math.abs(t.quantity_number || 0);
|
||||||
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
||||||
@@ -324,6 +325,12 @@ export function DailyControlPage() {
|
|||||||
whName(data.warehouses, g.tx.warehouse_id)
|
whName(data.warehouses, g.tx.warehouse_id)
|
||||||
].filter(Boolean).join(" · ")}
|
].filter(Boolean).join(" · ")}
|
||||||
</span>
|
</span>
|
||||||
|
{g.tx.delivered_to && (
|
||||||
|
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
|
||||||
|
<span className="text-slate-500 font-normal">تحویل به:</span>
|
||||||
|
<span>{g.tx.delivered_to}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
||||||
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||||
|
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type RowDraft } from "./StockTable";
|
||||||
|
|
||||||
|
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||||
|
|
||||||
|
interface FoundTableProps {
|
||||||
|
items: LossItem[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocal(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{items.map(({ tx, remaining }, i) => {
|
||||||
|
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||||
|
return (
|
||||||
|
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||||
|
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد پیداشده"
|
||||||
|
value={rows[i]?.qty || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// ============================================================
|
||||||
|
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||||
|
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||||
|
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||||
|
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||||
|
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine, type StockRecord } from "./StockTable";
|
||||||
|
|
||||||
|
interface ProjectTransferListProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||||
|
errors: Record<string, string>;
|
||||||
|
emptyMsg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[600px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">تعداد انتقال</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد میشود.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r) => {
|
||||||
|
const err = errors[r.id];
|
||||||
|
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||||
|
return (
|
||||||
|
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="تعداد انتقال"
|
||||||
|
value={qtyOf(r.id)}
|
||||||
|
onChange={(e) => onQty(r.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||||
|
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول «اقلام ثبتشده در این نشست» برای موجودی اولیه
|
||||||
|
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظهی همان صفحه.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
export interface PurchaseSessionEntry {
|
||||||
|
material: string;
|
||||||
|
brand: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
qty: number;
|
||||||
|
unitLabel: string;
|
||||||
|
time: string; // HH:MM آمادهی نمایش
|
||||||
|
whName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseSessionLogProps {
|
||||||
|
entries: PurchaseSessionEntry[];
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
|
||||||
|
if (!entries.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||||
|
<h3 className="text-[13px] font-bold">
|
||||||
|
🧾 اقلام ثبتشده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
|
||||||
|
>
|
||||||
|
پاک کردن لیست
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دسکتاپ: جدول فشرده */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12px]">
|
||||||
|
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 px-3">متریال</th>
|
||||||
|
<th className="py-2 px-3">برند</th>
|
||||||
|
<th className="py-2 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2 px-3">تعداد</th>
|
||||||
|
<th className="py-2 px-3">ساعت</th>
|
||||||
|
<th className="py-2 px-3">انبار</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
|
||||||
|
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.time}</td>
|
||||||
|
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: ردیفهای فشرده */}
|
||||||
|
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
|
||||||
|
{[...entries].reverse().map((e, i) => (
|
||||||
|
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
|
||||||
|
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-muted">
|
||||||
|
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ============================================================
|
||||||
|
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||||
|
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||||
|
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||||
|
|
||||||
|
interface ReturnFilterBarProps {
|
||||||
|
preset: DatePreset;
|
||||||
|
onPreset: (p: DatePreset) => void;
|
||||||
|
customFrom: string | null;
|
||||||
|
customTo: string | null;
|
||||||
|
onCustomFrom: (iso: string | null) => void;
|
||||||
|
onCustomTo: (iso: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||||
|
{DATE_PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPreset(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||||
|
preset === p.id
|
||||||
|
? "bg-accent font-bold text-white shadow-xs"
|
||||||
|
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset === "custom" && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||||
|
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// ============================================================
|
||||||
|
// جدول برداشتهای بازه برای بخش «برگشت»
|
||||||
|
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
|
||||||
|
// برگشت محاسبهشده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
|
||||||
|
// ورودیها کلید-محورند (کلید = tx-id) تا حذف ردیفهای موفق، بقیه را
|
||||||
|
// بههم نریزد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
import { faNum, fmtDateTime } from "../../../lib/formatters";
|
||||||
|
import { cn } from "../../../lib/utils";
|
||||||
|
import { specsLine } from "./StockTable";
|
||||||
|
|
||||||
|
interface ReturnTableProps {
|
||||||
|
withdrawals: InventoryTransaction[];
|
||||||
|
qtyOf: (id: string) => string;
|
||||||
|
noteOf: (id: string) => string;
|
||||||
|
onQty: (id: string, v: string) => void;
|
||||||
|
onNote: (id: string, v: string) => void;
|
||||||
|
remainders: Map<string, number>;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
emptyMsg: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number {
|
||||||
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
|
return isFinite(v) ? v : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشتها…</div>;
|
||||||
|
}
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[760px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3">تاریخ</th>
|
||||||
|
<th className="py-2.5 px-3">متریال</th>
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">برداشت</th>
|
||||||
|
<th className="py-2.5 px-3">مانده قابل برگشت</th>
|
||||||
|
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||||
|
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
|
||||||
|
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
|
||||||
|
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
|
||||||
|
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="۰"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">
|
||||||
|
«مانده قابل برگشت» = مقدار برداشت − برگشتهای ثبتشده همان ردیف. سقف ورودی همین مانده است.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتها */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{withdrawals.map((w) => {
|
||||||
|
const cap = remainders.get(w.id) ?? 0;
|
||||||
|
const q = parseNum(qtyOf(w.id));
|
||||||
|
const over = q > cap + 1e-9;
|
||||||
|
return (
|
||||||
|
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{w.specs && Object.keys(w.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
|
||||||
|
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||||
|
<span>{fmtDateTime(w.transaction_date)}</span>
|
||||||
|
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
|
||||||
|
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||||
|
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||||
|
)}
|
||||||
|
placeholder="مقدار برگشت"
|
||||||
|
value={qtyOf(w.id)}
|
||||||
|
onChange={(e) => onQty(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={noteOf(w.id)}
|
||||||
|
onChange={(e) => onNote(w.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویرایشگر مشخصههای فنی Key-Value (موجودی اولیه)
|
||||||
|
// خروجی: jsonb استاندارد {عنوان: مقدار} همخوان با specs_hash بکاند.
|
||||||
|
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface SpecKvRow { key: string; value: string; }
|
||||||
|
|
||||||
|
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const k = r.key.trim();
|
||||||
|
if (k) out[k] = r.value.trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecsKvEditorProps {
|
||||||
|
rows: SpecKvRow[];
|
||||||
|
onChange: (rows: SpecKvRow[]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
|
||||||
|
const update = (i: number, patch: Partial<SpecKvRow>) =>
|
||||||
|
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="عنوان مشخصه (مثلاً ضخامت)"
|
||||||
|
value={r.key}
|
||||||
|
onChange={(e) => update(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[12px] text-muted">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="مقدار (مثلاً ۲ میلیمتر)"
|
||||||
|
value={r.value}
|
||||||
|
onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
|
||||||
|
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||||
|
title="حذف این مشخصه"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange([...rows, { key: "", value: "" }])}
|
||||||
|
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||||||
|
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||||||
|
//
|
||||||
|
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
|
||||||
|
// موبایل (<640px): کارتهای عمودی لمسی با فیلد بزرگ ورود مقدار.
|
||||||
|
// هر بخش ستونهای نمایش یکسان را از اینجا میگیرد و فقط رفتار ورودی
|
||||||
|
// (تعداد ساده یا شمارششده) را با isCount انتخاب میکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { faNum } from "../../../lib/formatters";
|
||||||
|
|
||||||
|
export interface StockRecord {
|
||||||
|
id: string;
|
||||||
|
material: { id: string; name: string };
|
||||||
|
brand: { id: string; name: string } | null;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
quantity_number: number;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowDraft { key: string; qty: string; note: string; }
|
||||||
|
|
||||||
|
export function specsLine(specs: Record<string, string> | undefined): string {
|
||||||
|
return (
|
||||||
|
Object.entries(specs || {})
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(" · ") || "—"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockTableProps {
|
||||||
|
records: StockRecord[];
|
||||||
|
rows: RowDraft[];
|
||||||
|
setRows: (r: RowDraft[]) => void;
|
||||||
|
/** شمارش موجودی: placeholder «شمارششده» و پیشنمایش مقدار فعلی هنگام خالی بودن */
|
||||||
|
isCount?: boolean;
|
||||||
|
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است میتوان پنهان کرد */
|
||||||
|
showMaterial?: boolean;
|
||||||
|
emptyMsg: string;
|
||||||
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
|
||||||
|
if (emptyMsg) {
|
||||||
|
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qtyPlaceholder = isCount ? "شمارششده" : "۰";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||||
|
<table className="w-full text-[12.5px] min-w-[560px]">
|
||||||
|
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
|
||||||
|
<tr className="text-right text-muted">
|
||||||
|
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
|
||||||
|
<th className="py-2.5 px-3">مشخصهها</th>
|
||||||
|
<th className="py-2.5 px-3">برند</th>
|
||||||
|
<th className="py-2.5 px-3">واحد</th>
|
||||||
|
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||||
|
<th className="py-2.5 px-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||||
|
<th className="py-2.5 px-3">یادداشت</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line/40">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
|
||||||
|
{showMaterial && (
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||||
|
)}
|
||||||
|
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
|
||||||
|
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
|
||||||
|
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
|
||||||
|
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||||
|
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
|
||||||
|
<span>{faNum(records.length)} قلم کالا</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* موبایل: کارتهای عمودی لمسی */}
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||||
|
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||||
|
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||||
|
</div>
|
||||||
|
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||||
|
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] text-muted">موجودی</div>
|
||||||
|
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
|
||||||
|
placeholder={qtyPlaceholder}
|
||||||
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||||
|
placeholder="توضیح (اختیاری)"
|
||||||
|
value={rows[i]?.note || ""}
|
||||||
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// ============================================================
|
||||||
|
// منطق بخش «برگشت» — واکشی برداشتها/برگشتها و محاسبه مانده قابل برگشت
|
||||||
|
//
|
||||||
|
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمیشود؛
|
||||||
|
// مانده هر برداشت = مقدار برداشت − مجموع برگشتهای متصل به آن.
|
||||||
|
// اتصال دو راه دارد:
|
||||||
|
// ۱) برگشتهای جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
|
||||||
|
// ۲) برگشتهای قدیمی بدون REF (ثبتشده در همان انبار مبدأ) → تخصیص FIFO
|
||||||
|
// بین برداشتهای همان ردیف انبار (متریال+برند+مشخصات+واحد).
|
||||||
|
// همهی جمع/تفریقها با round4 گرد میشوند تا خطای اعشار جاوااسکریپت
|
||||||
|
// (مثل 0.30000000000000004) در ماندهها و سقفها نشت نکند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { apiV26 } from "../../../lib/api";
|
||||||
|
import type { InventoryTransaction } from "../../../lib/types";
|
||||||
|
|
||||||
|
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همهی محاسبات مانده */
|
||||||
|
export function round4(n: number): number {
|
||||||
|
return Math.round(n * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تاریخ — کلید روز میلادی محلی و بازههای پیشفرض
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
export function gregKey(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d: Date, n: number): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
|
||||||
|
|
||||||
|
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
|
||||||
|
{ id: "today", label: "امروز" },
|
||||||
|
{ id: "yesterday", label: "دیروز" },
|
||||||
|
{ id: "7d", label: "۷ روز اخیر" },
|
||||||
|
{ id: "30d", label: "۳۰ روز اخیر" },
|
||||||
|
{ id: "custom", label: "بازه دلخواه" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** بازهی سرور-ساید برای هر پیشفرض؛ date_to روزِ (inclusive بکاند: < date_to + 24h) */
|
||||||
|
export function presetRange(
|
||||||
|
preset: DatePreset,
|
||||||
|
customFrom?: string,
|
||||||
|
customTo?: string,
|
||||||
|
): { date_from: string; date_to: string } {
|
||||||
|
const today = new Date();
|
||||||
|
switch (preset) {
|
||||||
|
case "today":
|
||||||
|
return { date_from: gregKey(today), date_to: gregKey(today) };
|
||||||
|
case "yesterday": {
|
||||||
|
const y = gregKey(addDays(today, -1));
|
||||||
|
return { date_from: y, date_to: y };
|
||||||
|
}
|
||||||
|
case "7d":
|
||||||
|
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
|
||||||
|
case "30d":
|
||||||
|
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
|
||||||
|
case "custom":
|
||||||
|
return {
|
||||||
|
date_from: customFrom || gregKey(today),
|
||||||
|
date_to: customTo || customFrom || gregKey(today),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// تگ رفرنس [REF:<uuid>]
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
|
||||||
|
|
||||||
|
export function parseRef(note?: string | null): string | null {
|
||||||
|
if (!note) return null;
|
||||||
|
const m = note.match(REF_RE);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
|
||||||
|
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
|
||||||
|
const tag = `[REF:${withdrawalTxId}]`;
|
||||||
|
const note = (userNote || "").trim();
|
||||||
|
return note ? `${note} ${tag}` : tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// واکشی صفحهبندیشده (هر صفحه ۱۰۰ — سقف بکاند)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const MAX_PAGES = 10; // سقف محافظهکارانه: ۱۰۰۰ رکورد
|
||||||
|
|
||||||
|
export async function fetchWithdrawalsPage(
|
||||||
|
sourceWhId: string,
|
||||||
|
dateFrom: string,
|
||||||
|
dateTo: string,
|
||||||
|
page: number,
|
||||||
|
): Promise<{ rows: InventoryTransaction[]; total: number }> {
|
||||||
|
return apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: sourceWhId,
|
||||||
|
transaction_type: "WITHDRAWAL",
|
||||||
|
date_from: dateFrom,
|
||||||
|
date_to: dateTo,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی برگشتهای پروژه از ابتدای بازه (برای تطبیق REF) — صفحهبهصفحه تا سقف */
|
||||||
|
export async function fetchProjectReturns(
|
||||||
|
dateFrom: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
transaction_type: "RETURN",
|
||||||
|
date_from: dateFrom,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** همهی تراکنشهای یک نوع در یک انبار (برای محاسبه مانده مفقودیها) */
|
||||||
|
export async function fetchAllTypeInWarehouse(
|
||||||
|
warehouseId: string,
|
||||||
|
transactionType: string,
|
||||||
|
): Promise<InventoryTransaction[]> {
|
||||||
|
const out: InventoryTransaction[] = [];
|
||||||
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||||
|
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||||
|
warehouse_id: warehouseId,
|
||||||
|
transaction_type: transactionType,
|
||||||
|
sort: "transaction_date",
|
||||||
|
page,
|
||||||
|
});
|
||||||
|
out.push(...rows);
|
||||||
|
if (out.length >= total || rows.length === 0) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// مانده قابل برگشت
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتبشده) + واحد — همارز specs_hash بکاند */
|
||||||
|
function rowKey(t: InventoryTransaction): string {
|
||||||
|
const specs = Object.keys(t.specs || {})
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${String((t.specs || {})[k])}`)
|
||||||
|
.join("&");
|
||||||
|
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مانده قابل برگشت هر برداشت را حساب میکند.
|
||||||
|
* - برگشتهای REF-دار: مستقیماً از برداشت مقصد کم میشوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
|
||||||
|
* - برگشتهای REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته میشوند (به برداشت خودشان تعلق دارند).
|
||||||
|
* - برگشتهای قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشتهای همردیف تخصیص مییابند.
|
||||||
|
*/
|
||||||
|
export function computeRemainders(
|
||||||
|
withdrawals: InventoryTransaction[],
|
||||||
|
returns: InventoryTransaction[],
|
||||||
|
sourceWhId: string,
|
||||||
|
): Map<string, number> {
|
||||||
|
const rem = new Map<string, number>();
|
||||||
|
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
|
||||||
|
|
||||||
|
const inList = new Set(withdrawals.map((w) => w.id));
|
||||||
|
const legacy: InventoryTransaction[] = [];
|
||||||
|
|
||||||
|
for (const r of returns) {
|
||||||
|
const qty = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
if (qty <= 0) continue;
|
||||||
|
const ref = parseRef(r.note);
|
||||||
|
if (ref) {
|
||||||
|
if (inList.has(ref)) {
|
||||||
|
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
|
||||||
|
}
|
||||||
|
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
|
||||||
|
} else {
|
||||||
|
legacy.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// تخصیص FIFO برگشتهای قدیمیِ همانبار مبدأ بین برداشتهای همردیف (قدیمی→جدید)
|
||||||
|
const buckets = new Map<string, InventoryTransaction[]>();
|
||||||
|
for (const w of withdrawals) {
|
||||||
|
const k = rowKey(w);
|
||||||
|
const list = buckets.get(k);
|
||||||
|
if (list) list.push(w);
|
||||||
|
else buckets.set(k, [w]);
|
||||||
|
}
|
||||||
|
for (const list of buckets.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const r of legacy) {
|
||||||
|
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
|
||||||
|
const list = buckets.get(rowKey(r));
|
||||||
|
if (!list) continue;
|
||||||
|
let q = round4(Math.abs(r.quantity_number || 0));
|
||||||
|
for (const w of list) {
|
||||||
|
if (q <= 0) break;
|
||||||
|
const cur = rem.get(w.id) ?? 0;
|
||||||
|
if (cur <= 0) continue;
|
||||||
|
const take = Math.min(cur, q);
|
||||||
|
rem.set(w.id, round4(cur - take));
|
||||||
|
q = round4(q - take);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// کف صفر (محافظت در برابر دادههای ناسازگار قدیمی)
|
||||||
|
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
|
||||||
|
return rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مانده مفقودیِ باقیمانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
|
||||||
|
export function computeLossRemainders(
|
||||||
|
losses: InventoryTransaction[],
|
||||||
|
founds: InventoryTransaction[],
|
||||||
|
): { tx: InventoryTransaction; remaining: number }[] {
|
||||||
|
const foundByKey = new Map<string, number>();
|
||||||
|
for (const f of founds) {
|
||||||
|
const k = rowKey(f);
|
||||||
|
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
|
||||||
|
}
|
||||||
|
return losses
|
||||||
|
.map((tx) => {
|
||||||
|
const loss = Math.abs(tx.quantity_number || 0);
|
||||||
|
const found = foundByKey.get(rowKey(tx)) || 0;
|
||||||
|
return { tx, remaining: Math.max(0, round4(loss - found)) };
|
||||||
|
})
|
||||||
|
.filter((x) => x.remaining > 0);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
|
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { topicByName } from "../../lib/topics";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { daysBetween } from "../../lib/formatters";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { PurchaseHistory } from "./PurchaseHistory";
|
import { PurchaseHistory } from "./PurchaseHistory";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||||
|
function cleanPhoneForTel(phone?: string | null): string {
|
||||||
|
if (!phone) return "";
|
||||||
|
const enDigits = phone
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
|
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||||||
export function SupplierDetailPage() {
|
export function SupplierDetailPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
@@ -29,6 +39,19 @@ export function SupplierDetailPage() {
|
|||||||
const [linked, setLinked] = useState<string[]>([]);
|
const [linked, setLinked] = useState<string[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
// محاسبه میانگین زمان تحویل این تأمینکننده بر اساس خریدهای تحویلشده
|
||||||
|
const avgLeadTime = useMemo(() => {
|
||||||
|
if (!s?.id) return null;
|
||||||
|
const supQuotes = quotes.filter(
|
||||||
|
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||||||
|
);
|
||||||
|
const times = supQuotes
|
||||||
|
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||||
|
.filter((x): x is number => x != null);
|
||||||
|
|
||||||
|
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||||
|
}, [quotes, s?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -68,7 +91,6 @@ export function SupplierDetailPage() {
|
|||||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||||
|
|
||||||
const saveCategories = (names: string[]) => {
|
const saveCategories = (names: string[]) => {
|
||||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
|
||||||
if (!id || busy) return;
|
if (!id || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -116,21 +138,51 @@ export function SupplierDetailPage() {
|
|||||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xl font-extrabold">{s.name}</div>
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">{topicName}</Badge>
|
<div className="text-xl font-extrabold">{s.name}</div>
|
||||||
|
<Badge variant="outline">{topicName}</Badge>
|
||||||
|
</div>
|
||||||
|
{avgLeadTime != null && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<span>{avgLeadTime} روز</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
|
||||||
|
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
|
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
|
||||||
</span>
|
<div className="flex items-center">
|
||||||
|
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
|
||||||
|
<b className="me-1">تلفن:</b>
|
||||||
|
{s.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||||
|
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
|
||||||
|
dir="ltr"
|
||||||
|
title="تماس مستقیم با تأمینکننده"
|
||||||
|
>
|
||||||
|
<span className="tabular-nums">{s.phone}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
|
||||||
|
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||||
@@ -151,7 +203,7 @@ export function SupplierDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
{/* متریالهای این تأمینکننده */}
|
||||||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<h4 className="mb-1 text-[13.5px] font-bold">
|
<h4 className="mb-1 text-[13.5px] font-bold">
|
||||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||||
@@ -191,7 +243,7 @@ export function SupplierDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
{/* سابقه خرید از این تأمینکننده */}
|
||||||
<div className="mt-4 max-w-4xl">
|
<div className="mt-4 max-w-4xl">
|
||||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||||
</div>
|
</div>
|
||||||
@@ -208,4 +260,4 @@ export function SupplierDetailPage() {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||||
|
function cleanPhoneForTel(phone?: string | null): string {
|
||||||
|
if (!phone) return "";
|
||||||
|
const enDigits = phone
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
|
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||||
return suppliers.filter(
|
return suppliers.filter(
|
||||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||||
@@ -29,7 +38,11 @@ export function SupplierCard({
|
|||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head flex items-start justify-between gap-2">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
{/* فقط متن نام تأمینکننده لینک است و به کل کارت گسترش پیدا نمیکند */}
|
||||||
|
<Link
|
||||||
|
to={`/suppliers/${s.id}`}
|
||||||
|
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
|
||||||
|
>
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
@@ -49,17 +62,34 @@ export function SupplierCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||||
</span>
|
{s.phone ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||||
|
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
|
||||||
|
title="تماس مستقیم با تأمینکننده"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
|
||||||
|
<span className="tabular-nums">{s.phone}</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{materials.length > 0 && (
|
{materials.length > 0 && (
|
||||||
<div className="mt-2 border-t border-line pt-1.5">
|
<div className="mt-2 border-t border-line pt-1.5">
|
||||||
<div className="mb-1 flex flex-wrap gap-1">
|
<div className="mb-1 flex flex-wrap gap-1">
|
||||||
@@ -71,6 +101,7 @@ export function SupplierCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="sc-actions">
|
<div className="sc-actions">
|
||||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
|
|||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export interface ItemDraft {
|
export interface ItemDraft {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
@@ -51,7 +53,6 @@ export function materialOptions(
|
|||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
// متریالها سراسری هستند — مستقل از تاپیک
|
|
||||||
let list = materials;
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
@@ -67,21 +68,17 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و متریال
|
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
|
||||||
// همنام پیشنهاد میشوند.
|
|
||||||
if (cat)
|
if (cat)
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||||
);
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
|
||||||
const base = new Set<string>();
|
const base = new Set<string>();
|
||||||
apiSpecs.forEach((s) => s && base.add(s));
|
apiSpecs.forEach((s) => s && base.add(s));
|
||||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||||
@@ -90,7 +87,7 @@ export function materialOptions(
|
|||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
// مرحله ۲ — مدیریت اقلام سفارش
|
||||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
|
|
||||||
@@ -167,146 +164,205 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||||
}
|
}
|
||||||
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
|
||||||
// همان هویتِ متریال انجام میشود، نه دسته.
|
|
||||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
{/* هدر */}
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
<div className="space-y-1">
|
||||||
اقلام سفارش
|
<div className="flex items-center justify-between">
|
||||||
</h3>
|
<div className="flex items-center gap-2">
|
||||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||||
|
۲
|
||||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
</span>
|
||||||
<span>متریال</span>
|
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||||
<span>برند</span>
|
اقلام سفارش
|
||||||
<span>مشخصه</span>
|
</h3>
|
||||||
<span>تعداد</span>
|
</div>
|
||||||
<span>واحد</span>
|
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
||||||
<span>قیمت واحد</span>
|
{faNum(items.length)} قلم
|
||||||
<span>قیمت کل</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||||
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-field="line_items" className="flex flex-col gap-2">
|
{/* لیست کارتهای اقلام */}
|
||||||
{items.map((it) => (
|
<div data-field="line_items" className="flex flex-col gap-3">
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
{items.map((it, idx) => (
|
||||||
<Combobox
|
<div
|
||||||
value={it.cat}
|
key={it.tempId}
|
||||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||||
items={allMaterialNames}
|
>
|
||||||
allowAdd
|
{/* سربرگ قلم */}
|
||||||
addSuffix="به عنوان متریال جدید"
|
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||||
placeholder="متریال"
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
||||||
onBlur={() => rememberField("i-cat", it)}
|
{faNum(idx + 1)}
|
||||||
inputClassName="!text-[12px] !h-9"
|
</span>
|
||||||
className="min-w-0"
|
<span className="text-xs font-bold text-ink truncate">
|
||||||
/>
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||||
<Combobox
|
</span>
|
||||||
value={it.brand}
|
</div>
|
||||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
{items.length > 1 && (
|
||||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
<button
|
||||||
allowAdd
|
type="button"
|
||||||
addSuffix="به عنوان برند جدید"
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
placeholder="برند"
|
title="حذف قلم"
|
||||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
onClick={() => remove(it.tempId)}
|
||||||
onBlur={() => rememberField("i-brand", it)}
|
>
|
||||||
inputClassName="!text-[12px] !h-9"
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
className="min-w-0"
|
<span className="text-[11px]">حذف</span>
|
||||||
/>
|
</button>
|
||||||
<MultiSelectCombobox
|
)}
|
||||||
values={it.specs}
|
</div>
|
||||||
onChange={(specs) => patch(it.tempId, { specs })}
|
|
||||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
{/* ردیف ۱: متریال و برند */}
|
||||||
placeholder="مشخصه"
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
className="min-w-0"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
/>
|
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||||
<NumberInput
|
<Combobox
|
||||||
value={it.qty}
|
value={it.cat}
|
||||||
onChange={(qty) => {
|
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||||
patch(it.tempId, {
|
items={allMaterialNames}
|
||||||
qty,
|
allowAdd
|
||||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
addSuffix="به عنوان متریال جدید"
|
||||||
});
|
placeholder="نام متریال"
|
||||||
}}
|
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||||
placeholder="تعداد"
|
onBlur={() => rememberField("i-cat", it)}
|
||||||
showWords
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
/>
|
className="min-w-0"
|
||||||
<Combobox
|
/>
|
||||||
value={it.unit}
|
</div>
|
||||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
|
||||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
allowAdd
|
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||||
addSuffix="به عنوان واحد جدید"
|
<Combobox
|
||||||
placeholder="واحد"
|
value={it.brand}
|
||||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||||
onAdd={(v) => {
|
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||||
const name = v.trim();
|
allowAdd
|
||||||
if (!name) return;
|
addSuffix="به عنوان برند جدید"
|
||||||
void apiV26.catalog
|
placeholder="برند"
|
||||||
.createUnit(name)
|
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||||
.then(() => {
|
onBlur={() => rememberField("i-brand", it)}
|
||||||
setUnitOptions((prev) =>
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
className="min-w-0"
|
||||||
);
|
/>
|
||||||
})
|
</div>
|
||||||
.catch(() => {});
|
</div>
|
||||||
}}
|
|
||||||
inputClassName="!text-[12px] !h-9"
|
{/* ردیف ۲: مشخصه فنی */}
|
||||||
className="min-w-0"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
/>
|
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||||
<NumberInput
|
<MultiSelectCombobox
|
||||||
value={it.price}
|
values={it.specs}
|
||||||
onChange={(price) => {
|
onChange={(specs) => patch(it.tempId, { specs })}
|
||||||
patch(it.tempId, {
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||||
price,
|
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
className="min-w-0"
|
||||||
});
|
/>
|
||||||
}}
|
</div>
|
||||||
placeholder="قیمت واحد"
|
|
||||||
showWords
|
{/* بخش مقادیر و محاسبات مالی */}
|
||||||
maxDecimals={2}
|
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||||
/>
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||||
<NumberInput
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||||
value={it.total}
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
onChange={(total) => {
|
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||||
patch(it.tempId, {
|
<NumberInput
|
||||||
total,
|
value={it.qty}
|
||||||
price:
|
onChange={(qty) => {
|
||||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
patch(it.tempId, {
|
||||||
});
|
qty,
|
||||||
}}
|
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||||
placeholder="قیمت کل"
|
});
|
||||||
showWords
|
}}
|
||||||
/>
|
placeholder="مقدار"
|
||||||
|
className="!text-right text-right"
|
||||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
/>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
title="حذف قلم"
|
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||||
onClick={() => remove(it.tempId)}
|
<Combobox
|
||||||
>
|
value={it.unit}
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||||
</svg>
|
allowAdd
|
||||||
</button>
|
addSuffix="به عنوان واحد جدید"
|
||||||
|
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||||
|
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||||
|
onAdd={(v) => {
|
||||||
|
const name = v.trim();
|
||||||
|
if (!name) return;
|
||||||
|
void apiV26.catalog
|
||||||
|
.createUnit(name)
|
||||||
|
.then(() => {
|
||||||
|
setUnitOptions((prev) =>
|
||||||
|
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}}
|
||||||
|
inputClassName="!text-xs !h-9 text-right"
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۴: قیمت هر واحد (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||||
|
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||||
|
</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.price}
|
||||||
|
onChange={(price) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
price,
|
||||||
|
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت هر واحد"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۵: قیمت کل قلم (راستچین شده) */}
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||||
|
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||||
|
<NumberInput
|
||||||
|
value={it.total}
|
||||||
|
onChange={(total) => {
|
||||||
|
patch(it.tempId, {
|
||||||
|
total,
|
||||||
|
price:
|
||||||
|
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="قیمت کل قلم"
|
||||||
|
showWords
|
||||||
|
className="!text-right text-right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
{/* دکمه افزودن قلم جدید */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
||||||
onClick={add}
|
onClick={add}
|
||||||
>
|
>
|
||||||
<span className="text-base font-bold leading-none">+</span>
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن قلم جدید</span>
|
<span>افزودن قلم جدید</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,43 +9,61 @@ export interface StepperProps {
|
|||||||
onGoto: (step: number) => void;
|
onGoto: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
const STEPS = [
|
||||||
|
{ id: 1, label: "تأمینکننده" },
|
||||||
|
{ id: 2, label: "اقلام" },
|
||||||
|
{ id: 3, label: "شرایط پرداخت" },
|
||||||
|
{ id: 4, label: "پروژه" },
|
||||||
|
{ id: 5, label: "بازبینی و ثبت" },
|
||||||
|
];
|
||||||
|
|
||||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
// نوار ۵ مرحلهای ویزارد — واکنشگرا و ضدشکستگی در موبایل
|
||||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
|
||||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||||
{STEPS.map((label, i) => {
|
{STEPS.map(({ id: step, label }) => {
|
||||||
const step = i + 1;
|
|
||||||
const isCurrent = step === current;
|
const isCurrent = step === current;
|
||||||
const isComplete = completedSteps.has(step);
|
const isComplete = completedSteps.has(step);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={step}
|
key={step}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||||
|
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||||
isCurrent
|
isCurrent
|
||||||
? "bg-[#2563eb] font-bold text-white"
|
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||||
: isComplete
|
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||||
? "bg-green-bg font-medium text-green"
|
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||||
: "hover:bg-slate-bg",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onGoto(step)}
|
onClick={() => onGoto(step)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
isCurrent
|
||||||
|
? "bg-white/20 text-white"
|
||||||
|
: isComplete
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||||
|
: "bg-muted/15 text-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||||
|
!isCurrent && "hidden md:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{label}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { track } from "../../lib/analytics";
|
import { track } from "../../lib/analytics";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { LineItem, Quote } from "../../lib/types";
|
import type { LineItem, Quote } from "../../lib/types";
|
||||||
@@ -82,120 +82,6 @@ export function WizardPage() {
|
|||||||
const [proforma, setProforma] = useState<File | null>(null);
|
const [proforma, setProforma] = useState<File | null>(null);
|
||||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [loadedDraftId, setLoadedDraftId] = useState<string | null>(null);
|
|
||||||
const draftFetchedRef = useRef(false);
|
|
||||||
const lastRemoteUpdatedAtRef = useRef<string | null>(null);
|
|
||||||
const isSelfUpdatingRef = useRef<boolean>(false);
|
|
||||||
const isCommittedOrCancelledRef = useRef<boolean>(false);
|
|
||||||
const initialLoadedRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
// ---------- تابع اعمال دادههای پیشنویس به فیلدهای ویزارد ----------
|
|
||||||
const applyDraftPayloadToForm = useCallback(
|
|
||||||
(p: Record<string, any>) => {
|
|
||||||
if (!p) return;
|
|
||||||
|
|
||||||
// ۱. تأمینکننده و وضعیت رسمی
|
|
||||||
if (p.supplier_name !== undefined) {
|
|
||||||
setSupplierName(p.supplier_name || "");
|
|
||||||
setSupplierInvalid(false);
|
|
||||||
}
|
|
||||||
if (p.is_official !== undefined) {
|
|
||||||
setIsOfficial(Boolean(p.is_official));
|
|
||||||
}
|
|
||||||
|
|
||||||
// استنتاج تاپیک: ابتدا از اطلاعات تأمینکننده، سپس متریال، سپس اولین تاپیک موجود
|
|
||||||
let topicToSet = "";
|
|
||||||
if (p.supplier_name) {
|
|
||||||
const sup = suppliers.find((s) => normalize(s.name) === normalize(p.supplier_name));
|
|
||||||
if (sup?.topic) topicToSet = sup.topic;
|
|
||||||
}
|
|
||||||
if (!topicToSet && Array.isArray(p.items) && p.items.length > 0) {
|
|
||||||
for (const it of p.items) {
|
|
||||||
if (it.material_name) {
|
|
||||||
const mat = materials.find((m) => normalize(m.name) === normalize(it.material_name));
|
|
||||||
if (mat?.topic) {
|
|
||||||
topicToSet = mat.topic;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!topicToSet && topics.length > 0) {
|
|
||||||
topicToSet = topics[0].name;
|
|
||||||
}
|
|
||||||
if (topicToSet) {
|
|
||||||
setSelectedTopic(topicToSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ۲. اقلام (Step 2)
|
|
||||||
let newItems: ItemDraft[] = [];
|
|
||||||
if (Array.isArray(p.items) && p.items.length > 0) {
|
|
||||||
newItems = p.items.map((it: any, idx: number) => {
|
|
||||||
const qty = it.quantity != null ? Number(it.quantity) : null;
|
|
||||||
const price = it.unit_price != null ? Number(it.unit_price) : null;
|
|
||||||
return {
|
|
||||||
tempId: "it" + idx,
|
|
||||||
cat: it.material_name || "",
|
|
||||||
brand: it.brand_name || "",
|
|
||||||
specs: Array.isArray(it.specs) ? it.specs : [],
|
|
||||||
qty,
|
|
||||||
unit: it.unit_name || "",
|
|
||||||
price,
|
|
||||||
total: qty != null && price != null ? qty * price : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setItems(newItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ۳. شرایط پرداخت / termsheet (Step 3)
|
|
||||||
if (Array.isArray(p.termsheets) && p.termsheets.length > 0) {
|
|
||||||
const newCards: TsCardDraft[] = p.termsheets.map((ts: any, idx: number) => ({
|
|
||||||
key: "ts" + idx,
|
|
||||||
milestones:
|
|
||||||
Array.isArray(ts.milestones) && ts.milestones.length > 0
|
|
||||||
? ts.milestones.map((m: any) => ({
|
|
||||||
when_iso: m.due_date || m.when_iso || null,
|
|
||||||
amount: m.amount != null ? Number(m.amount) : null,
|
|
||||||
}))
|
|
||||||
: [{ when_iso: null, amount: null }],
|
|
||||||
}));
|
|
||||||
setCards(newCards);
|
|
||||||
|
|
||||||
const targetIds = (newItems.length > 0 ? newItems : items).map((i) => i.tempId);
|
|
||||||
const newCov: Record<string, string[]> = {};
|
|
||||||
newCards.forEach((c) => {
|
|
||||||
newCov[c.key] = targetIds;
|
|
||||||
});
|
|
||||||
setCoverage(newCov);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ۴. پروژه (Step 4)
|
|
||||||
const projNames: string[] = [];
|
|
||||||
if (Array.isArray(p.project_names)) {
|
|
||||||
projNames.push(...p.project_names.filter(Boolean));
|
|
||||||
}
|
|
||||||
if (p.project_name && !projNames.includes(p.project_name)) {
|
|
||||||
projNames.push(p.project_name);
|
|
||||||
}
|
|
||||||
if (projNames.length > 0) {
|
|
||||||
const newAssignment: Record<string, string[]> = {};
|
|
||||||
const targetItems = newItems.length > 0 ? newItems : items;
|
|
||||||
targetItems.forEach((it) => {
|
|
||||||
newAssignment[it.tempId] = projNames;
|
|
||||||
});
|
|
||||||
setAssignment(newAssignment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ۵. یادداشت و تاریخ انقضا (Step 5)
|
|
||||||
if (p.notes !== undefined) {
|
|
||||||
setNotes(p.notes || "");
|
|
||||||
}
|
|
||||||
if (p.expiry_date !== undefined) {
|
|
||||||
setExpiryDate(p.expiry_date || null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[suppliers, materials, topics, items],
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -214,122 +100,6 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
}, [params, suppliers, topics]);
|
}, [params, suppliers, topics]);
|
||||||
|
|
||||||
// ---------- بارگذاری اولیه پیشنویس ربات بله (?draft=&draft_id=) ----------
|
|
||||||
useEffect(() => {
|
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
|
||||||
if (!draftId || draftFetchedRef.current) return;
|
|
||||||
|
|
||||||
draftFetchedRef.current = true;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const draft = await api.drafts.get(draftId);
|
|
||||||
if (!draft || !draft.payload) return;
|
|
||||||
setLoadedDraftId(draft.id);
|
|
||||||
lastRemoteUpdatedAtRef.current = draft.updated_at || null;
|
|
||||||
|
|
||||||
applyDraftPayloadToForm(draft.payload);
|
|
||||||
setHighestVisited(5);
|
|
||||||
initialLoadedRef.current = true;
|
|
||||||
|
|
||||||
toast.success("پیشنویس از ربات بله بارگذاری شد. لطفاً اطلاعات را بازبینی کنید.", {
|
|
||||||
duration: 6000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(`خطا در فراخوانی پیشنویس از ربات: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [params, applyDraftPayloadToForm]);
|
|
||||||
|
|
||||||
// ---------- پولینگ دورهای برای دریافت تغییرات یا ثبت از سمت ربات بله ----------
|
|
||||||
useEffect(() => {
|
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
|
||||||
if (!draftId) return;
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
|
||||||
try {
|
|
||||||
const draft = await api.drafts.get(draftId);
|
|
||||||
if (!draft) return;
|
|
||||||
|
|
||||||
if (draft.status === "committed") {
|
|
||||||
isCommittedOrCancelledRef.current = true;
|
|
||||||
toast.success("این پیشنهاد از طریق ربات بله با موفقیت ثبت نهایی شد.", { duration: 8000 });
|
|
||||||
navigate("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (draft.status === "cancelled") {
|
|
||||||
isCommittedOrCancelledRef.current = true;
|
|
||||||
toast.error("این پیشنویس از طریق ربات بله لغو شد.", { duration: 6000 });
|
|
||||||
navigate("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
draft.updated_at &&
|
|
||||||
lastRemoteUpdatedAtRef.current &&
|
|
||||||
draft.updated_at > lastRemoteUpdatedAtRef.current &&
|
|
||||||
!isSelfUpdatingRef.current
|
|
||||||
) {
|
|
||||||
lastRemoteUpdatedAtRef.current = draft.updated_at;
|
|
||||||
applyDraftPayloadToForm(draft.payload);
|
|
||||||
toast.info("🔄 اصلاحات جدید از ربات بله دریافت و در ویزارد اعمال شد.", { duration: 4000 });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* خطای موقت شبکه در پولینگ نادیده گرفته میشود */
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [params, navigate, applyDraftPayloadToForm]);
|
|
||||||
|
|
||||||
// ---------- همگامسازی خودکار تغییرات ویزارد به پیشنویس ربات (Debounced Auto-Save) ----------
|
|
||||||
useEffect(() => {
|
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
|
||||||
if (!draftId || isCommittedOrCancelledRef.current || !initialLoadedRef.current) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
isSelfUpdatingRef.current = true;
|
|
||||||
try {
|
|
||||||
const payloadToSave: Record<string, any> = {
|
|
||||||
supplier_name: supplierName.trim() || null,
|
|
||||||
is_official: isOfficial,
|
|
||||||
items: items.map((it) => ({
|
|
||||||
material_name: it.cat.trim() || null,
|
|
||||||
brand_name: it.brand.trim() || null,
|
|
||||||
specs: it.specs,
|
|
||||||
quantity: it.qty,
|
|
||||||
unit_name: it.unit.trim() || null,
|
|
||||||
unit_price: it.price,
|
|
||||||
})),
|
|
||||||
termsheets: cards.map((c, i) => ({
|
|
||||||
label: "گزینه " + faNum(i + 1),
|
|
||||||
milestones: (c.milestones || []).map((m) => ({
|
|
||||||
due_date: m.when_iso,
|
|
||||||
amount: m.amount,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
project_names: [...new Set(Object.values(assignment).flat())],
|
|
||||||
notes: notes.trim() || null,
|
|
||||||
expiry_date: expiryDate || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.drafts.update(draftId, payloadToSave);
|
|
||||||
const updated = await api.drafts.get(draftId);
|
|
||||||
if (updated?.updated_at) {
|
|
||||||
lastRemoteUpdatedAtRef.current = updated.updated_at;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Auto-save to draft failed:", err);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
isSelfUpdatingRef.current = false;
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
}, 800);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [supplierName, isOfficial, items, cards, assignment, notes, expiryDate, params]);
|
|
||||||
|
|
||||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||||
const completedSteps = useMemo(() => {
|
const completedSteps = useMemo(() => {
|
||||||
const s = new Set<number>();
|
const s = new Set<number>();
|
||||||
@@ -357,11 +127,9 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (_rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها ----------
|
||||||
const projectNames = useMemo(() => {
|
const projectNames = useMemo(() => {
|
||||||
const names = new Set<string>(projects.map((p) => p.name));
|
const names = new Set<string>(projects.map((p) => p.name));
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
@@ -389,7 +157,7 @@ export function WizardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- خلاصهی مرحله ۵ ----------
|
// ---------- خلاصه مرحله ۵ ----------
|
||||||
const review = useMemo(() => {
|
const review = useMemo(() => {
|
||||||
let grandTotal = 0;
|
let grandTotal = 0;
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
@@ -432,23 +200,14 @@ export function WizardPage() {
|
|||||||
|
|
||||||
const combined = `${field} ${msg}`.toLowerCase();
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
// ۱. تأمینکننده
|
|
||||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
|
||||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
return { step: 2, field: field || "line_items" };
|
return { step: 2, field: field || "line_items" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
|
||||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
return { step: 3, field: field || "termsheets" };
|
return { step: 3, field: field || "termsheets" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ۴. پروژه
|
|
||||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
|
||||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
@@ -521,10 +280,6 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
|
||||||
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
|
||||||
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
|
||||||
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
|
||||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
const matched = matchMaterial(
|
const matched = matchMaterial(
|
||||||
@@ -623,15 +378,6 @@ export function WizardPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await createQuote.mutateAsync(quote);
|
const created = await createQuote.mutateAsync(quote);
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
|
||||||
if (draftId) {
|
|
||||||
isCommittedOrCancelledRef.current = true;
|
|
||||||
try {
|
|
||||||
await api.drafts.commit(draftId, created.id);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not commit draft:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (proforma) {
|
if (proforma) {
|
||||||
try {
|
try {
|
||||||
await api.files.upload(created.id, "proforma", proforma);
|
await api.files.upload(created.id, "proforma", proforma);
|
||||||
@@ -641,7 +387,7 @@ export function WizardPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success("پیشنهاد ذخیره شد و در ربات بله تأیید گردید.");
|
toast.success("پیشنهاد ذخیره شد.");
|
||||||
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -650,19 +396,6 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
const draftId = params.get("draft") || params.get("draft_id");
|
|
||||||
if (draftId) {
|
|
||||||
isCommittedOrCancelledRef.current = true;
|
|
||||||
try {
|
|
||||||
await api.drafts.cancel(draftId);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not cancel draft:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navigate("/");
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const incomplete: { label: string; step: number }[] = [];
|
const incomplete: { label: string; step: number }[] = [];
|
||||||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||||||
@@ -678,7 +411,7 @@ export function WizardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-10">
|
||||||
<Header
|
<Header
|
||||||
title="افزودن پیشنهاد جدید"
|
title="افزودن پیشنهاد جدید"
|
||||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||||
@@ -686,21 +419,12 @@ export function WizardPage() {
|
|||||||
backLabel="بازگشت به دفتر"
|
backLabel="بازگشت به دفتر"
|
||||||
/>
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{loadedDraftId && (
|
{/* نوار مراحل استپر */}
|
||||||
<div className="mb-4 flex items-center justify-between rounded-xl border border-blue-200 bg-blue-50/80 p-3.5 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/40 dark:text-blue-200 shadow-xs">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="inline-flex h-6 px-2.5 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
|
|
||||||
ربات بله
|
|
||||||
</span>
|
|
||||||
<span>اطلاعات پیشنویس استعلام با هوش مصنوعی بارگذاری شد. لطفاً اقلام و شرایط را بازبینی فرمایید.</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono font-medium">#{loadedDraftId.slice(0, 8)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
|
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -724,12 +448,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -740,7 +464,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -754,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
@@ -769,15 +493,14 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
{/* پاورقی کنترل مراحل */}
|
||||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
|
||||||
<div>
|
<div>
|
||||||
{currentStep > 1 && (
|
{currentStep > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToStep(currentStep - 1)}
|
onClick={() => goToStep(currentStep - 1)}
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<span>قبلی</span>
|
<span>قبلی</span>
|
||||||
@@ -785,22 +508,19 @@ export function WizardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Link
|
||||||
<button
|
to="/"
|
||||||
type="button"
|
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
|
||||||
onClick={() => void handleCancel()}
|
|
||||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors cursor-pointer border-none bg-transparent"
|
|
||||||
>
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
{currentStep < TOTAL_STEPS && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={next}
|
onClick={next}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>بعدی</span>
|
<span>بعدی</span>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
@@ -811,7 +531,7 @@ export function WizardPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||||
>
|
>
|
||||||
<span>ذخیره پیشنهاد</span>
|
<span>ذخیره پیشنهاد</span>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
@@ -820,21 +540,21 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
{/* دیالوگ فیلدهای خالی */}
|
||||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="text-[13.5px] leading-7">
|
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||||
{emptyFields.map((f) => (
|
{emptyFields.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.step}
|
key={f.step}
|
||||||
type="button"
|
type="button"
|
||||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
goToStep(f.step);
|
goToStep(f.step);
|
||||||
@@ -861,7 +581,7 @@ export function WizardPage() {
|
|||||||
goToStep(emptyFields[0]?.step ?? 1);
|
goToStep(emptyFields[0]?.step ?? 1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
تکمیل فیلد ها
|
تکمیل فیلدها
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -786,3 +786,12 @@ button:disabled {
|
|||||||
padding: 0.45rem;
|
padding: 0.45rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* اسکرول افقی بدون نوار اسکرول (چیپهای لمسی موبایل) */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
+34
-18
@@ -455,6 +455,7 @@ CREATE TABLE inventory_transactions (
|
|||||||
physical_qty NUMERIC(18,6),
|
physical_qty NUMERIC(18,6),
|
||||||
transfer_pair_id UUID,
|
transfer_pair_id UUID,
|
||||||
performed_by TEXT,
|
performed_by TEXT,
|
||||||
|
delivered_to TEXT,
|
||||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -551,6 +552,35 @@ CREATE TABLE standard_conversions (
|
|||||||
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
|
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Inter-project material transfers
|
||||||
|
CREATE TABLE project_transfers (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
|
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||||||
|
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||||
|
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||||||
|
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||||
|
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
|
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||||||
|
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||||||
|
note TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
|
||||||
|
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
|
||||||
|
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
transferred_by TEXT,
|
||||||
|
received_at TIMESTAMPTZ,
|
||||||
|
received_by TEXT,
|
||||||
|
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
|
||||||
|
receipt_allocations JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_transfers_from ON project_transfers(from_project_id, status);
|
||||||
|
CREATE INDEX idx_project_transfers_to ON project_transfers(to_project_id, status);
|
||||||
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
|
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -579,6 +609,10 @@ CREATE TRIGGER trg_quote_attachments_updated_at
|
|||||||
BEFORE UPDATE ON quote_attachments
|
BEFORE UPDATE ON quote_attachments
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_project_transfers_updated_at
|
||||||
|
BEFORE UPDATE ON project_transfers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -625,22 +659,4 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
details JSONB NOT NULL DEFAULT '{}'
|
details JSONB NOT NULL DEFAULT '{}'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ---------- BALE BOT DRAFTS (quote extraction & review sync) ----------
|
|
||||||
CREATE TABLE IF NOT EXISTS bot_drafts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
chat_id BIGINT NOT NULL,
|
|
||||||
kind VARCHAR(16) NOT NULL,
|
|
||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
missing JSONB,
|
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
||||||
source VARCHAR(16),
|
|
||||||
review_message_id BIGINT,
|
|
||||||
last_bale_update_at TIMESTAMPTZ,
|
|
||||||
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);
|
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"intent": "register_quote",
|
|
||||||
"patch": {
|
|
||||||
"supplier_name": "شرکت فولاد مبارکه",
|
|
||||||
"project_name": "برج آسمان",
|
|
||||||
"project_names": ["برج آسمان"],
|
|
||||||
"is_official": true,
|
|
||||||
"notes": "تحویل پای کارگاه",
|
|
||||||
"expiry_date": null,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"material_name": "میلگرد",
|
|
||||||
"brand_name": null,
|
|
||||||
"specs": ["A3", "سایز ۱۴"],
|
|
||||||
"quantity": 5000,
|
|
||||||
"unit_name": "کیلوگرم",
|
|
||||||
"unit_price": 28500,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"payment_terms": [
|
|
||||||
{
|
|
||||||
"label": "شرایط پرداخت",
|
|
||||||
"milestones": [
|
|
||||||
{"due_date": null, "amount": 50000000},
|
|
||||||
{"due_date": null, "amount": null}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"confidence": 0.95
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import json
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
api_key = "sk-a671eab15be846d494bf0839070a3b32"
|
|
||||||
url = "https://api.deepseek.com/chat/completions"
|
|
||||||
|
|
||||||
prompt = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
|
|
||||||
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
|
|
||||||
{
|
|
||||||
"intent": "register_quote",
|
|
||||||
"patch": {
|
|
||||||
"supplier_name": "نام تأمینکننده",
|
|
||||||
"project_name": "نام پروژه",
|
|
||||||
"project_names": ["نام پروژه"],
|
|
||||||
"is_official": true,
|
|
||||||
"notes": "یادداشتها",
|
|
||||||
"expiry_date": "YYYY-MM-DD",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"material_name": "فقط نام کلی کالا",
|
|
||||||
"brand_name": "نام کارخانه یا برند",
|
|
||||||
"specs": ["مشخصه ۱", "مشخصه ۲"],
|
|
||||||
"quantity": 5000,
|
|
||||||
"unit_name": "کیلوگرم",
|
|
||||||
"unit_price": 28500,
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"payment_terms": [
|
|
||||||
{
|
|
||||||
"label": "شرایط پرداخت",
|
|
||||||
"milestones": [
|
|
||||||
{"due_date": "YYYY-MM-DD", "amount": 50000000}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"confidence": 0.95
|
|
||||||
}
|
|
||||||
قوانین:
|
|
||||||
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
|
|
||||||
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
|
|
||||||
"""
|
|
||||||
|
|
||||||
user_msg = "استعلام قیمت از شرکت فولاد مبارکه برای ۵ تن میلگرد A3 سایز ۱۴ به قیمت هر کیلو ۲۸۵۰۰ تومن، تحویل پای کارگاه برج آسمان، پرداخت در دو مرحله ۵۰ میلیون نقد و مابقی تا آخر ماه"
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"model": "deepseek-chat",
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": prompt},
|
|
||||||
{"role": "user", "content": user_msg}
|
|
||||||
],
|
|
||||||
"response_format": {"type": "json_object"}
|
|
||||||
}
|
|
||||||
|
|
||||||
data = json.dumps(payload).encode("utf-8")
|
|
||||||
req = urllib.request.Request(url, data=data, headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}"
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req)
|
|
||||||
res_data = json.loads(resp.read().decode("utf-8"))
|
|
||||||
content = res_data["choices"][0]["message"]["content"]
|
|
||||||
with open("tests/deepseek_output.json", "w", encoding="utf-8") as out_f:
|
|
||||||
out_f.write(content)
|
|
||||||
print("DEEPSEEK SUCCESS! Saved to tests/deepseek_output.json")
|
|
||||||
print("KEYS IN EXTRACTED JSON:", list(json.loads(content).get("patch", {}).keys()))
|
|
||||||
except Exception as e:
|
|
||||||
print("DEEPSEEK ERROR:", e)
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import json
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Stub external dependencies if not present in host python
|
|
||||||
for mod in ["sqlalchemy", "sqlalchemy.ext", "sqlalchemy.ext.asyncio", "sqlalchemy.orm", "asyncpg", "pydantic", "pydantic_settings", "httpx"]:
|
|
||||||
if mod not in sys.modules:
|
|
||||||
m = types.ModuleType(mod)
|
|
||||||
sys.modules[mod] = m
|
|
||||||
|
|
||||||
sys.modules["pydantic_settings"].BaseSettings = type("BaseSettings", (), {})
|
|
||||||
sys.modules["pydantic_settings"].SettingsConfigDict = lambda **k: None
|
|
||||||
sys.modules["pydantic"].BaseModel = type("BaseModel", (), {})
|
|
||||||
|
|
||||||
# Stub specific symbols needed by app.db and app.models
|
|
||||||
sys.modules["sqlalchemy"].select = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy"].update = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy"].func = types.SimpleNamespace(gen_random_uuid=lambda: None, now=lambda: None)
|
|
||||||
sys.modules["sqlalchemy"].BigInteger = type("BigInteger", (), {})
|
|
||||||
sys.modules["sqlalchemy"].DateTime = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy"].Index = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy"].Integer = type("Integer", (), {})
|
|
||||||
sys.modules["sqlalchemy"].String = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy"].Text = type("Text", (), {})
|
|
||||||
sys.modules["sqlalchemy.dialects.postgresql"] = types.ModuleType("sqlalchemy.dialects.postgresql")
|
|
||||||
sys.modules["sqlalchemy.dialects.postgresql"].JSONB = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy.dialects.postgresql"].UUID = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy.orm"].DeclarativeBase = type("DeclarativeBase", (), {})
|
|
||||||
sys.modules["sqlalchemy.orm"].Mapped = type("Mapped", (), {})
|
|
||||||
sys.modules["sqlalchemy.orm"].mapped_column = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy.ext.asyncio"].create_async_engine = lambda *a, **k: None
|
|
||||||
sys.modules["sqlalchemy.ext.asyncio"].async_sessionmaker = lambda *a, **k: None
|
|
||||||
|
|
||||||
# Add 'ai procurement' to sys.path
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ai procurement"))
|
|
||||||
|
|
||||||
from app.drafts import (
|
|
||||||
new_quote_payload,
|
|
||||||
merge_quote_patch,
|
|
||||||
build_quote_request,
|
|
||||||
compute_quote_missing,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_wizard_conversion():
|
|
||||||
mock_path = Path(__file__).resolve().parents[1] / "tests" / "wizard_mock_data.json"
|
|
||||||
with open(mock_path, encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
# 1. Start with clean payload
|
|
||||||
payload = new_quote_payload()
|
|
||||||
|
|
||||||
# 2. Extract patch from wizard mock data (simulating NLU patch extraction)
|
|
||||||
s1 = data["step1_topic_and_supplier"]
|
|
||||||
s2 = data["step2_line_items"]
|
|
||||||
s3 = data["step3_termsheets"]
|
|
||||||
s4 = data["step4_projects"]
|
|
||||||
s5 = data["step5_review_and_metadata"]
|
|
||||||
|
|
||||||
patch = {
|
|
||||||
"supplier_name": s1["supplier_name"],
|
|
||||||
"is_official": s1["is_official"],
|
|
||||||
"project_names": s4["projects"],
|
|
||||||
"expiry_date": s5["expiry_date"],
|
|
||||||
"notes": s5["notes"],
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"material_name": it["material_name"],
|
|
||||||
"brand_name": it["brand"],
|
|
||||||
"specs": it["specs"],
|
|
||||||
"quantity": it["quantity"],
|
|
||||||
"unit_name": it["unit"],
|
|
||||||
"unit_price": it["unit_price"],
|
|
||||||
}
|
|
||||||
for it in s2
|
|
||||||
],
|
|
||||||
"payment_terms": [
|
|
||||||
{
|
|
||||||
"label": ts["label"],
|
|
||||||
"milestones": ts["milestones"],
|
|
||||||
}
|
|
||||||
for ts in s3
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3. Merge patch into payload
|
|
||||||
changed = merge_quote_patch(payload, patch)
|
|
||||||
assert changed, "Patch should have changed payload"
|
|
||||||
|
|
||||||
# 4. Simulate resolution
|
|
||||||
payload["supplier_id"] = "11111111-1111-1111-1111-111111111111"
|
|
||||||
payload["project_ids"] = ["22222222-2222-2222-2222-222222222222"]
|
|
||||||
for i, it in enumerate(payload["items"], start=1):
|
|
||||||
it["material_id"] = f"33333333-3333-3333-3333-33333333333{i}"
|
|
||||||
it["unit_id"] = 3
|
|
||||||
|
|
||||||
# 5. Build QuoteCreate request
|
|
||||||
body, problems = build_quote_request(payload)
|
|
||||||
assert problems == [], f"Expected no problems, got {problems}"
|
|
||||||
assert body is not None, "Body should not be None"
|
|
||||||
|
|
||||||
assert body["supplier_id"] == "11111111-1111-1111-1111-111111111111"
|
|
||||||
assert body["is_official"] is True
|
|
||||||
assert len(body["line_items"]) == 2
|
|
||||||
assert body["line_items"][0]["temp_id"] == "t1"
|
|
||||||
assert "A3، سایز ۱۴" in body["line_items"][0]["notes"]
|
|
||||||
assert "فولاد مبارکه" in body["line_items"][0]["notes"]
|
|
||||||
|
|
||||||
assert len(body["termsheets"]) == 1
|
|
||||||
assert body["termsheets"][0]["temp_id"] == "p1"
|
|
||||||
assert len(body["termsheets"][0]["schedule_steps"]) == 3
|
|
||||||
assert body["termsheets"][0]["schedule_steps"][0]["step_no"] == 1
|
|
||||||
assert body["termsheets"][0]["schedule_steps"][0]["amount"] == 300000000.0
|
|
||||||
|
|
||||||
assert len(body["item_termsheets"]) == 2
|
|
||||||
assert body["item_termsheets"][0]["line_item_temp_id"] == "t1"
|
|
||||||
assert body["item_termsheets"][0]["termsheet_temp_id"] == "p1"
|
|
||||||
assert body["item_termsheets"][0]["unit_price"] == 48500.0
|
|
||||||
|
|
||||||
print("ALL WIZARD CONVERSION ASSERTIONS PASSED SUCCESSFULLY! [OK]")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
test_wizard_conversion()
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"step1_topic_and_supplier": {
|
|
||||||
"topic": "آهنآلات و مقاطع فولادی",
|
|
||||||
"supplier_name": "فولاد آذرخش",
|
|
||||||
"is_official": true,
|
|
||||||
"new_supplier_details": {
|
|
||||||
"location": "تهران - ونک",
|
|
||||||
"phone": "02188776655",
|
|
||||||
"contact_person": "حسینی"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"step2_line_items": [
|
|
||||||
{
|
|
||||||
"material_name": "میلگرد",
|
|
||||||
"brand": "فولاد مبارکه",
|
|
||||||
"specs": [
|
|
||||||
"A3",
|
|
||||||
"سایز ۱۴"
|
|
||||||
],
|
|
||||||
"quantity": 12500.0,
|
|
||||||
"unit": "کیلوگرم",
|
|
||||||
"unit_price": 48500.0,
|
|
||||||
"total_price": 606250000.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"material_name": "میلگرد",
|
|
||||||
"brand": "فولاد مبارکه",
|
|
||||||
"specs": [
|
|
||||||
"A3",
|
|
||||||
"سایز ۱۶"
|
|
||||||
],
|
|
||||||
"quantity": 8000.0,
|
|
||||||
"unit": "کیلوگرم",
|
|
||||||
"unit_price": 47200.0,
|
|
||||||
"total_price": 377600000.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"step3_termsheets": [
|
|
||||||
{
|
|
||||||
"label": "گزینه ۱",
|
|
||||||
"milestones": [
|
|
||||||
{
|
|
||||||
"due_date": "2026-09-22",
|
|
||||||
"amount": 300000000.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"due_date": "2026-09-28",
|
|
||||||
"amount": 450000000.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"due_date": "2026-10-15",
|
|
||||||
"amount": 233850000.0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"covered_items": [
|
|
||||||
"میلگرد - فولاد مبارکه - A3 - سایز ۱۴",
|
|
||||||
"میلگرد - فولاد مبارکه - A3 - سایز ۱۶"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"step4_projects": {
|
|
||||||
"projects": [
|
|
||||||
"برج آسمان"
|
|
||||||
],
|
|
||||||
"item_project_assignments": [
|
|
||||||
{
|
|
||||||
"item_name": "میلگرد - فولاد مبارکه - A3 - سایز ۱۴",
|
|
||||||
"assigned_projects": [
|
|
||||||
"برج آسمان"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item_name": "میلگرد - فولاد مبارکه - A3 - سایز ۱۶",
|
|
||||||
"assigned_projects": [
|
|
||||||
"برج آسمان"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"step5_review_and_metadata": {
|
|
||||||
"quote_date": "2026-09-19T08:30:00Z",
|
|
||||||
"expiry_date": "2026-10-05",
|
|
||||||
"notes": "تحویل پای کارگاه برج آسمان. کرایه حمل با فروشنده است.",
|
|
||||||
"proforma_file": "پیشفاکتور_فولاد_آذرخش_۱۴۰۳.pdf"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"step1_topic_and_supplier": {
|
|
||||||
"topic": null,
|
|
||||||
"supplier_name": null,
|
|
||||||
"is_official": null,
|
|
||||||
"new_supplier_details": {
|
|
||||||
"location": null,
|
|
||||||
"phone": null,
|
|
||||||
"contact_person": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"step2_line_items": [
|
|
||||||
{
|
|
||||||
"material_name": null,
|
|
||||||
"brand": null,
|
|
||||||
"specs": [],
|
|
||||||
"quantity": null,
|
|
||||||
"unit": null,
|
|
||||||
"unit_price": null,
|
|
||||||
"total_price": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"step3_termsheets": [
|
|
||||||
{
|
|
||||||
"label": null,
|
|
||||||
"milestones": [
|
|
||||||
{
|
|
||||||
"due_date": null,
|
|
||||||
"amount": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"covered_items": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"step4_projects": {
|
|
||||||
"projects": [],
|
|
||||||
"item_project_assignments": [
|
|
||||||
{
|
|
||||||
"item_name": null,
|
|
||||||
"assigned_projects": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"step5_review_and_metadata": {
|
|
||||||
"quote_date": null,
|
|
||||||
"expiry_date": null,
|
|
||||||
"notes": null,
|
|
||||||
"proforma_file": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user