3 Commits
220 changed files with 5789 additions and 35979 deletions
+29 -45
View File
@@ -1,4 +1,3 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO) # Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this Self-contained ship package: everything needed to run the stack is in this
@@ -13,9 +12,7 @@ folder — no external files or sibling folders required.
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik | | **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) | | **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
``` ```
Browser ──► Traefik :80 (edge) Browser ──► Traefik :80 (edge)
│ │
├── Host(localhost) ──► frontend (nginx SPA :80) ├── Host(localhost) ──► frontend (nginx SPA :80)
@@ -28,7 +25,6 @@ Browser ──► Traefik :80 (edge)
│ │
├── postgres :5432 (procurement + casdoor DBs) ├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files) └── minio :9000 (invoice/proforma files)
``` ```
--- ---
@@ -78,7 +74,6 @@ v1.3/
├── Projects/ (projects) ├── Projects/ (projects)
├── Units/ (unit conversions) ├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port) └── Inventory/ (warehouse management panel — React port)
``` ```
--- ---
@@ -91,9 +86,9 @@ the form (username / password) is sent to
(password grant), verifies the returned JWT, and starts the session — (password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor. no browser redirect to Casdoor.
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record - **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»). stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage - **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control, quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses). «کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only. The «انبارها» and «تغییر موجودی» sections are PM-only.
@@ -116,11 +111,11 @@ for production builds).
Run `backend/tools/seed_casdoor.py` once after the first boot to create the Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details — Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist — addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md). are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a > The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see > single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data). > [Restoring the committed seed data](#restoring-the-committed-seed-data).
--- ---
@@ -136,7 +131,7 @@ at these public addresses (HTTPS):
Log in to the app with your Casdoor account (org `inventory`); the role is Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)). [`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel) ### MinIO console on the server (SSH tunnel)
@@ -146,19 +141,20 @@ is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you 1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel): use the panel):
```powershell ```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host> ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
``` ```
You will also land in a normal SSH session — that is fine; the tunnel You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open. lives as long as the window stays open.
2. In your browser, open: **http://localhost:9001** 2. In your browser, open: **http://localhost:9001**
3. Log in with the production MinIO root credentials from the server's 3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are `/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See **never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md). [`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there. ERP stores, browsable and downloadable from there.
@@ -172,11 +168,11 @@ tunnel.
Notes from the server setup: Notes from the server setup:
* The panel is published only on the server's loopback, so the internet - The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser. cannot reach it at all — the tunnel is what carries it to your browser.
* Host port `9000` stays reserved for the deploy-webhook receiver, which is - Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`. why the console uses `9001`.
* If you ever need the raw S3 API from your machine, or a clickable URL for - If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default. tunnel remains the safer default.
@@ -185,9 +181,9 @@ tunnel remains the safer default.
## Prerequisites (target machine) ## Prerequisites (target machine)
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS) - **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`) - ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
* Internet access on first build (image + `npm ci` downloads) - Internet access on first build (image + `npm ci` downloads)
--- ---
@@ -196,7 +192,6 @@ tunnel remains the safer default.
```powershell ```powershell
cd <this-folder> cd <this-folder>
docker compose up -d --build docker compose up -d --build
``` ```
First run downloads base images and builds the frontend (takes a few minutes). First run downloads base images and builds the frontend (takes a few minutes).
@@ -206,7 +201,6 @@ Wait until all services are up:
```powershell ```powershell
docker compose ps docker compose ps
``` ```
All should show `Up` (postgres also `(healthy)`). All should show `Up` (postgres also `(healthy)`).
@@ -231,12 +225,11 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell ```powershell
backend\tools\smoke_test.py # read paths backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py backend\tools\smoke_test_write.py / smoke_test_misc.py
``` ```
> Every API route now requires a Casdoor JWT — a bare > Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design. > `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md). > Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
--- ---
@@ -248,7 +241,7 @@ backend\tools\smoke_test_write.py / smoke_test_misc.py
| Web console | http://localhost:9001 | | Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) | | Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) | | Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` | | Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata. File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -256,7 +249,6 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell ```powershell
.\mc.cmd ls --recursive .\mc.cmd ls --recursive
``` ```
--- ---
@@ -269,12 +261,10 @@ Port: 5433
Database: procurement Database: procurement
Username: postgres Username: postgres
Password: postgres Password: postgres
``` ```
```powershell ```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement docker exec -it procurement-postgres psql -U postgres -d procurement
``` ```
--- ---
@@ -287,24 +277,22 @@ material). Two supported ways to bring a fresh stack up:
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty 1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders: `postgres_data` volume; then run the seeders:
```powershell ```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them) # Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional): # Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed" docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
``` ```
2. **Restore your own dump** — create dumps on the server (kept server-side, 2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git: e.g. `/srv/erp/backups/`), never in git:
```powershell ```powershell
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
``` ```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor` Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this. (see `docs/OPERATIONS.md`) automates this.
@@ -316,44 +304,40 @@ docker compose logs -f api # see logs
docker compose down # stop (keep data) docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
``` ```
--- ---
## Known behavior ## Known behavior
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on - Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login` every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback (401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless (`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production. `PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`, - Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC); `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers. the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor + The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»). grant/revoke «مدیر تدارکات»).
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»: - **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
at least one item with a valid quantity is enough to submit; other items at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z» stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote. badges and a «رسید جزیی» badge on the quote.
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor - **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
(`casdoor`) — the separate casdoor-postgres container is gone (data was (`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`). dumped/restored; see `docs/casdoor-auth.md`).
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only - **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. Production (real domains, serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect): HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars). (see the prod file header for the required env vars).
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume - Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS` (see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup. for the units-dashboard tables and `quote_attachments` at startup.
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction. - Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
* The supplier detail page (`/suppliers/:id`) shows the purchase history with - The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO. invoice/proforma download links from MinIO.
* **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامین‌کننده‌ای برای این متریال ثبت نشده است») if none exist. - If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
* If builds fail with disk-full errors: `docker system prune -a`, then rebuild. - If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`. reachable; check `docker compose ps` and `docker compose logs api`.
```
+8 -76
View File
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.db.engine import AsyncSessionLocal from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User, UserPanel, UserProjectMembership from app.models.procurement import Project, User
from app.utils.errors import ( from app.utils.errors import (
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
@@ -106,10 +106,6 @@ class UserContext:
project_id: object | None # uuid.UUID | None — default (first) project of a PM project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs) project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str display_name: str
# لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...}
panels: dict | None = None
# عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM)
member_project_ids: list | None = None
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
@@ -123,12 +119,10 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
if not casdoor_id: if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization") raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
username = str(claims.get("name") or claims.get("preferred_username") or "")
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None: if user is None:
user = User( user = User(
casdoor_id=casdoor_id, casdoor_id=casdoor_id,
username=username or None,
first_name=str( first_name=str(
claims.get("firstName") claims.get("firstName")
or claims.get("firstname") or claims.get("firstname")
@@ -141,42 +135,17 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
) )
db.add(user) db.add(user)
await db.flush() await db.flush()
elif username and not user.username:
user.username = username
await db.flush()
# لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش) project_ids: list = []
panels: dict = {} if user.role == "pm":
panel_rows = ( projects = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
)
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
await db.execute(
select(UserProjectMembership)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
]
pm_projects = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id == user.id) .where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
pm_project_ids_list = [p.id for p in pm_projects] project_ids = [p.id for p in projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
@@ -186,28 +155,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
project_id=project_id, project_id=project_id,
project_ids=project_ids, project_ids=project_ids,
display_name=display_name, display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
) )
async def pm_project_ids(db: AsyncSession, user: User) -> list: async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed or assigned to the given PM/supervisor user (oldest first).""" """Every project id managed by the given PM user (oldest first)."""
pm_rows = ( rows = (
await db.execute( await db.execute(
select(Project.id) select(Project.id)
.where(Project.pm_user_id == user.id) .where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
member_rows = ( return list(rows)
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None: async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
@@ -257,31 +217,3 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
return _dep return _dep
def require_procurement_writer():
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
(دوره مهاجرت از مدل نقش به مدل پنل)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") == "full":
return ctx
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
return _dep
def allow_quote_create():
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
(محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
return ctx
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
return _dep
+4 -164
View File
@@ -174,7 +174,7 @@ _V26_DDL = [
""", """,
""" """
DO $$ BEGIN DO $$ BEGIN
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor'); CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
EXCEPTION WHEN duplicate_object THEN NULL; END $$ EXCEPTION WHEN duplicate_object THEN NULL; END $$
""", """,
""" """
@@ -247,7 +247,6 @@ _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()
@@ -259,9 +258,6 @@ _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
@@ -384,12 +380,11 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"ON CONFLICT (from_unit, to_unit) DO NOTHING", "ON CONFLICT (from_unit, to_unit) DO NOTHING",
c, c,
) )
# v2.5/v2.6 category decoupling from materials: drop the old supplier-derived # v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
# Category→Material filter cascade (see ensure_dashboard_schema below).
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()") await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
@@ -415,50 +410,6 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)") logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
async def ensure_rbac_schema(engine: AsyncEngine) -> None:
"""Panel-access schema (pm branch): users.username, projects planning
columns, user_panels and user_project_members. Idempotent; safe on any
volume — the RBAC model expects these and login 500s without them."""
stmts = (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE'",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()",
"""
CREATE TABLE IF NOT EXISTS user_panels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
panel TEXT NOT NULL,
level TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_user_panel UNIQUE (user_id, panel)
)
""",
"CREATE INDEX IF NOT EXISTS ix_user_panels_user_id ON user_panels(user_id)",
"""
CREATE TABLE IF NOT EXISTS user_project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_user_project_member UNIQUE (user_id, project_id)
)
""",
"CREATE INDEX IF NOT EXISTS ix_user_project_members_user_id ON user_project_members(user_id)",
"CREATE INDEX IF NOT EXISTS ix_user_project_members_project_id ON user_project_members(project_id)",
)
async with engine.begin() as conn:
for stmt in stmts:
await conn.execute(text(stmt))
logger.info("RBAC schema ensured (username / project columns / user_panels / user_project_members)")
async def ensure_audit_table(engine: AsyncEngine) -> None: async def ensure_audit_table(engine: AsyncEngine) -> None:
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" """v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
ddl = """ ddl = """
@@ -515,114 +466,3 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("bot_drafts table ensured") logger.info("bot_drafts table ensured")
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS 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()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
"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:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("project_transfers table ensured")
# ---------------------------------------------------------------------------
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
# needs a real materials.category_id link. v2.6 had deliberately dropped the
# old supplier-derived column; this is a fresh, topic-backfilled link
# (nullable — a material may stay uncategorized). The rest is B-tree index
# coverage for the aggregation queries in services/dashboard_service.py.
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
# backfill. Every statement is idempotent.
# ---------------------------------------------------------------------------
_DASHBOARD_SCHEMA_DDL = [
# Fresh column (inline FK names the constraint materials_category_id_fkey,
# matching the DO-block guard below for volumes that predate v2.8).
"""
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
""",
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
# groups those under «بدون دسته»).
"""
UPDATE materials SET category_id = mc.id
FROM material_categories mc
WHERE materials.topic = mc.name AND materials.category_id IS NULL
""",
# FK for the (rare) volume that already had the column without it.
"""
DO $$ BEGIN
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
# Drill-down / PM scoping: every dashboard query filters through it.
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
# Daily flow + daily control (date-range aggregation).
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
# Type slices (withdrawal / return / waste) inside a date range.
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
# Funnel per-status counts (existing composite leads with supplier_id).
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
# Project-density workload grouping.
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
# Receipt-level arrival reporting.
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
# «What»-filtered stock scans across warehouses (existing composites lead
# with warehouse_id, useless for a bare material/brand filter).
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
]
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
Statement-per-transaction like ensure_v26: one failure can never block
the rest of the upgrade. Safe to call on every startup.
"""
for ddl in _DASHBOARD_SCHEMA_DDL:
try:
async with engine.begin() as conn:
await conn.execute(text(ddl))
except Exception: # pragma: no cover - pre-existing data conflicts
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
-6
View File
@@ -28,13 +28,10 @@ from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_bot_drafts_table, ensure_bot_drafts_table,
ensure_dashboard_schema,
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_rbac_schema,
ensure_project_transfers_table,
ensure_v26, ensure_v26,
) )
from app.observability import RequestContextMiddleware, setup_logging from app.observability import RequestContextMiddleware, setup_logging
@@ -109,12 +106,9 @@ async def lifespan(_app: FastAPI):
await ensure_material_topic_column(engine) await ensure_material_topic_column(engine)
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_rbac_schema(engine)
await ensure_dashboard_schema(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_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
-3
View File
@@ -32,8 +32,6 @@ from app.models.procurement import (
TermsheetScheduleStep, TermsheetScheduleStep,
) )
from app.models.project_transfer import ProjectTransfer
__all__ = [ __all__ = [
"Base", "Base",
"Unit", "Unit",
@@ -62,5 +60,4 @@ __all__ = [
"ItemDecision", "ItemDecision",
"ItemReceipt", "ItemReceipt",
"QuoteAttachment", "QuoteAttachment",
"ProjectTransfer",
] ]
+1 -7
View File
@@ -130,18 +130,13 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __tablename__ = "materials"
# v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the # v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
# category taxonomy for the dashboard filter cascade (nullable — backfill
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="SET NULL")
)
canonical_unit_id: Mapped[int] = mapped_column( canonical_unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
) )
@@ -152,7 +147,6 @@ class Material(Base):
) )
canonical_unit: Mapped["Unit"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
) )
-1
View File
@@ -156,7 +156,6 @@ 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()
) )
+1 -54
View File
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum( user_role_enum = Enum(
"pm", "procurement_manager", "admin", "supervisor", "pm", "procurement_manager", "admin",
name="user_role", native_enum=True, name="user_role", native_enum=True,
) )
quote_status_enum = Enum( quote_status_enum = Enum(
@@ -58,7 +58,6 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
username: Mapped[str | None] = mapped_column(Text)
first_name: Mapped[str] = mapped_column(Text, nullable=False) first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text) phone: Mapped[str | None] = mapped_column(Text)
@@ -66,47 +65,6 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
class UserPanel(Base):
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
panel: 'procurement' | 'inventory' | 'pm'
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها
(به‌جز تغییر موجودی)
'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛
انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی
"""
__tablename__ = "user_panels"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
panel: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
class UserProjectMembership(Base):
"""user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت
پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند."""
__tablename__ = "user_project_members"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
class Supplier(Base): class Supplier(Base):
__tablename__ = "suppliers" __tablename__ = "suppliers"
@@ -190,23 +148,12 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
start_date: Mapped[date | None] = mapped_column(Date) start_date: Mapped[date | None] = mapped_column(Date)
planned_start: Mapped[date | None] = mapped_column(Date)
planned_end: Mapped[date | None] = mapped_column(Date)
actual_start: Mapped[date | None] = mapped_column(Date)
actual_end: Mapped[date | None] = mapped_column(Date)
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
# v2.6: the single PM responsible for this project (confirms arrivals). # v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column( pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL") ForeignKey("users.id", ondelete="SET NULL")
) )
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Quote(Base): class Quote(Base):
-87
View File
@@ -1,87 +0,0 @@
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
View File
@@ -4,7 +4,6 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
dashboard,
drafts, drafts,
files, files,
inventory, inventory,
@@ -16,7 +15,6 @@ from app.routers import (
material_categories, material_categories,
materials, materials,
pm, pm,
project_transfers,
projects, projects,
quote_events, quote_events,
quotes, quotes,
@@ -53,8 +51,6 @@ ROUTERS = [
standard_conversions.router, standard_conversions.router,
inventory.router, inventory.router,
pm.router, pm.router,
project_transfers.router,
dashboard.router,
users.router, users.router,
drafts.router, drafts.router,
] ]
+3 -16
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec from app.models.procurement import LineItemSpec
@@ -14,7 +13,6 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
_procurement_writer = require_procurement_writer()
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
@@ -71,11 +69,7 @@ async def list_attribute_definitions(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_attribute_definition( async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
payload: AttributeDefinitionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key)) duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None: if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -108,10 +102,7 @@ async def get_attribute_definition(
@router.patch("/{definition_id}") @router.patch("/{definition_id}")
async def update_attribute_definition( async def update_attribute_definition(
definition_id, definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
payload: AttributeDefinitionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ad = await db.get(AttributeDefinition, definition_id) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
@@ -145,11 +136,7 @@ async def update_attribute_definition(
@router.delete("/{definition_id}") @router.delete("/{definition_id}")
async def delete_attribute_definition( async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
definition_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ad = await db.get(AttributeDefinition, definition_id) ad = await db.get(AttributeDefinition, definition_id)
if ad is None: if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد") raise NotFoundError("تعریف ویژگی یافت نشد")
+2 -5
View File
@@ -22,7 +22,7 @@ from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict from app.routers.users import _user_projects, user_to_dict
from app.services import analytics from app.services import analytics
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError from app.utils.errors import ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth") logger = logging.getLogger("procurement.auth")
@@ -78,9 +78,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token) claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db) ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track( await analytics.track(
"login_success", "login_success",
{"username": payload.username, "role": ctx.role}, {"username": payload.username, "role": ctx.role},
@@ -95,6 +92,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": body.get("expires_in"), "expires_in": body.get("expires_in"),
"user": await user_to_dict(ctx.user, projects, db), "user": user_to_dict(ctx.user, projects),
} }
} }
+3 -18
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem from app.models.procurement import LineItem
@@ -13,7 +12,6 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_procurement_writer()
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
@@ -72,11 +70,7 @@ async def list_brand_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_brand_material( async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
payload: BrandMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
if await db.get(Brand, payload.brand_id) is None: if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id") raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None: if await db.get(Material, payload.material_id) is None:
@@ -113,12 +107,7 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
@router.patch("/{bm_id}") @router.patch("/{bm_id}")
async def update_brand_material( async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
bm_id,
payload: BrandMaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -131,11 +120,7 @@ async def update_brand_material(
@router.delete("/{bm_id}") @router.delete("/{bm_id}")
async def delete_brand_material( async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
bm_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id) bm = await db.get(BrandMaterial, bm_id)
if bm is None: if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد") raise NotFoundError("ارتباط برند-متریال یافت نشد")
+3 -18
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -12,7 +11,6 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"]) router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict: def brand_to_dict(brand: Brand) -> dict:
@@ -44,11 +42,7 @@ async def list_brands(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_brand( async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
payload: BrandCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all() rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows): if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -67,12 +61,7 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{brand_id}") @router.patch("/{brand_id}")
async def update_brand( async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
brand_id,
payload: BrandUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
@@ -90,11 +79,7 @@ async def update_brand(
@router.delete("/{brand_id}") @router.delete("/{brand_id}")
async def delete_brand( async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
brand_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
brand = await db.get(Brand, brand_id) brand = await db.get(Brand, brand_id)
if brand is None: if brand is None:
raise NotFoundError("برند یافت نشد") raise NotFoundError("برند یافت نشد")
-212
View File
@@ -1,212 +0,0 @@
"""MVP dashboard router — read-only aggregations for the warehouse +
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
client (frontend/src/lib/api.ts).
Scope (same resolution pattern as the inventory / pm routers):
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
an explicit ?project_id must be one of them.
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
an optional filter.
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
inventory router).
Endpoints:
- GET /dashboard/filters — «What» cascade options
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
- GET /dashboard/inventory/dead-stock — dormant stock table
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
- GET /dashboard/daily-control — today's tickets + performers
- GET /dashboard/procurement/workload — workload per project (density)
Every response is the standard `{"data": …}` envelope.
"""
import uuid
from dataclasses import dataclass
from datetime import date as date_cls
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.services import dashboard_service
from app.utils.errors import (
ForbiddenError,
UnauthorizedError,
ValidationError,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@dataclass
class DashboardScope:
role: str # "pm" | "office"
project_ids: list | None # PM: managed project ids; office: None = all
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
async def dashboard_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژه‌های خودش)"),
db: AsyncSession = Depends(get_db),
) -> DashboardScope:
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
if request.headers.get("authorization"):
user: UserContext | None = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if user.role == "pm":
allowed = list(user.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
if user.role in ("admin", "procurement_manager"):
return DashboardScope(role="office", project_ids=None, project_id=project_id)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager" or role == "admin":
role = "office"
elif role == "pm":
role = "pm"
else:
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
if role == "pm":
raw = request.headers.get("x-project-id")
if not raw:
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
try:
if project_id is None:
project_id = uuid.UUID(str(raw))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
return DashboardScope(role="office", project_ids=None, project_id=project_id)
def _what_filters(
material_id: uuid.UUID | None,
brand_id: uuid.UUID | None,
specs_hash: str | None,
) -> dict:
return {
"material_id": material_id,
"brand_id": brand_id,
"specs_hash": specs_hash,
}
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Options for the global filter bar (Material→Brand→Specs)."""
return await dashboard_service.filter_options(db)
@router.get("/inventory/drilldown")
async def inventory_drilldown(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
return await dashboard_service.inventory_drilldown(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
**_what_filters(material_id, brand_id, specs_hash),
)
@router.get("/inventory/dead-stock")
async def dead_stock(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
limit: int = Query(default=200, ge=1, le=1000),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود."""
return await dashboard_service.dead_stock(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
idle_days=idle_days,
limit=limit,
**_what_filters(material_id, brand_id, specs_hash),
)
@router.get("/inventory/flow")
async def inventory_flow(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"),
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
warehouse_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده."""
today = datetime.now(dashboard_service.TEHRAN).date()
date_from = date_from or (today - timedelta(days=29))
date_to = date_to or (today + timedelta(days=1))
if date_to <= date_from:
raise ValidationError("بازه‌ی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
if (date_to - date_from).days > 180:
raise ValidationError("بازه‌ی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
return await dashboard_service.daily_flow(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
warehouse_id=warehouse_id,
**_what_filters(material_id, brand_id, specs_hash),
)
@router.get("/daily-control")
async def daily_control(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیش‌فرض امروز)"),
warehouse_id: uuid.UUID | None = Query(default=None),
):
"""Daily control: today's outbound/return tickets + exact performer names."""
day = date or datetime.now(dashboard_service.TEHRAN).date()
return await dashboard_service.daily_control(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
day=day,
warehouse_id=warehouse_id,
)
@router.get("/procurement/workload")
async def procurement_workload(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
):
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژه‌ها."""
return await dashboard_service.procurement_workload(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
)
+3 -4
View File
@@ -29,10 +29,9 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"]) router = APIRouter(prefix="/quotes", tags=["files"])
# حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود # حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
# برای هر کاربر واردشده آزاد است. # برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin") _attachment_deleter = require_role("procurement_manager", "admin")
_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = { ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
@@ -126,7 +125,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)]) @router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
@@ -209,7 +208,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction # Proforma — no status restriction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)]) @router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id) await _quote_or_404(db, quote_id)
mime = _validate_upload(file) mime = _validate_upload(file)
+32 -97
View File
@@ -12,7 +12,6 @@ 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
@@ -59,7 +58,6 @@ class Scope:
role: str role: str
project_id: uuid.UUID | None project_id: uuid.UUID | None
actor: str | None actor: str | None
project_ids: list[uuid.UUID] | None = None
async def inventory_scope( async def inventory_scope(
@@ -77,41 +75,25 @@ async def inventory_scope(
if request.headers.get("authorization"): if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db) user: UserContext = await get_optional_user(request, db)
if user is not None: if user is not None:
panels = user.panels or {} if user.role is None:
if user.role is None and not panels: raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است") # v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
# ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی if user.role == "procurement_manager":
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
# لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد if user.role not in ("pm", "admin"):
if "inventory" in panels: raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
if panels["inventory"] == "full": if user.role == "pm":
# کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
# limited: فقط پروژه‌های عضویت + امکان تغییر موجودی
allowed = list(user.member_project_ids or [])
if not allowed:
raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role in ("procurement_manager", "pm"):
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
if user.role == "supervisor":
allowed = list(user.project_ids or []) allowed = list(user.project_ids or [])
if not allowed:
raise ForbiddenError("پروژه تخصیص داده نشده است")
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed) return Scope(role="pm", project_id=project_id, actor=user.display_name)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed) if not allowed:
if user.role == "admin": raise ValidationError(
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
)
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ValidationError("نقش نامعتبر است", field="role")
# Dev fallback: header-based identity (fork's placeholder mode) — only # Dev fallback: header-based identity (fork's placeholder mode) — only
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
@@ -146,9 +128,9 @@ async def inventory_scope(
return Scope(role=role, project_id=project_id, actor=actor) return Scope(role=role, project_id=project_id, actor=actor)
def _require_inventory_modifier(scope: Scope) -> None: def _require_pm(scope: Scope) -> None:
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM): if scope.role != ROLE_PM:
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند") raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -215,7 +197,6 @@ 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,
} }
@@ -234,16 +215,8 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(Warehouse) stmt = select(Warehouse)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name) stmt = stmt.order_by(Warehouse.name)
@@ -275,11 +248,9 @@ async def create_warehouse(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه) # PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM and payload.project_id != scope.project_id:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id")
if payload.project_id not in allowed:
raise ValidationError("شما فقط می‌توانید برای پروژه تخصیص‌یافته به خود انبار بسازید", field="project_id")
if await db.get(Project, payload.project_id) is None: if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description) wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
@@ -296,9 +267,8 @@ async def update_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) if scope.project_id != wh.project_id:
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None: if payload.name is not None:
wh.name = payload.name wh.name = payload.name
@@ -322,9 +292,7 @@ async def delete_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM and scope.project_id != wh.project_id:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
# inventory rows cascade via DDL; transactions are RESTRICTed through # inventory rows cascade via DDL; transactions are RESTRICTed through
# inventory cascade — block deletion if any stock history exists. # inventory cascade — block deletion if any stock history exists.
@@ -355,19 +323,7 @@ async def list_inventory(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
stmt = select(InventoryItem) stmt = select(InventoryItem)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
elif allowed:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id.in_(allowed)
)
else:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id Warehouse.project_id == scope.project_id
) )
@@ -432,15 +388,7 @@ async def list_transactions(
stmt = select(InventoryTransaction).join( stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id) stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
@@ -449,18 +397,9 @@ 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:
# asyncpg تایپ سخت‌گیرانه دارد: رشته باید به datetime آگاه تبدیل شود. stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
# دیتابیس با 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( stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
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)
@@ -515,9 +454,7 @@ async def inventory_history(
if item is None: if item is None:
raise NotFoundError("موجودی یافت نشد") raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id) wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role in (ROLE_PM, "supervisor"): if scope.role == ROLE_PM and wh.project_id != scope.project_id:
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute( txs = (await db.execute(
select(InventoryTransaction) select(InventoryTransaction)
@@ -537,19 +474,17 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_require_inventory_modifier(scope) _require_pm(scope)
# No explicit db.begin(): the session may already be inside an implicit # No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db # transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure. # commits on success / rolls back on failure.
with nullcontext(): with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id) wh = await get_warehouse_or_404(db, payload.warehouse_id)
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) if wh.project_id != scope.project_id:
if scope.role in (ROLE_PM, "supervisor"):
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None: if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if dest.project_id not in allowed: if dest.project_id != scope.project_id:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor) txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record( await audit_record(
+2 -16
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -15,7 +14,6 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"], tags=["item-receipts"],
) )
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -47,13 +45,7 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_receipt( async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
quote_id,
line_item_id,
payload: ReceiptCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id) await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id) decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
@@ -73,13 +65,7 @@ async def create_receipt(
@router.patch("") @router.patch("")
async def update_receipt( async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
quote_id,
line_item_id,
payload: ReceiptUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
await _line_item_or_404(db, quote_id, line_item_id) await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None: if decision is None:
+3 -18
View File
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -24,7 +23,6 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"], tags=["item-termsheets"],
) )
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -60,11 +58,7 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_item_termsheet( async def create_item_termsheet(
quote_id, quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
line_item_id,
payload: ItemTermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
@@ -99,12 +93,7 @@ async def create_item_termsheet(
@router.patch("/{item_termsheet_id}") @router.patch("/{item_termsheet_id}")
async def update_item_termsheet( async def update_item_termsheet(
quote_id, quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
line_item_id,
item_termsheet_id,
payload: ItemTermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
@@ -125,11 +114,7 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}") @router.delete("/{item_termsheet_id}")
async def delete_item_termsheet( async def delete_item_termsheet(
quote_id, quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
line_item_id,
item_termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
it = await db.scalar( it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
+1 -9
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -13,7 +12,6 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -55,13 +53,7 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
@router.put("") @router.put("")
async def put_line_item_specs( async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
quote_id,
line_item_id,
payload: SpecsPutIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement — specs not in the payload are DELETED (P1-19).""" """FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin(): async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
+3 -19
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, Unit from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote from app.models.procurement import ItemDecision, LineItem, Quote
@@ -15,7 +14,6 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -52,12 +50,7 @@ async def list_line_items(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_line_item( async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
quote_id,
payload: LineItemCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -96,11 +89,7 @@ async def get_line_item(
@router.patch("/{line_item_id}") @router.patch("/{line_item_id}")
async def update_line_item( async def update_line_item(
quote_id, quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
line_item_id,
payload: LineItemUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
@@ -142,12 +131,7 @@ async def update_line_item(
@router.delete("/{line_item_id}") @router.delete("/{line_item_id}")
async def delete_line_item( async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
quote_id,
line_item_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id) li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+3 -18
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier from app.models.procurement import Supplier
@@ -13,7 +12,6 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"]) router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict: def category_to_dict(category: MaterialCategory) -> dict:
@@ -48,11 +46,7 @@ async def list_categories(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_category( async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
payload: MaterialCategoryCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None: if duplicate is not None:
raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug")
@@ -76,12 +70,7 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{category_id}") @router.patch("/{category_id}")
async def update_category( async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
category_id,
payload: MaterialCategoryUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
@@ -103,11 +92,7 @@ async def update_category(
@router.delete("/{category_id}") @router.delete("/{category_id}")
async def delete_category( async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
category_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await db.get(MaterialCategory, category_id) category = await db.get(MaterialCategory, category_id)
if category is None: if category is None:
raise NotFoundError("دسته‌بندی یافت نشد") raise NotFoundError("دسته‌بندی یافت نشد")
+7 -36
View File
@@ -9,7 +9,6 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import ( from app.models.catalog import (
AttributeDefinition, AttributeDefinition,
@@ -39,7 +38,6 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"]) router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str): def _coerce_filter_value(data_type: str, raw: str):
@@ -127,10 +125,7 @@ async def list_materials(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_material( async def create_material(
payload: MaterialCreate, payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
expand: str | None = None,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
material = await material_service.create_material(db, payload) material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))} return {"data": await serialize_material(db, material, parse_expand(expand))}
@@ -145,12 +140,7 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
@router.patch("/{material_id}") @router.patch("/{material_id}")
async def update_material( async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
material_id,
payload: MaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -159,11 +149,7 @@ async def update_material(
@router.delete("/{material_id}") @router.delete("/{material_id}")
async def delete_material( async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
@@ -262,10 +248,7 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201) @router.post("/{material_id}/units", status_code=201)
async def create_material_unit( async def create_material_unit(
material_id, material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
payload: MaterialUnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
await _material_or_404(db, material_id) await _material_or_404(db, material_id)
@@ -304,11 +287,7 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}") @router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit( async def update_material_unit(
material_id, material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
mu_id,
payload: MaterialUnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
mu = await db.scalar( mu = await db.scalar(
@@ -340,12 +319,7 @@ async def update_material_unit(
@router.delete("/{material_id}/units/{mu_id}") @router.delete("/{material_id}/units/{mu_id}")
async def delete_material_unit( async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
material_id,
mu_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
mu = await db.scalar( mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
) )
@@ -391,10 +365,7 @@ async def get_material_attribute_values(
@router.put("/{material_id}/attribute-values") @router.put("/{material_id}/attribute-values")
async def put_material_attribute_values( async def put_material_attribute_values(
material_id, material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
payload: MaterialAttributeValuesPut,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
"""FULL replacement of a material's EAV values (empty array deletes all).""" """FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin(): async with db.begin():
+15 -68
View File
@@ -38,14 +38,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
ItemReceipt, ItemReceipt,
ItemReceiptAllocation, ItemReceiptAllocation,
LineItem, LineItem,
LineItemSpec,
Project, Project,
Quote, Quote,
Supplier, Supplier,
@@ -72,17 +71,16 @@ class ArrivalScope:
user_id: uuid.UUID | None user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's active project; admin: optional filter project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope( async def arrivals_scope(
request: Request, request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"), project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ArrivalScope: ) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM / supervisor → scoped to the requested project, which must be one they manage PM → scoped to the requested project, which must be one they manage
(projects.pm_user_id, 1:N); without the param, their first project. (projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter). Admin / procurement manager → all projects (param = optional filter).
""" """
@@ -91,19 +89,19 @@ async def arrivals_scope(
if ctx is not None: if ctx is not None:
if ctx.role is None: if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role in ("pm", "supervisor"): if ctx.role == "pm":
allowed = list(ctx.project_ids or []) allowed = list(ctx.project_ids or [])
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
if not allowed: if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
if ctx.role in ("admin", "procurement_manager"): if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است") raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to # Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor). # admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would # Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM. # let anyone confirm receipts as PM.
@@ -112,10 +110,8 @@ async def arrivals_scope(
role = (request.headers.get("x-user-role") or "").strip().lower() role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager": if role == "procurement_manager":
role = "admin" role = "admin"
elif role == "supervisor":
role = "pm"
if role not in ("pm", "admin"): if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید") raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
# The explicit query param wins; otherwise the dev header carries it. # The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm": if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id") raw_project = request.headers.get("x-project-id")
@@ -183,35 +179,20 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
async def pm_on_the_way( async def pm_on_the_way(
ctx: ArrivalScope = Depends(arrivals_scope), ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
): ):
stmt = ( stmt = (
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand) select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
.join(LineItem, LineItem.id == ItemDecision.line_item_id) .join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id) .join(Material, Material.id == LineItem.material_id)
.join(Supplier, Supplier.id == Quote.supplier_id) .join(Supplier, Supplier.id == Quote.supplier_id)
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if material_id is not None: if ctx.role == "pm" and ctx.project_id is None:
stmt = stmt.where(Material.id == material_id)
if brand_id is not None:
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None: if ctx.project_id is not None:
if ctx.project_id not in allowed: # PM: active project; admin/manager: optional project filter
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -220,29 +201,8 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
# frozen spec snapshot per line item (key = attribute label)
li_ids = [d.LineItem.id for d in decisions]
specs_by_line: dict = {}
if li_ids:
spec_rows = (
await db.execute(
select(LineItemSpec, AttributeDefinition)
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
.where(LineItemSpec.line_item_id.in_(li_ids))
)
).all()
for spec, ad in spec_rows:
value = spec.value_string
if value is None and spec.value_number is not None:
value = str(spec.value_number)
if value is None and spec.value_boolean is not None:
value = str(spec.value_boolean)
if value is None and spec.value_date is not None:
value = spec.value_date.isoformat()
if value is not None:
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
out = [] out = []
for d, li, q, m, sup, proj, bm, br in decisions: for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
out.append({ out.append({
"item_decision_id": str(d.id), "item_decision_id": str(d.id),
@@ -254,8 +214,6 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name}, "material": {"id": str(m.id), "name": m.name},
"brand": {"id": str(br.id), "name": br.name} if br else None,
"specs": specs_by_line.get(li.id, {}),
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
@@ -285,10 +243,6 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id) decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen": if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if decision.project_id not in allowed:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
if ctx.project_id is not None and decision.project_id != ctx.project_id: if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست") raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
@@ -497,17 +451,10 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id) .join(Project, Project.id == ItemDecision.project_id)
) )
if ctx.role == "pm": if ctx.role == "pm" and ctx.project_id is None:
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None: if ctx.project_id is not None:
if ctx.project_id not in allowed: # PM: active project; admin/manager: optional project filter
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows] receipts = [r.ItemReceipt for r in rows]
-206
View File
@@ -1,206 +0,0 @@
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)}
+14 -56
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role from app.auth import UserContext, get_optional_user
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,7 +13,6 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_procurement_writer()
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
@@ -34,12 +33,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = { d = {
"id": str(project.id), "id": str(project.id),
"name": project.name, "name": project.name,
"location": project.location or "", "description": project.description,
"project_manager": project.project_manager or "",
"description": project.description or "",
"start_date": to_date(project.start_date), "start_date": to_date(project.start_date),
"status": project.status or "ACTIVE",
"progress": float(project.progress) if project.progress is not None else 0.0,
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at), "created_at": to_iso(project.created_at),
} }
@@ -47,8 +42,6 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
pm = await db.get(User, project.pm_user_id) pm = await db.get(User, project.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif project.project_manager:
d["pm_name"] = project.project_manager
return d return d
@@ -62,14 +55,7 @@ async def list_projects(
): ):
stmt = select(Project) stmt = select(Project)
if q: if q:
stmt = stmt.where( stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
or_(
Project.name.ilike(f"%{q}%"),
Project.description.ilike(f"%{q}%"),
Project.location.ilike(f"%{q}%"),
Project.project_manager.ilike(f"%{q}%"),
)
)
stmt = sort_query(stmt, Project, sort) stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items) pm_map = await _pm_map(db, items)
@@ -78,38 +64,24 @@ async def list_projects(
d = { d = {
"id": str(p.id), "id": str(p.id),
"name": p.name, "name": p.name,
"location": p.location or "", "description": p.description,
"project_manager": p.project_manager or "",
"description": p.description or "",
"start_date": to_date(p.start_date), "start_date": to_date(p.start_date),
"status": p.status or "ACTIVE",
"progress": float(p.progress) if p.progress is not None else 0.0,
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at), "created_at": to_iso(p.created_at),
} }
pm = pm_map.get(p.pm_user_id) pm = pm_map.get(p.pm_user_id)
if pm: if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
elif p.project_manager:
d["pm_name"] = p.project_manager
data.append(d) data.append(d)
return {"data": data, "meta": meta} return {"data": data, "meta": meta}
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_project( async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
payload: ProjectCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = Project( project = Project(
name=payload.name.strip(), name=payload.name,
location=payload.location.strip() if payload.location else "", description=payload.description,
project_manager=payload.project_manager.strip() if payload.project_manager else "",
description=payload.description.strip() if payload.description else "",
start_date=payload.start_date, start_date=payload.start_date,
planned_start=payload.start_date,
status="ACTIVE",
) )
db.add(project) db.add(project)
await db.flush() await db.flush()
@@ -131,51 +103,37 @@ async def update_project(
request: Request, request: Request,
ctx: UserContext | None = Depends(_optional_ctx), ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
if payload.name is not None: if payload.name is not None:
project.name = payload.name.strip() project.name = payload.name
if payload.location is not None:
project.location = payload.location.strip()
if payload.project_manager is not None:
project.project_manager = payload.project_manager.strip()
if payload.description is not None: if payload.description is not None:
project.description = payload.description.strip() project.description = payload.description
if payload.start_date is not None: if payload.start_date is not None:
project.start_date = payload.start_date project.start_date = payload.start_date
project.planned_start = payload.start_date
if payload.status is not None:
project.status = payload.status
# PM assignment (1:N): one PM per project, a PM may own many projects. # PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel). # Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set: if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager", "pm"): if role not in ("admin", "procurement_manager"):
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است") raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
if payload.pm_user_id is None: if payload.pm_user_id is None:
project.pm_user_id = None project.pm_user_id = None
else: else:
pm_user = await db.get(User, payload.pm_user_id) pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None: if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id") raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role not in ("supervisor", "pm"): if pm_user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id") raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
project.pm_user_id = pm_user.id project.pm_user_id = pm_user.id
if not project.project_manager:
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
await db.flush() await db.flush()
return {"data": await project_to_dict(db, project)} return {"data": await project_to_dict(db, project)}
@router.delete("/{project_id}") @router.delete("/{project_id}")
async def delete_project( async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
project_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
+1 -8
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn from app.schemas.procurement import NoteEventIn
@@ -12,7 +11,6 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_procurement_writer()
@router.get("") @router.get("")
@@ -36,12 +34,7 @@ async def list_events(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_event( async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
quote_id,
payload: NoteEventIn,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
if quote is None: if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد") raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
+10 -65
View File
@@ -8,7 +8,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ( from app.models.procurement import (
@@ -37,15 +36,12 @@ from app.services.expand_service import (
serialize_quote_with_children, serialize_quote_with_children,
serialize_quotes, serialize_quotes,
) )
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError from app.utils.errors import NotFoundError
from app.utils.pagination import sort_query from app.utils.pagination import sort_query
from app.utils.security import get_actor from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"]) router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_procurement_writer()
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
@@ -160,24 +156,7 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_quote( async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
payload: QuoteCreate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
ctx: UserContext = Depends(allow_quote_create()),
_auth=None,
):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor) quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)} return {"data": await serialize_quote_with_children(db, quote)}
@@ -188,11 +167,7 @@ async def create_quote(
@router.patch("/{quote_id}") @router.patch("/{quote_id}")
async def update_quote( async def update_quote(
quote_id, quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
payload: QuoteUpdate,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.update_quote(db, quote_id, payload, actor) quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())} return {"data": await serialize_quote(db, quote, set())}
@@ -200,11 +175,7 @@ async def update_quote(
@router.patch("/{quote_id}/children") @router.patch("/{quote_id}/children")
async def replace_quote_children( async def replace_quote_children(
quote_id, quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
payload: QuoteChildrenReplaceIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
"""Full replacement of a quote's line items + termsheets (edit modal).""" """Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
@@ -217,11 +188,7 @@ async def replace_quote_children(
@router.patch("/{quote_id}/accept") @router.patch("/{quote_id}/accept")
async def accept_quote( async def accept_quote(
quote_id, quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
payload: AcceptQuoteIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.accept_quote(db, quote_id, payload, actor) quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)} return {"data": await _accept_response(db, quote)}
@@ -257,12 +224,7 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
@router.patch("/{quote_id}/arrive") @router.patch("/{quote_id}/arrive")
async def arrive_quote( async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.arrive_quote(db, quote_id, actor) quote = await quote_service.arrive_quote(db, quote_id, actor)
return { return {
"data": { "data": {
@@ -275,11 +237,7 @@ async def arrive_quote(
@router.patch("/{quote_id}/arrive-items") @router.patch("/{quote_id}/arrive-items")
async def arrive_items( async def arrive_items(
quote_id, quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
payload: ArriveItemsIn,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.arrive_items(db, quote_id, payload, actor) quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return { return {
@@ -301,23 +259,14 @@ async def arrive_items(
@router.patch("/{quote_id}/reject") @router.patch("/{quote_id}/reject")
async def reject_quote( async def reject_quote(
quote_id, quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
payload: RejectQuoteIn | None = None,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
): ):
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire") @router.patch("/{quote_id}/expire")
async def expire_quote( async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
quote_id,
db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer),
):
quote = await quote_service.expire_quote(db, quote_id, actor) quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}} return {"data": {"id": str(quote.id), "status": quote.status}}
@@ -327,10 +276,6 @@ async def expire_quote(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.delete("/{quote_id}") @router.delete("/{quote_id}")
async def delete_quote( async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
quote_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
result = await quote_service.delete_quote(db, quote_id) result = await quote_service.delete_quote(db, quote_id)
return {"data": result} return {"data": result}
+3 -20
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -16,7 +15,6 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"], tags=["schedule-steps"],
) )
_procurement_writer = require_procurement_writer()
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
@@ -59,11 +57,7 @@ async def list_schedule_steps(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_schedule_step( async def create_schedule_step(
quote_id, quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
termsheet_id,
payload: ScheduleStepCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no step_no = payload.step_no
@@ -90,12 +84,7 @@ async def create_schedule_step(
@router.patch("/{step_id}") @router.patch("/{step_id}")
async def update_schedule_step( async def update_schedule_step(
quote_id, quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
termsheet_id,
step_id,
payload: ScheduleStepUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
step = await _step_or_404(db, termsheet_id, step_id) step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no: if payload.step_no is not None and payload.step_no != step.step_no:
@@ -120,13 +109,7 @@ async def update_schedule_step(
@router.delete("/{step_id}") @router.delete("/{step_id}")
async def delete_schedule_step( async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
quote_id,
termsheet_id,
step_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
step = await _step_or_404(db, termsheet_id, step_id) step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar( count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where( select(func.count()).select_from(TermsheetScheduleStep).where(
+3 -18
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import StandardConversion from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -12,7 +11,6 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict: def sc_to_dict(sc: StandardConversion) -> dict:
@@ -43,11 +41,7 @@ async def list_standard_conversions(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_standard_conversion( async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
payload: StandardConversionCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar( duplicate = await db.scalar(
select(StandardConversion.id).where( select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit, StandardConversion.from_unit == payload.from_unit,
@@ -73,12 +67,7 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{sc_id}") @router.patch("/{sc_id}")
async def update_standard_conversion( async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
sc_id,
payload: StandardConversionUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -105,11 +94,7 @@ async def update_standard_conversion(
@router.delete("/{sc_id}") @router.delete("/{sc_id}")
async def delete_standard_conversion( async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
sc_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
sc = await db.get(StandardConversion, sc_id) sc = await db.get(StandardConversion, sc_id)
if sc is None: if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد") raise NotFoundError("تبدیل استاندارد یافت نشد")
+25 -113
View File
@@ -2,15 +2,12 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog. GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink. DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — suppliers linked to a material. GET /materials/{id}/suppliers — price-comparison data source.
""" """
from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial from app.models.procurement import Supplier, SupplierMaterial
@@ -21,50 +18,31 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_procurement_writer()
def link_to_dict( def link_to_dict(link: SupplierMaterial, material: Material | None = None,
link: SupplierMaterial,
material: Material | None = None,
category: MaterialCategory | None = None, category: MaterialCategory | None = None,
supplier: Supplier | None = None, supplier: Supplier | None = None) -> dict:
) -> dict:
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
return { return {
"id": str(link.id), "id": str(link.id),
"supplier_id": str(link.supplier_id), "supplier_id": str(link.supplier_id),
"material_id": str(link.material_id), "material_id": str(link.material_id),
"is_active": link.is_active, "is_active": link.is_active,
"last_quoted_price": ( "last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
float(link.last_quoted_price) if link.last_quoted_price is not None else None
),
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None, "last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at), "created_at": to_iso(link.created_at),
"material": ( "material": {
{
"id": str(material.id), "id": str(material.id),
"name": material.name, "name": material.name,
"category": ( "category": {"id": str(category.id), "name": category.name} if category else None,
{"id": str(category.id), "name": category.name} if category else None } if material else None,
), "supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
}
if material
else None
),
"supplier": (
{"id": str(supplier.id), "name": supplier.name} if supplier else None
),
} }
# ---------------------------------------------------------------------------
# GET /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.get("/suppliers/{supplier_id}/materials") @router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials( async def list_supplier_materials(
supplier_id: UUID, supplier_id,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -78,110 +56,44 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at) .order_by(SupplierMaterial.created_at)
) )
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = [lnk.material_id for lnk in items] mat_ids = {l.material_id for l in items}
materials: dict = {} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
if mat_ids: return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
materials = {m.id: m for m in result.scalars()}
return {
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
"meta": meta,
}
# ---------------------------------------------------------------------------
# POST /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material( async def create_supplier_material(
supplier_id: UUID, supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id) link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id) material = await db.get(Material, payload.material_id)
return {"data": link_to_dict(link, material)} return {"data": link_to_dict(link, material, None)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material( async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
supplier_id: UUID,
material_id: UUID,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id) await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
# --------------------------------------------------------------------------- @router.get("/materials/{material_id}/suppliers")
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers( async def list_material_suppliers(
material_id: UUID, material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Return the suppliers linked to a material, straight from PostgreSQL.
The path parameter is typed as UUID so FastAPI validates and coerces it
before the handler runs; a malformed id is rejected with 422, an unknown
material with 404.
Atomic JOIN — a supplier is returned only through its supplier_materials
link, so the response can never contain a supplier that is not actually
linked to this material:
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
Response envelope (frontend MaterialSupplier binds these fields directly):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
]
}
"""
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
stmt = ( stmt = (
select(Supplier) select(SupplierMaterial)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id) .where(SupplierMaterial.material_id == material_id)
.order_by(Supplier.name) .order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
) )
suppliers = (await db.execute(stmt)).scalars().all() items, meta = await paginate(stmt, page, page_size, db)
sup_ids = {l.supplier_id for l in items}
return { suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
"data": [ return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
{
"id": str(supplier.id),
"name": supplier.name,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"location": supplier.location,
}
for supplier in suppliers
]
}
+3 -18
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier from app.models.procurement import Quote, Supplier
@@ -13,7 +12,6 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"]) router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -73,11 +71,7 @@ async def list_suppliers(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_supplier( async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
payload: SupplierCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
category = await _category_or_404(db, payload.category_id) category = await _category_or_404(db, payload.category_id)
supplier = Supplier( supplier = Supplier(
name=payload.name, name=payload.name,
@@ -102,12 +96,7 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{supplier_id}") @router.patch("/{supplier_id}")
async def update_supplier( async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
supplier_id,
payload: SupplierUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
@@ -131,11 +120,7 @@ async def update_supplier(
@router.delete("/{supplier_id}") @router.delete("/{supplier_id}")
async def delete_supplier( async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
supplier_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
supplier = await db.get(Supplier, supplier_id) supplier = await db.get(Supplier, supplier_id)
if supplier is None: if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد") raise NotFoundError("تأمین‌کننده یافت نشد")
+4 -24
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -27,7 +26,6 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -82,12 +80,7 @@ async def list_termsheets(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_termsheet( async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
quote_id,
payload: TermsheetCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin(): async with db.begin():
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
@@ -117,11 +110,7 @@ async def get_termsheet(
@router.patch("/{termsheet_id}") @router.patch("/{termsheet_id}")
async def update_termsheet( async def update_termsheet(
quote_id, quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
termsheet_id,
payload: TermsheetUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered": if ts.status != "offered":
@@ -138,11 +127,7 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/status") @router.patch("/{termsheet_id}/status")
async def update_termsheet_status( async def update_termsheet_status(
quote_id, quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
termsheet_id,
payload: TermsheetStatusUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
): ):
async with db.begin(): async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -154,12 +139,7 @@ async def update_termsheet_status(
@router.delete("/{termsheet_id}") @router.delete("/{termsheet_id}")
async def delete_termsheet( async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
quote_id,
termsheet_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id) ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id) quote = await _quote_or_404(db, quote_id)
if quote.status != "offered": if quote.status != "offered":
+3 -18
View File
@@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec from app.models.procurement import LineItem, LineItemSpec
@@ -12,7 +11,6 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"]) router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict: def unit_full_to_dict(unit: Unit) -> dict:
@@ -47,11 +45,7 @@ async def list_units(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create_unit( async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
payload: UnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None: if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -76,12 +70,7 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@router.patch("/{unit_id}") @router.patch("/{unit_id}")
async def update_unit( async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
unit_id: int,
payload: UnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
@@ -103,11 +92,7 @@ async def update_unit(
@router.delete("/{unit_id}") @router.delete("/{unit_id}")
async def delete_unit( async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
unit_id: int,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
unit = await db.get(Unit, unit_id) unit = await db.get(Unit, unit_id)
if unit is None: if unit is None:
raise NotFoundError("واحد یافت نشد") raise NotFoundError("واحد یافت نشد")
+31 -186
View File
@@ -17,21 +17,15 @@ import uuid
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import delete, select, update, text, or_ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record from app.utils.audit import record as audit_record
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, User, UserPanel, UserProjectMembership from app.models.procurement import Project, User
from app.schemas.procurement import ( from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import ( from app.utils.errors import (
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
@@ -47,47 +41,17 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه", "pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات", "procurement_manager": "مدیر تدارکات",
"admin": "ادمین", "admin": "ادمین",
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
"supervisor": "سرپرست پروژه",
} }
PANEL_LEVELS = ("full", "limited") def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
PANELS = ("procurement", "inventory", "pm")
async def _panels_of(db: AsyncSession, user_id) -> dict:
"""panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها)."""
rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
)
).scalars().all()
return {row.panel: row.level for row in rows}
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
"""عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته."""
rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user_id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return [str(pid) for pid in rows]
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
"""Serialize a user. `projects` = every project the PM manages (1:N); """Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility. project_id/project_name stay as the first (oldest) one for compatibility."""
با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند."""
projects = projects or [] projects = projects or []
first = projects[0] if projects else None first = projects[0] if projects else None
out = { return {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
"username": user.username,
"first_name": user.first_name, "first_name": user.first_name,
"last_name": user.last_name, "last_name": user.last_name,
"phone": user.phone, "phone": user.phone,
@@ -98,39 +62,23 @@ async def user_to_dict(user: User, projects: list[Project] | None = None, db: As
"projects": [{"id": str(p.id), "name": p.name} for p in projects], "projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at), "created_at": to_iso(user.created_at),
} }
if db is not None:
out["panels"] = await _panels_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
"""Admin, procurement manager, or project manager — organization management.""" """Admin OR procurement manager (the same person) — organization management."""
if ctx.role not in ("admin", "procurement_manager", "pm"): if ctx.role not in ("admin", "procurement_manager"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]: async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project assigned to the user (supervisor or PM) (oldest first). """Every project the user manages as PM (oldest first; [] for non-PMs)."""
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership). if user.role != "pm":
"""
if user.role not in ("supervisor", "pm"):
return [] return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = ( rows = (
await db.execute( await db.execute(
select(Project) select(Project)
.where( .where(Project.pm_user_id == user.id)
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
@@ -179,7 +127,7 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me") @router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
return {"data": await user_to_dict(ctx.user, projects, db)} return {"data": user_to_dict(ctx.user, projects)}
@router.get("/me/projects") @router.get("/me/projects")
@@ -190,18 +138,6 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
active project against this list and falls back gracefully when a active project against this list and falls back gracefully when a
project is revoked.""" project is revoked."""
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
# عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود
member_ids = set(ctx.member_project_ids or [])
have = {p.id for p in projects}
if member_ids:
extra = (
await db.execute(
select(Project)
.where(Project.id.in_(member_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
projects = list(projects) + [p for p in extra if p.id not in have]
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@@ -212,20 +148,20 @@ async def list_users(
): ):
"""Every user (with role + assigned projects) — Organization panel.""" """Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} pm_ids = {u.id for u in users if u.role == "pm"}
by_user: dict = {} by_user: dict = {}
if supervisor_ids: if pm_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(supervisor_ids)) .where(Project.pm_user_id.in_(pm_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users], "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
"meta": {"total": len(users)}, "meta": {"total": len(users)},
} }
@@ -251,30 +187,30 @@ async def create_user(
db.add(user) db.add(user)
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
return {"data": await user_to_dict(user)} return {"data": user_to_dict(user)}
@router.patch("/{user_id}") @router.patch("/{user_id}")
async def update_user_role( async def update_user_role(
user_id, user_id,
payload: UserRoleUpdate, payload: UserRoleUpdate,
ctx: UserContext = Depends(_org_manager), ctx: UserContext = Depends(require_role("admin")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Set/clear a role — organization management (admin, procurement manager, PM).""" """Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
if payload.role == "admin" and ctx.role != "admin": assign or elevate roles. Revoking (or switching away from «pm») also
raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند") unassigns the user from their project."""
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
previous_role = user.role previous_role = user.role
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"): if user.role == "pm" and payload.role != "pm":
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
user.role = payload.role user.role = payload.role
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
projects = await _user_projects(db, user) projects = await _user_projects(db, user)
return {"data": await user_to_dict(user, projects)} return {"data": user_to_dict(user, projects)}
@router.post("/sync") @router.post("/sync")
@@ -295,15 +231,8 @@ async def sync_casdoor_users(
continue continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid)) existing = await db.scalar(select(User).where(User.casdoor_id == cid))
if existing is None: if existing is None:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
by_uname = await db.scalar(select(User).where(User.username == uname))
if by_uname is not None:
by_uname.casdoor_id = cid
continue
db.add(User( db.add(User(
casdoor_id=cid, casdoor_id=cid,
username=uname,
first_name=str( first_name=str(
cu.get("firstName") cu.get("firstName")
or cu.get("displayName") or cu.get("displayName")
@@ -314,27 +243,23 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None, phone=str(cu.get("phone") or "") or None,
)) ))
created += 1 created += 1
elif not existing.username:
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
if uname:
existing.username = uname
await db.flush() await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} pm_ids = {u.id for u in users if u.role == "pm"}
by_user: dict = {} by_user: dict = {}
if supervisor_ids: if pm_ids:
projs = ( projs = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id.in_(supervisor_ids)) .where(Project.pm_user_id.in_(pm_ids))
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users], "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
"meta": {"total": len(users), "created": created}, "meta": {"total": len(users), "created": created},
} }
@@ -357,97 +282,17 @@ async def assign_user_project(
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
if user.role not in ("supervisor", "pm"): if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role") raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
if payload.project_id is None: if payload.project_id is None:
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
if user.username:
await db.execute(
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
{"uname": user.username}
)
await db.flush() await db.flush()
return {"data": await user_to_dict(user)} return {"data": user_to_dict(user)}
project = await db.get(Project, payload.project_id) project = await db.get(Project, payload.project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id project.pm_user_id = user.id
if user.username:
await db.execute(
text("""
INSERT INTO project_assignments (id, user_id, project_id)
SELECT gen_random_uuid(), pu.id, :pid
FROM pm_users pu
WHERE pu.username = :uname
AND NOT EXISTS (
SELECT 1 FROM project_assignments pa
WHERE pa.user_id = pu.id AND pa.project_id = :pid
)
"""),
{"pid": project.id, "uname": user.username}
)
await db.flush() await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user))} return {"data": user_to_dict(user, await _user_projects(db, user))}
# ---------------------------------------------------------------------------
# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
# ---------------------------------------------------------------------------
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
for panel, level in payload.panels.items():
if panel not in PANELS:
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
if level not in PANEL_LEVELS:
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
for panel, level in payload.panels.items():
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
wanted = list(dict.fromkeys(payload.project_ids))
if wanted:
found = (
await db.execute(select(Project).where(Project.id.in_(wanted)))
).scalars().all()
found_ids = {p.id for p in found}
unknown = [str(pid) for pid in wanted if pid not in found_ids]
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await db.execute(
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
)
for pid in wanted:
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
-1
View File
@@ -64,7 +64,6 @@ 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")
+2 -26
View File
@@ -44,19 +44,14 @@ class SupplierMaterialCreate(APIModel):
class ProjectCreate(APIModel): class ProjectCreate(APIModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
class ProjectUpdate(APIModel): class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1) name: str | None = Field(default=None, min_length=1)
location: str | None = None
project_manager: str | None = None
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
status: str | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects). # PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM. # Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None pm_user_id: UUID | None = None
@@ -140,11 +135,6 @@ 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"]
@@ -152,7 +142,6 @@ 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):
@@ -214,13 +203,13 @@ class UserCreate(APIModel):
last_name: str = "" last_name: str = ""
phone: str | None = None phone: str | None = None
casdoor_id: str | None = None casdoor_id: str | None = None
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserRoleUpdate(APIModel): class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment).""" """role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None role: Literal["pm", "procurement_manager", "admin"] | None = None
class UserProjectUpdate(APIModel): class UserProjectUpdate(APIModel):
@@ -352,16 +341,3 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0) received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None on_time: bool | None = None
quality_note: str | None = None quality_note: str | None = None
class UserPanelsUpdate(APIModel):
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود."""
panels: dict[str, str] = Field(default_factory=dict)
class UserPanelProjectsUpdate(APIModel):
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
project_ids: list[UUID] = Field(default_factory=list)
-74
View File
@@ -1,74 +0,0 @@
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
-732
View File
@@ -1,732 +0,0 @@
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
Read-only service. Every function receives the resolved scope (PM → the list
of projects they manage; office roles → project_ids=None = whole
organization) plus an optional explicit project filter, and the optional
«What» filters (material_id / brand_id / specs_hash).
Quantities are NEVER mixed across units: inventory rows live in the
material's canonical unit, so stock aggregates carry their unit code and the
UI picks one unit at a time. Numeric results leave the service chart-ready
(floats); identifiers and labels are plain strings.
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
exactly — this module is the server side of that contract.
"""
import uuid
from datetime import date as date_cls
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.catalog import Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
LineItem,
Project,
Quote,
QuoteProjects,
)
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
# daily widgets means today at this offset.
TEHRAN = timezone(timedelta(hours=3, minutes=30))
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
# separately so the UI can stack it as a distinct red segment inside the
# outbound bar. Transfers are excluded from the buckets (they are internal
# moves) but DO count toward the running balance.
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
"""«What» filters for a statement that already joins inventory→material."""
if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id)
if brand_id is not None:
stmt = stmt.where(InventoryItem.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
return stmt
def _scope_filters(stmt, *, project_ids, project_id):
"""warehouse.project_id restriction for the resolved dashboard scope."""
if project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
elif project_ids is not None:
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
return stmt
def _qty(value) -> float:
return round(float(value), 4) if value is not None else 0.0
# ---------------------------------------------------------------------------
# «What» filter cascade — Category → Material → Brand → Specs
# ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict:
"""Options for the global filter bar (Material → Brand → Specs).
Materials come from the catalog (active only). Brand options per material
are the UNION of the catalog links (brand_materials — every brand defined
for that material) and the distinct brands actually present in stock, so
the cascade is complete even for materials whose current stock rows carry
no brand yet. Spec options come from stock rows (inventory.specs_hash),
since specs are a stock-level dimension.
"""
materials = (
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
).scalars().all()
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
inv_rows = (
await db.execute(
select(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Brand.name,
)
.select_from(InventoryItem)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.distinct()
)
).all()
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
catalog_brand_rows = (
await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.select_from(BrandMaterial)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.is_active.is_(True))
)
).all()
brands_by_material: dict[uuid.UUID, dict] = {}
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
for material_id, brand_id, brand_name in catalog_brand_rows:
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
if brand_id is not None:
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
if specs_hash:
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
def material_entry(m: Material) -> dict:
unit = units.get(m.canonical_unit_id)
brands = brands_by_material.get(m.id, {})
spec_map = specs_by_material.get(m.id, {})
return {
"id": str(m.id),
"name": m.name,
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
"brands": [
{"id": str(bid), "name": bname}
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
],
"specs": [
{"hash": h, "specs": sp}
for h, sp in sorted(spec_map.items())
],
}
# فهرست تخت متریال‌ها — سرآغاز آبشار سه‌فیلتری (متریال → برند → مشخصات)
return {"data": {"materials": [material_entry(m) for m in materials]}}
# ---------------------------------------------------------------------------
# Widget 1 — Drill-down matrix (organization → project → warehouse)
# ---------------------------------------------------------------------------
async def inventory_drilldown(
db: AsyncSession,
*,
project_ids,
project_id,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
warehouse, unit) stock aggregation. Active catalog materials without any
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
(labelled with their canonical unit) instead of disappearing in an inner
join.
"""
# تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
# راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
stock = (
select(
InventoryItem.material_id.label("material_id"),
InventoryItem.brand_id.label("brand_id"),
InventoryItem.specs_hash.label("specs_hash"),
InventoryItem.specs.label("specs"),
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code.label("ucode"),
Unit.name_fa.label("uname_fa"),
func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.group_by(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
)
)
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
stock_sq = stock.subquery()
# واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد»)
canon_unit = aliased(Unit)
stmt = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
stock_sq.c.specs,
stock_sq.c.pid,
stock_sq.c.pname,
stock_sq.c.wid,
stock_sq.c.wname,
stock_sq.c.ucode,
stock_sq.c.uname_fa,
func.coalesce(stock_sq.c.qty, 0).label("qty"),
func.coalesce(stock_sq.c.rows, 0).label("rows"),
canon_unit.code.label("canon_ucode"),
canon_unit.name_fa.label("canon_uname_fa"),
)
.select_from(Material)
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
.where(Material.status == "active")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
# موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده)
if brand_id is not None:
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {}
total_rows = 0
materials_tree: dict[str, dict] = {}
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
q = _qty(qty)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
units_legend.setdefault(canon_ucode, canon_uname_fa)
total_by_unit.setdefault(canon_ucode, 0.0)
total_rows += row_count
# Build full material name
parts = [mname]
if bname:
parts.append(bname)
if specs:
parts.extend(str(v) for v in specs.values() if v)
full_name = " ".join(parts)
# Use full_name + unit as unique key for material level, or just full_name
mat_key = f"{mid}_{full_name}"
mat = materials_tree.setdefault(
mat_key,
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
)
if ucode is not None:
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
mat["rows"] += row_count
# مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار)
if pid is None:
continue
proj = mat["_projects"].setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
)
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count
materials = []
for mat in materials_tree.values():
projects_list = []
for proj in mat.pop("_projects").values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects_list.append({**proj, "warehouses": warehouses})
projects_list.sort(key=lambda p: p["name"])
materials.append({**mat, "projects": projects_list})
materials.sort(key=lambda m: m["name"])
return {
"data": {
"units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"materials": materials,
}
}
# ---------------------------------------------------------------------------
# Widget 2 — Dead stock (dormant capital)
# ---------------------------------------------------------------------------
async def dead_stock(
db: AsyncSession,
*,
project_ids,
project_id,
idle_days: int = 60,
material_id=None,
brand_id=None,
specs_hash=None,
limit: int = 200,
) -> dict:
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
Last activity = MAX(inventory_transactions.transaction_date) per row —
served by idx_inventory_tx_inventory_date — falling back to
inventory.updated_at for rows that never posted a transaction.
"""
last_tx = (
select(func.max(InventoryTransaction.transaction_date))
.where(InventoryTransaction.inventory_id == InventoryItem.id)
.correlate(InventoryItem)
.scalar_subquery()
)
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
stmt = (
select(
InventoryItem.id,
InventoryItem.quantity,
InventoryItem.specs,
Material.name,
Brand.name,
Warehouse.name,
Project.name,
Unit.code,
Unit.name_fa,
last_activity.label("last_activity"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
.order_by(last_activity.asc())
.limit(limit)
)
stmt = _stock_filters(
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
rows = (await db.execute(stmt)).all()
now = datetime.now(TEHRAN)
items = []
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
items.append({
"inventory_id": str(inv_id),
"material": mat_name,
"brand": brand_name,
"specs": specs or {},
"warehouse": wh_name,
"project": proj_name,
"quantity": _qty(quantity),
"unit": {"code": ucode, "name_fa": uname_fa},
"last_activity": last.isoformat() if last else None,
"idle_days": (now - last).days if last else None,
})
return {"data": {"idle_days_threshold": idle_days, "items": items}}
# ---------------------------------------------------------------------------
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
# ---------------------------------------------------------------------------
async def daily_flow(
db: AsyncSession,
*,
project_ids,
project_id,
date_from: datetime,
date_to: datetime,
warehouse_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Per-day buckets from the signed ledger.
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
balance = running sum of EVERY signed movement (transfers/found/stock
counts included), seeded with the opening balance before `date_from`.
"""
def _with_joins(sel):
return (
sel.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
what_conditions = []
if material_id is not None:
what_conditions.append(InventoryItem.material_id == material_id)
if brand_id is not None:
what_conditions.append(InventoryItem.brand_id == brand_id)
if specs_hash:
what_conditions.append(InventoryItem.specs_hash == specs_hash)
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
bucket_rows = (
await db.execute(
_with_joins(
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity))
.where(
InventoryTransaction.transaction_date >= date_from,
InventoryTransaction.transaction_date < date_to,
*scope_conditions,
*what_conditions,
)
.group_by(day_expr, InventoryTransaction.transaction_type)
)
)
).all()
opening = _qty(
await db.scalar(
_with_joins(
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
InventoryTransaction.transaction_date < date_from,
*scope_conditions,
*what_conditions,
)
)
)
)
signed_by_day: dict[date_cls, dict[str, float]] = {}
for day, tx_type, signed in bucket_rows:
day_key = day.date() if isinstance(day, datetime) else day
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
days = []
running = opening
current = date_from.date()
while current < date_to.date():
types = signed_by_day.get(current, {})
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
running = round(running + sum(types.values()), 4)
days.append({
"date": current.isoformat(),
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
"outbound": outbound,
"waste": waste,
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
"balance": running,
})
current += timedelta(days=1)
return {"data": {"opening_balance": opening, "days": days}}
# ---------------------------------------------------------------------------
# Widget 4 — Daily control (today's site operations)
# ---------------------------------------------------------------------------
async def daily_control(
db: AsyncSession,
*,
project_ids,
project_id,
day: date_cls,
warehouse_id=None,
) -> dict:
"""One day (Tehran calendar): outbound / return / waste / purchase ticket
counts + quantities, the exact performer names, and the operation detail."""
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
day_end = day_start + timedelta(days=1)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
count_rows = (
await db.execute(
select(
InventoryTransaction.transaction_type,
func.count().label("tickets"),
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
*scope_conditions,
)
.group_by(InventoryTransaction.transaction_type)
)
).all()
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
performers_rows = (
await db.execute(
select(
InventoryTransaction.performed_by,
func.count().label("tickets"),
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
*scope_conditions,
)
.group_by(InventoryTransaction.performed_by)
.order_by(func.count().desc())
)
).all()
detail_rows = (
await db.execute(
select(
InventoryTransaction,
Warehouse.name,
Material.name,
InventoryItem.specs,
Unit.code,
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
InventoryTransaction.transaction_type.in_(
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
),
*scope_conditions,
)
.order_by(InventoryTransaction.transaction_date.desc())
.limit(200)
)
).all()
zero = {"tickets": 0, "qty": 0.0}
items = []
for tx, wh_name, mat_name, specs, ucode in detail_rows:
items.append({
"id": str(tx.id),
"transaction_type": str(tx.transaction_type),
"quantity": _qty(tx.quantity),
"unit_code": ucode,
"performed_by": tx.performed_by,
"note": tx.note,
"warehouse": wh_name,
"material": mat_name,
"specs": specs or {},
"transaction_date": tx.transaction_date.isoformat(),
})
return {
"data": {
"date": day.isoformat(),
"outbound": by_type.get("WITHDRAWAL", zero),
"returns": by_type.get("RETURN", zero),
"waste": {
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
},
"purchases": by_type.get("PURCHASE", zero),
"performers": [
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
for name, tickets, qty in performers_rows
],
"items": items,
}
}
# ---------------------------------------------------------------------------
# Widget 8 — Procurement workload per project (project density)
# ---------------------------------------------------------------------------
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
"""Per project: offered quotes still open + chosen purchase lines (open
vs fully received). Chosen lines attribute to item_decisions.project_id
(the purchased destination); offered quotes to any linked project."""
received_sq = (
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
.group_by(ItemReceipt.item_decision_id)
.subquery()
)
order_conditions = [ItemDecision.status == "chosen"]
if project_id is not None:
order_conditions.append(ItemDecision.project_id == project_id)
elif project_ids is not None:
order_conditions.append(ItemDecision.project_id.in_(project_ids))
order_rows = (
await db.execute(
select(
Project.id,
Project.name,
func.count().label("chosen_items"),
func.sum(LineItem.quantity).label("ordered_qty"),
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
func.sum(
case(
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
else_=0,
)
).label("open_items"),
)
.select_from(ItemDecision)
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id)
.outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
.where(*order_conditions)
.group_by(Project.id, Project.name)
)
).all()
offered_by_project: dict[uuid.UUID | None, set] = {}
offered_primary = (
await db.execute(
select(Quote.id, Quote.project_id).where(
Quote.status == "offered", Quote.project_id.is_not(None)
)
)
).all()
for quote_id, pid in offered_primary:
offered_by_project.setdefault(pid, set()).add(quote_id)
offered_junction = (
await db.execute(
select(QuoteProjects.quote_id, QuoteProjects.project_id)
.join(Quote, Quote.id == QuoteProjects.quote_id)
.where(Quote.status == "offered")
)
).all()
for quote_id, pid in offered_junction:
offered_by_project.setdefault(pid, set()).add(quote_id)
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
names: dict[uuid.UUID, str] = {}
if project_ids_needed:
names = {
p.id: p.name
for p in (
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
).scalars()
}
projects = []
for pid, _pname, chosen, ordered, received, open_items in order_rows:
if pid is None:
offered = 0
else:
offered = len(offered_by_project.pop(pid, set()))
if project_id is not None and pid != project_id:
offered = 0
elif project_ids is not None and pid not in project_ids:
offered = 0
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": offered,
"chosen_items": chosen,
"open_orders": open_items,
"ordered_qty": _qty(ordered),
"received_qty": _qty(received),
})
# offered-only projects that have no chosen lines yet
for pid, quote_ids in offered_by_project.items():
if not quote_ids:
continue
if project_id is not None and pid != project_id:
continue
if project_ids is not None and (pid is None or pid not in project_ids):
continue
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": len(quote_ids),
"chosen_items": 0,
"open_orders": 0,
"ordered_qty": 0.0,
"received_qty": 0.0,
})
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
return {"data": {"projects": projects}}
@@ -290,7 +290,6 @@ 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,
) )
@@ -320,7 +319,6 @@ 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,
) )
@@ -1,471 +0,0 @@
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())
-213
View File
@@ -7,7 +7,6 @@ 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
@@ -19,7 +18,6 @@ from app.models.procurement import (
ItemTermsheet, ItemTermsheet,
LineItem, LineItem,
LineItemProjects, LineItemProjects,
LineItemSpec,
Project, Project,
Quote, Quote,
QuoteEvent, QuoteEvent,
@@ -420,78 +418,6 @@ 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
@@ -528,8 +454,6 @@ 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
@@ -633,125 +557,6 @@ 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,
@@ -805,8 +610,6 @@ 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:
@@ -1100,17 +903,6 @@ async def quote_stats(
*conditions, *conditions,
) )
) )
# Procurement funnel (cumulative, not the current-state counts above):
# a quote counts as accepted/arrived if it EVER reached that stage.
funnel_row = (
await db.execute(
select(
func.count().label("offered_total"),
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
).where(*conditions)
)
).one()
return { return {
"total": sum(counts.values()), "total": sum(counts.values()),
"offered": counts["offered"], "offered": counts["offered"],
@@ -1119,11 +911,6 @@ async def quote_stats(
"rejected": counts["rejected"], "rejected": counts["rejected"],
"expired": counts["expired"], "expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None, "average_lead_time_days": int(round(float(avg))) if avg is not None else None,
"funnel": {
"offered_total": funnel_row.offered_total,
"accepted_total": funnel_row.accepted_total,
"arrived_total": funnel_row.arrived_total,
},
} }
-84
View File
@@ -128,7 +128,6 @@ 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"),
@@ -262,11 +261,8 @@ async def run() -> None:
material_by_name: dict[str, Material] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
name=name, name=name,
topic=cat.name,
category_id=cat.id, # v2.8: dashboard Category→Material cascade
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
) )
@@ -444,86 +440,6 @@ async def run() -> None:
)) ))
await db.flush() await db.flush()
# ---- MVP dashboard demo ledger -----------------------------------
# Multi-day history so every widget has real data to show:
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
# - withdrawals/returns/waste over the last ~12 days → flow series
# - tickets dated TODAY (performed_by names) → daily control
# Quantities are hand-checked to keep every row >= 0.
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
mat = material_by_name[MATERIALS[mat_idx][0]]
wh = warehouse_rows[project_idx][wh_idx]
inv = await db.scalar(select(InventoryItem).where(
InventoryItem.warehouse_id == wh.id,
InventoryItem.material_id == mat.id,
InventoryItem.specs_hash == "default",
))
if inv is None:
inv = InventoryItem(
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
specs={}, specs_hash="default",
)
db.add(inv)
await db.flush()
return inv
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
days_ago: int, performed_by: str, note: str | None = None) -> None:
inv.quantity += Decimal(str(qty))
db.add(InventoryTransaction(
inventory_id=inv.id,
warehouse_id=wh.id,
transaction_type=tx_type,
quantity=Decimal(str(qty)),
performed_by=performed_by,
note=note or "تاریخچه دمو داشبورد",
transaction_date=d(days_ago),
))
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
# dead stock: cable + switches bought long ago, untouched since
cable = await ensure_inv(0, 0, 0)
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
keys = await ensure_inv(1, 0, 0)
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
cement = await ensure_inv(4, 2, 0)
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
blocks = await ensure_inv(5, 2, 1)
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
pe16_a = await ensure_inv(6, 1, 0)
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
pe16_b = await ensure_inv(6, 1, 1)
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
pe25 = await ensure_inv(7, 1, 0)
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
await db.flush()
await db.commit() await db.commit()
# summary # summary
@@ -0,0 +1,388 @@
# پلی‌بوک اجرایی: افزودن موتور STT بله به خط لوله چندمدله‌ی بات تدارکات
> **نسخه:** ۱.۰
> **مخاطب:** ایجنت هوش مصنوعی مجری + تیم فنی + مدیر محصول
> **ماهیت سند:** سند تصمیم‌محور — هر تست به یک «دروازه‌ی تصمیم (Gate)» می‌رسد و هر دروازه، جدول «وضعیت مشاهده‌شده ← اقدام» دارد. ایجنت باید فقط بر اساس وضعیت‌های واقعیِ مشاهده‌شده در همان لحظه، شاخه‌ی درست را انتخاب کند.
> **هدف نهایی:** افزودن ترنسکریپشن موتور بله (STT سمت سرور بله) به‌عنوان یکی از کاندیداهای خط لوله‌ی Multi-Provider STT فعلیِ بات، بدون هیچ تغییر ساختاری در بات.
---
## ۰. قواعد الزام‌آور اجرا (برای ایجنت — قبل از هر اقدام بخوانید)
1. **مبنای گیت فقط `production` است.** سایر برنچ‌ها (از جمله `prod-v7` که کد بات روی آن است) متعلق به جریان کاری خودشان‌اند و **نباید** توسط ایجنت دست‌کاری، مرج یا پوش شوند. تنها مهندس ارشد شرکت مرج می‌کند. **هیچ پوشی بدون تأیید صریح مالک انجام نشود.**
2. **درخواست‌های شبکه به بله باید کم‌حجم و انسان‌نما باشند.** هشدار جدی: سازندگان کتابخانه‌های مهندسی‌معکوس بله تأکید کرده‌اند که «استفاده‌ی زیاد از POST gRPCِ غیر-احراز (پیام/مدیا/حضور) می‌تواند فلگ، rate-limit یا بن موقت ایجاد کند». پس: ترجیح کانال WebSocket، ترتیبی نه موازی، تأخیر انسانی، سقف روزانه.
3. **حساب‌ها:** فقط شماره‌ی اختصاصی شرکت (Bale ئرنر) — هرگز شماره‌ی شخصی. حساب فقط برای این کار باشد.
4. **حریم خصوصی:** صدای کاربران تدارکات روی سرورهای بله پردازش می‌شود (همین حالا هم ویس از بله می‌آید)؛ اما اگر ایجنت صدا را مجدداً به یک چت آپلود می‌کند (مسیر R2/R3)، باید بلافاصله پس از ترنسکرایب پیام را حذف کند و آدرسی از صدا نگه ندارد.
5. **هیچ سکرتی (توکن بات، توکن کاربر، session_file، کلید LLM) نباید در گیت کامیت شود.** سکرت‌های موجود در `docker-compose.yml` (توکن بات و کلید DeepSeek به‌عنوان پیش‌فرض) باید در فرصت مناسب rotate و به `${VAR:?…}` تبدیل شوند.
6. **معیار موفقیت، سرعت نیست — دقت است.** بدون عبور از دروازه‌ی دقت (G5) هیچ ساخت/استقراری مجاز نیست.
---
## ۱. واقعیت‌های تأییدشده (پایه‌ی همه‌ی تصمیم‌ها)
### ۱.۱ — سیستم فعلی: بات و خط لوله‌ی STT
| مؤلفه | مسیر | توضیح |
|---|---|---|
| خط لوله STT | `bot/app/stt.py` | `transcribe_multi()` صدای MP3 (خروجی ffmpeg: mono 16kHz) را **به موازات** به همه‌ی پرووایدرها می‌فرستد؛ هر پرووایدر یک `POST {base_url}/audio/transcriptions` (multipart، فیلد `file`، به‌همراه `model` و `language=fa`) است و خروجی `{"text": "..."}` انتظار دارد. **fail-soft:** خطای هر پرووایدر فقط آن را حذف می‌کند (`return None`)؛ بقیه ادامه می‌دهند. |
| پیکربندی پرووایدرها | `bot/app/config.py` | `STT_PROVIDER_1..3_NAME/BASE_URL/API_KEY/MODEL/LANGUAGE` + `STT_PROVIDERS_JSON` + fallback قدیمی `STT_*`. نکته: یک پرووایدر فقط وقتی فعال می‌شود که **هم `BASE_URL` و هم `API_KEY`** پر باشند. |
| مصرف خروجی‌ها | `bot/app/nlu.py` | همه‌ی ترنسکریپت‌های کاندید به DeepSeek می‌روند و خروجی نهایی `consensus_text` + `intent` + `patch` (اقلام استعلام) است. **یعنی افزودن بله = یک کاندید بیشتر در ورودی consensus.** |
| مسیر ویس در بات | `bot/app/handlers/__init__.py` | ویس → دانلود (`getFile`) → `transcribe_multi` → متن به‌عنوان ورودی NLU. |
**نتیجه‌ی معماری (کلیدی):** افزودن هر مسیر STT = ساخت **یک سرویس adapter با قرارداد OpenAI-compatible** + پر کردن `STT_PROVIDER_3_*`. **صفر تغییر در کد بات.** (این نقطه‌ی اتصال رسمی و پایدار است.)
### ۱.۲ — شواهد تجربی Bot API (تست‌شده — تکرارپذیر)
روی توکن بات فعلی (`procurement_assistant_bot`)، متدهای مشکوک STT تست شدند:
```
POST https://tapi.bale.ai/bot<TOKEN>/transcribeVoice → {"ok":false,"error_code":404,"description":"Bad Request: handler not found"}
POST .../getTranscription → (همان 404)
POST .../transcribe → (همان 404)
POST .../speechToText → (همان 404)
POST .../voiceToText → (همان 404)
POST .../getMe (کنترل) → {"ok":true,"result":{"username":"procurement_assistant_bot",...}}
```
مستندات رسمی (`docs.bale.ai`) هم هیچ متد یا فیلد ترنسکریپشن برای آبجکت Voice/Audio ندارند.
**نتیجه: Bot API بله قطعاً STT ندارد. مسیر «فیلد مخفی» فقط با تست T1 (بخش ۳) قابل رد شدن نهایی است.**
### ۱.۳ — یافته‌های گزارش‌های ایجنت شرکت (خلاصه‌ی راستی‌آزمایی‌شده)
| یافته | وضعیت راستی‌آزمایی |
|---|---|
| کلاینت وب بله روی **gRPC-Web + Protocol Buffers** (`@bufbuild/protobuf`) است | **منطقی و هم‌راستا:** کتابخانه‌های مستقل مهندسی‌معکوس بله هم همین را تأیید می‌کنند؛ باید در T2 مجدداً مشاهده شود. |
| گیت‌وی‌ها: `https://maviz-ws.bale.ai` و `wss://maviz-ws.bale.ai/ws/?uid={user_id}` (+ بتا/نکست) | در وب ایندکس نشده (طبیعی) — **باید در T2 با چشم مشاهده شود.** |
| ماژول‌های `SpeechTranscript` و `AIChat` در باندل | سرنخ معتبر — **باید در T2/T3 تأیید و دیکود شود.** |
| «روش دوم غیرعملی است چون باید کل .protoها بازسازی شوند» | **نادرست/بیش‌برآورد:** با دیکود جنریک Protobuf و استخراج توصیف‌گر از خود باندل (T3) یا با استفاده از aiobale-py (T4)، این کار در حد «افزودن یک متد» کوچک می‌شود. |
| ماتریس تأخیر («روش دوم زیر ۱ ثانیه») | **گمراه‌کننده:** گلوگاه، استنتاج ASR روی سرور بله است؛ عدد واقعی فقط از T2/T5 می‌آید. |
### ۱.۴ — اکوسیستم مهندسی معکوس موجود (مهم — دارایی راهبردی)
- **`aiobale-py`** (v0.3.9، فعال): فریم‌ورک async برای بله روی **WebSocket + Protobuf**، الهام‌گرفته از aiogram؛ ~۸۰ متد RPC (پیام، فایل، گروه، مخاطبین…)، ورود تلفنی با OTP، `session_file`، توکن JWT، rate-limiter داخلی، docs و مخزن عمومی:
- PyPI: `pip install aiobale-py` (ایمپورت: `aiobale`)
- GitHub: `github.com/aminmadaniofficial/aiobale` — مستندات: `aminmadaniofficial.github.io/aiobale/`
- **متد ترنسکرایب ندارد** → باید همان RPC کشف‌شده در T2 به آن افزوده شود (زیرساخت لاگین/WS/کدک آماده است).
- نسخه‌ی اولیه‌ی `Enalite/aiobale` (GitHub الان 404؛ نام PyPI توسط یک پکیج خالی اشغال شده) → **فقط به فورک فعال `aiobale-py` اتکا کنید.**
- پروژه‌ی جانبی مرتبط: `bale-mcp-server` (MCP برای بله با aiobale-py) — برای الگوگیری نحوه‌ی استفاده.
- سرویس ثالث STT: [majidapi.ir](https://majidapi.ir) (`/ai/stt?url=...&token=...`) — مسیر جایگزین R7 (بخش ۵).
### ۱.۵ — وضعیت برنچ‌ها (قواعد همکاری)
| برنچ | وضعیت | مجاز برای ایجنت؟ |
|---|---|---|
| `production` (`253c922`) | مبنای رسمی کار | ✅ فقط خواندن/مبنا |
| `prod-v7` (کد بات/ایجنت، `638076d`) | کار مالک این سند؛ منتظر تست نهایی و مرج توسط مهندس ارشد | ⛔ دست‌کاری/پوش ممنوع |
| `prod-v8/9/10`، `pm`، `pm_2` و بقیه | برنچ‌های سایر اعضا؛ در حال تست | ⛔ هیچ دخالتی |
---
## ۲. نقشه‌ی وضعیت‌ها (State Machine) — نمای کلی مسیر
```
S0 (شروع)
│
├─T1─> G1 ──(فیلد ترنسکریپشن پیدا شد)───────────────► R1: پایان! (بدون کرالر) ✅
│ └─(پیدا نشد)──► T2
│
├─T2─> G2 ──(RPC ساده/دیکودپذیر)──► T3+T4 ─> G3 ──(موفق)──► T5+ G4 ─► T7 ─> G5
│ ├─(فقط روی WS)──────────► همان T3+T4 (aiobale مسیر WS است) ▲
│ ├─(رمزنگاری/امضای خاص)──► R4 (Playwright) یا R3 ─► T5/T7
│ └─(نیاز به اثر انگشت مرورگر)─► R4 ─► T5/T7
│
├─(T5/T7)─> G5 ──(بهبود دقت کافی)──► G6 (ToS) ──(سبز)──► BUILD (R2/R3/R4) ─► G7 (سواک) ─► استقرار کامل
│ └─(بهبود ناکافی)──► STOP: همان پرووایدرهای فعلی ⛔
│ └─(قرمز)──► STOP یا فقط R4 در حد محدود ⛔
```
---
## ۳. فاز A — تست‌های کشف (T1…T9)
> برای هر تست: هدف، پیش‌نیاز، مراحل، خروجی، معیار قبولی، و حالت‌های ممکن. ایجنت بعد از هر تست **حتماً** به جدول دروازه‌ی مربوطه (بخش ۴) مراجعه کند.
### T1 — تست payload خام ویس (۳۰ دقیقه)
**هدف:** رد یا تأیید نهایی وجود فیلد ترنسکریپشن مخفی در پیام‌های Bot API.
**پیش‌نیاز:** یک ربات تستی **مستقل** (از BotFather بله بسازید؛ از ربات فعلی استفاده نکنید چون در حال long-poll است و مصرف‌کننده‌ی دوم تعارض ایجاد می‌کند). یک حساب بله برای فرستادن ویس.
**مراحل:**
1. اسکریپت مینیمال پایتون: `getUpdates` بدون offset + چاپ JSON کامل هر آپدیت در فایل.
2. یک ویس فارسی واقعی (۵–۱۰ ثانیه) به ربات تستی بفرستید.
3. JSON کامل ویس را ذخیره کنید؛ کلیدهای آبجکت `voice`/`audio`/`message` را لیست کنید.
4. `getFile` را هم صدا بزنید و فیلدهای پاسخ را ضبط کنید.
**معیار قبولی/حالت‌ها:**
- **حالت A:** هر فیلدی مثل `transcription`/`transcript`/`speech`/`text` روی ویس وجود دارد → **پایان پروژه‌ی کرالر (R1)**؛ فقط به بات وصل کنید.
- **حالت B:** خبری نیست (قوی‌ترین احتمال طبق مستندات) → برو به T2.
### T2 — کپچر ترافیک کلاینت وب (نیم‌روز) — قاطع‌ترین تست
**هدف:** کشف «دقیق» مکانیزم ترنسکرایب: نام RPC، ترابری (unary/WS)، قالب فریم، هدر احراز، و **زمان واقعی ASR**.
**پیش‌نیاز:** حساب سوخته‌ی اختصاصی (سیم‌کارت شرکت)، مرورگر Chromium، ابزار دیکود Protobuf: `protoscope` یا `blackboxprotobuf` (نصب با pip/go)، و اختیاری mitmproxy.
**مراحل:**
1. ورود به `web.bale.ai`؛ DevTools → Network با گزینه‌ی Preserve log؛ فیلتر `WS` و `Fetch/XHR` را فعال نگه دارید.
2. یک ویس در یک چت باز کنید و دکمه‌ی «تبدیل به متن / متن پیاده» را بزنید.
3. **همه‌ی درخواست‌های همان لحظه** را ضبط کنید:
- اگر `POST` روی هاستی مثل `maviz-ws.bale.ai` با `content-type: application/grpc-web+proto` (یا `grpc-web-text`) ظاهر شد → همین RPC است.
- اگر قاب باینری روی WebSocket `wss://…/ws/?uid=…` رفت → RPC روی WS است.
4. برای هر نمونه ذخیره کنید: `req.bin`، `resp.bin`، هدرهای مهم (نام هدر توکن: `authorization`/`x-auth`/…)، URL کامل، و **زمان پاسخ از تب Timing** (همین = تأخیر واقعی ASR بله).
5. ۵ تا ۱۰ ویس متنوع (کوتاه/بلند/با لهجه) تکرار کنید تا پایداری اسکیما معلوم شود؛ در `samples/` ذخیره و در `findings.md` خلاصه کنید.
6. دیکود آفلاین: به یاد داشته باشید پیام‌های gRPC-Web یک **پیشوند ۵ بایتی** دارند (۱ بایت فلگ + ۴ بایت طول) و سپس Protobuf می‌آید → ابتدا پیشوند را بردارید، سپس:
`protoc --decode_raw < resp_payload.bin` یا `protoscope resp_payload.bin`
7. اگر آرایه‌ی WS دیده شد: فریم‌ها را از DevTools کپی و با protoscope دیکود کنید؛ به دنبال پیام معادل `SpeechTranscript` بگردید.
**خروجی:** `capture/findings.md` شامل: نام دقیق RPC، ترابری، framing، هدر احراز، تأخیر واقعی، و نقشه‌ی مقدماتی فیلدها (کدام فیلد = message_id؟ file_id؟ متن؟).
### T3 — استخراج اسکیما (۱–۲ روز)
**هدف:** تبدیل کپچرها به یک اسکیمای قابل استفاده (و ترجیحاً `.proto`).
**روش‌ها (به ترتیب):**
1. **از خود باندل:** باندل وب بله (`/static/js/index.*.js` و در صورت وجود `.js.map`) را از قبل دارید؛ پروژه‌های ساخته‌شده با `@bufbuild/protobuf` معمولاً **`FileDescriptorProto` باینری را داخل JS جاسازی می‌کنند**. روش عملی: جست‌وجوی بایت‌های توصیف‌گر، استخراج و سپس:
`protoc --decode=google.protobuf.FileDescriptorProto google/protobuf/descriptor.proto < desc.bin`
(یا دیکود همان بایت‌ها با پایتون + `grpcio-tools`). اگر sourcemap موجود بود، مستقیم‌تر است.
2. **از نمونه‌ها:** با چند نمونه‌ی req/resp دیکودشده با `protoc --decode_raw`، همبستگی فیلدها را استخراج کنید (فیلد شماره‌ی متن جواب، message_id درخواست و…).
3. **مسیر موازی (اپ اندروید):** APK رسمی بله (از سایت رسمی) + `jadx`؛ جست‌وجوی `"SpeechTranscript"`؛ در اندروید معمولاً شماره‌ی فیلدها واضح‌ترند.
**معیار قبولی:** توان ساخت درخواست معتبر و خواندن پاسخ — حتی به‌صورت «فیلد ۱ = message_id، فیلد ۲ = متن». داشتن `.proto` کامل لازم نیست.
### T4 — اسپایک aiobale-py (۱ روز)
**هدف:** اثبات امکان اجرای ترنسکرایب با یک اکانت کاربری، بدون مرورگر.
**پیش‌نیاز:** venv ایزوله (**نه** محیط بات)، حساب سوخته، اسکیمای T3.
**مراحل:**
1. `pip install aiobale-py` → ورود: `Client(session_file="bale.session", phone_number="…")` و عبور از OTP (دروازه‌ی انسانی).
2. کاوش لایه‌ی کدک: در `site-packages/aiobale/…` ببینید متدها چطور تعریف می‌شوند (احتمالاً مپینگ متد→پیام Protobuf). **یک متد سفارشی برای RPC ترنسکریپت کشف‌شده اضافه کنید.**
3. **کشف مکانیزم دقیق:** آیا RPC ترنسکرایب `message_id` می‌خواهد (یعنی صدا باید از قبل «پیامی در بله» باشد) یا `file_id`/بایت خام قبول می‌کند؟ هر سه فرض را تست کنید:
- (الف) آپلود ویس به یک **چت اسکرچ** (چت با خود یا گروه خصوصی اختصاصی) → گرفتن `message_id` → ترنسکرایب → **حذف پیام**.
- (ب) `upload_file` (در aiobale موجود است) → ترنسکرایب با فایل.
- (ج) اگر RPC ورودی بایت می‌گیرد → ساده‌ترین (بدون پیام واسط).
4. تست صحت: متن برگشتی را با متنی که وب بله برای **همان ویس** نشان می‌دهد مقایسه کنید (باید یکسان/نزدیک باشد).
5. تست پایداری نشست: ری‌استارت پروسه و استفاده‌ی مجدد از session file؛ ثبت زمان انقضای توکن.
**حالت‌ها:**
- **موفق کامل** → مسیر R2 برای ساخت adapter انتخاب می‌شود.
- **نیمه‌موفق** (login/WS می‌شود ولی RPC دیکود نمی‌شود) → T3 را عمیق‌تر + کپچر بیشتر؛ در غیر این صورت R3/R4.
- **ناموفق** (چالش احراز، بن، نبود قابلیت افزودن متد) → R3 و R4.
### T-extra — تست «AI Chat به‌عنوان اوراکل» (نیم‌روز، موازی با T2/T4)
**هدف:** اگر چت هوش مصنوعی بله ویس را با متن پیاده‌شده پاسخ می‌دهد، **حتی نیازی به RPC جدید نیست**: با متدهای پیام‌رسانیِ *موجودِ* aiobale، ویس را به چت AI بفرستید و متن را از پاسخ استخراج کنید.
**مراحل:** با اکانت سوخته در وب بله، یک ویس به چت AI بفرستید؛ دقیقاً ببینید آیا متن پیاده‌شده در پاسخ می‌آید و با چه قالبی. سپس همان را با aiobale بازسازی کنید.
**معیار:** اگر متن پیاده با قالب قابل‌اعتماد (ثابت) برگردد → مسیر R3 فعال می‌شود (ارزان‌تر از R2).
### T5 — اندازه‌گیری تأخیر/پایداری واقعی (۱–۲ روز)
**هدف:** جایگزینی اعداد ساختگی گزارش با اعداد واقعی.
**مراحل:** ۵۰–۱۰۰ ویس واقعی از مسیر انتخابی (ترتیبی، با فاصله‌ی انسانی ۱–۳ ثانیه)؛ ثبت p50/p95، نرخ خطا، انواع خطا، و هر نشانه‌ی rate-limit.
**معیار پذیرش پیشنهادی:** نرخ موفقیت ≥ ۹۵٪ و p95 ≤ ۸ ثانیه (برای UX بات). در غیر این صورت G4 قرمز می‌شود.
### T7 — بنچمارک دقت (۱ روز) — **دروازه‌ی کسب‌وکاری، قبل از هر ساخت**
**هدف:** آیا افزودن بله واقعاً خروجی نهایی را بهتر می‌کند؟
**مراحل:**
1. ۲۰–۳۰ ویس **واقعی تدارکاتی** جمع کنید (با اطلاع فرستندگان؛ صداها را بلندمدت نگه ندارید).
2. برای هر ویس سه خروجی بگیرید: (الف) وضعیت فعلی (whisper/سایر + consensus فعلی)، (ب) فقط متن بله، (ج) consensus با اضافه‌شدن کاندید بله.
3. سه معیار بسنجید: WER متن، دقت استخراج موجودیت‌ها (نام کالا/برند/تعداد/واحد در JSON نهایی)، و تعداد «موارد سخت» که با بله درست شده‌اند.
**معیار پیشنهادی:** اگر کاهش خطای استخراج ≥ ~۱۵٪ نسبی **یا** حل ≥ ۲ مورد سختِ لهجه‌ای → پروژه توجیه دارد؛ در غیر این‌صورت **STOP** (همان خط لوله‌ی فعلی کافی است).
### T6 + T9 — سواک بن و ماندگاری نشست (۱–۲ هفته، پسیو)
استفاده‌ی کم‌حجم روزانه (مثلاً ≤ ۱۰۰ ترنسکرایب در روز) از IP شرکت؛ ثبت: تعداد ورود مجدد لازم، چالش‌های امنیتی، هشدارها، عمر واقعی توکن. خروجی: سقف عملیاتی + runbook ورود مجدد.
### T8 — بازبینی حقوقی/ToS (موازی — **دروازه‌ی انسانی**)
ایجنت فقط شواهد را جمع می‌کند؛ تصمیم Go/No-Go توسط مدیریت مستند می‌شود. معیار: سند تصمیم مکتوب.
---
## ۴. دروازه‌های تصمیم (جدول‌های وضعیت ← اقدام)
### G1 (بعد از T1)
| وضعیت مشاهده‌شده | اقدام |
|---|---|
| فیلد ترنسکریپشن روی ویس هست | **پایان:** R1 — مستقیم به بات وصل کن (بدون هیچ کرالری) |
| نیست | برو T2 |
### G2 (بعد از T2) — مهم‌ترین دروازه‌ی فنی
| وضعیت مشاهده‌شده | معنا | اقدام |
|---|---|---|
| RPC از نوع **unary POST gRPC-Web** با پروتوباف دیکودپذیر | بهترین حالت | برو T3+T4 → R2 |
| RPC **فقط روی WebSocket** | حالت مطلوب دوم (aiobale دقیقاً WS است) | برو T3+T4 → R2 (از کانال WS؛ **POST gRPC زیاد نزن**) |
| درخواست **رمزنگاری/امضای اختصاصی** دارد (فراتر از پروتوباف) | هزینه‌ی نگهداری بالا | R4 (Playwright) یا R3 به‌عنوان مسیر اصلی؛ R2 فقط اگر … شکست |
| ترنسکرایب نیازمند **اثر انگشت/رفتار مرورگر** است | R2 غیرممکن | R4 |
| ترنسکرایب **message_id روی پیامی موجود** می‌خواهد | نیازمند «آپلود به چت اسکرچ» | R2 با فلوی «آپلود→ترنسکرایب→حذف» (بخش ۵.۲) |
### G3 (بعد از T3/T4)
| وضعیت | اقدام |
|---|---|
| ساخت درخواست + خواندن پاسخ ممکن شد | برو T5 → T7 |
| فقط یکی از دو جهت ممکن است | کپچر بیشتری بگیر (T2)؛ اگر نشد → R3/R4 |
| شکست کامل | R3 (اگر T-extra موفق بود) وگرنه R4؛ اگر هر دو هم نشد → STOP |
### G4 (بعد از T5)
| وضعیت | اقدام |
|---|---|
| نرخ موفقیت ≥ ۹۵٪ و p95 ≤ ۸s | ادامه |
| rate-limit مشاهده شد | کاهش نرخ + backoff نمایی + سقف روزانه؛ مجدداً بسنج |
| بن/چالش امنیتی | توقف فوری؛ رفتن به R4 با اکانت جدید، یا STOP |
### G5 (بعد از T7) — دروازه‌ی کسب‌وکار
| وضعیت | اقدام |
|---|---|
| بهبود دقت مطابق آستانه | ادامه به G6 |
| بهبود ناکافی | **STOP** — همان پرووایدرها؛ یافته‌ها را مستند کن |
### G6 (ToS) / G7 (سواک)
| وضعیت | اقدام |
|---|---|
| ToS سبز + سواک سالم | استقرار کامل (بخش ۶) |
| ToS قرمز | STOP یا فقط R4 با حجم محدود (طبق تصمیم مدیریت) |
| سواک ناسالم | rollback به پرووایدرهای قبلی (حذف envها) و بازگشت به G4 |
---
## ۵. فاز B — پیاده‌سازی (فقط پس از عبور از دروازه‌ها)
### ۵.۱ — قرارداد مشترک adapter (برای همه‌ی مسیرها یکسان)
سرویس `bale-stt` (کانتینر داخلی، **بدون پورت عمومی**):
- `POST /v1/audio/transcriptions` — multipart، فیلد `file` (فایل MP3)، `model`، `language=fa` → پاسخ `{"text": "..."}` (دقیقاً قرارداد OpenAI که بات انتظار دارد).
- هدر `Authorization: Bearer <INTERNAL_KEY>` را بپذیرد (فقط کلید داخلی، نه توکن بله).
- `GET /healthz` برای سلامت.
- موتور داخلی قابل‌سوییچ با env: `BALE_STT_ENGINE=grpc|aichat|playwright` → همه‌ی مسیرها همزیستی دارند و تعویض موتور بدون تغییر بات.
### ۵.۲ — R2: مهندسی معکوس (مسیر پیشنهادی اصلی)
معماری: `bot → adapter → aiobale-py (اکانت سوخته) → WS/gRPC بله → متن`
نکات پیاده‌سازی:
- اکانت: `session_file` روی یک **داکر ولوم**؛ هرگز در گیت. `BALE_PHONE` و سکرت‌ها در env.
- اگر RPC به message_id نیاز دارد: فلوی «آپلود ویس به چت اسکرچ → ترنسکرایب → **حذف پیام**» — با `send_voice` و `delete_message` موجود در aiobale.
- ترتیبی اجرا کنید (قفل asyncio)، فاصله‌ی ۱–۳ ثانیه، سقف روزانه قابل‌تنظیم (`BALE_STT_DAILY_CAP`).
- مدیریت انقضای توکن: تشخیص خطای احراز → علامت‌گذاری «نیازمند OTP» + هشدار به ادمین (دروازه‌ی انسانی).
- fail-soft مضاعف: adapter در هر خطا `{"text": ""}` (یا 5xx کنترل‌شده) برگرداند تا بات فقط این کاندید را حذف کند.
### ۵.۳ — R3: اوراکل AI Chat
همان adapter، اما داخلش: ارسال ویس به چت AI (با aiobale) → استخراج متن از قالب پاسخ → بازگرداندن. سبک‌ترین پیاده‌سازی؛ وابسته به پایداری قالب پاسخ.
### ۵.۴ — R4: Playwright (فالبک)
کانتینر جدا با Chromium headless + پروفایل پایدار (`userDataDir` روی ولوم)، یک worker تک‌نمونه با صف؛ مراحل: ورود یک‌باره (OTP انسانی) → برای هر درخواست: آپلود ویس به یک چت اسکرچ → کلیک «تبدیل به متن» → انتظار روی سلکتور متن → استخراج → حذف پیام. RAM/زمان بیشتر؛ مقاوم‌تر به تغییر پروتکل.
### ۵.۵ — ادغام با بات (بدون تغییر کد)
در `docker-compose.yml` (نسخه‌ی تیم/سرور، **نه** برنچ دیگران) سرویس `bale-stt` اضافه و در سرویس `bot` این envها ست شود:
```
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=<INTERNAL_KEY>
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
```
(یادآوری از کد: پرووایدر فقط با پر بودن همزمان BASE_URL و API_KEY فعال می‌شود.)
### ۵.۶ — سکرت‌ها و امنیت
| سکرت | محل | نکته |
|---|---|---|
| توکن بات بله | env سرور | **موجود در compose کامیت‌شده → rotate** |
| کلید DeepSeek | env سرور | **همین‌طور rotate** |
| `session_file` اکانت سوخته | داکر ولوم | هرگز گیت؛ مالک مشخص |
| `INTERNAL_KEY` adapter | env | فقط بین bot و adapter |
| `DRAFT_TOKEN_SECRET` بات | env | پیش‌فرض فعلی در کد ضعیف است → ست شود |
### ۵.۷ — مقیاس
فاز اول: تک‌نمونه، ترتیبی، سقف روزانه. توسعه فقط بعد از G7 و فقط با شواهد پایداری.
---
## ۶. پذیرش، استقرار، پایش، بازگشت
**تست‌های پذیرش (اجباری):**
1. `curl -F "file=@voice.mp3" -F model=bale-stt http://localhost:8080/v1/audio/transcriptions` → `{"text": "<متن فارسی درست>"}`.
2. بات: ارسال ویس واقعی → پاسخ استخراج‌شده‌ی صحیح.
3. **fail-soft:** توقف کانتینر `bale-stt` → بات همچنان ویس را (با پرووایدرهای دیگر) پردازش کند ✓.
4. حریم خصوصی: بررسی کنید هیچ فایل صوتی روی adapter باقی نمی‌ماند و پیام‌های اسکرچ حذف شده‌اند.
**استقرار فازبندی‌شده:** dev → سرور با حجم کم (۱ هفته) → کامل.
**پایش:** شمارش درخواست، نرخ موفقیت، هیستوگرام تأخیر، انواع خطا، وضعیت نشست. هشدار اگر نرخ خطا در ۳۰ دقیقه > ۲۰٪.
**بازگشت (Rollback):** حذف `STT_PROVIDER_3_*` از env بات + توقف کانتینر adapter — بات فوراً به وضعیت قبل برمی‌گردد (بدون deploy مجدد).
---
## ۷. رجیستر ریسک
| ریسک | احتمال | اثر | مقابله |
|---|---|---|---|
| بن/rate-limit اکانت | متوسط | توقف مسیر | حجم کم، WS-first، backoff، اکانت اختصاصی قابل‌تعویض، G4 |
| تغییر پروتکل بله | متوسط | قطع سرویس | pایش نسخه‌ی باندل (hash)، آلرت خطا، فالبک R4، سوییچ موتور با env |
| انقضای نشست/نیاز OTP | بالا (دوره‌ای) | توقف موقت | ولوم session + آلرت + runbook ورود (انسانی) |
| دقت ناکافی نسبت به هزینه | نامعلوم | هزینه‌ی بی‌فایده | **G5 قبل از ساخت** |
| ریسک حقوقی/ToS | نامعلوم | توقف اجباری | **T8/G6 — تصمیم مدیریت** |
| حریم خصوصی صدا | کم | اعتباری | حذف فوری پیام‌ها/فایل‌ها؛ عدم نگهداری |
| وابستگی به فرد نگهدارنده | متوسط | رکود | مستندسازی در همین پلی‌بوک + مالک رسمی |
---
## ۸. دروازه‌های انسانی (فقط انسان می‌تواند)
1. **OTP ورود اکانت** (بار اول و هر بار انقضا).
2. **تصمیم ToS/حقوقی (G6).**
3. **جمع‌آوری نمونه‌های واقعی ویس برای T7** (با رضایت).
4. **تأیید نهایی استقرار** و تأیید مرج برنچ توسط مهندس ارشد.
---
## ضمیمه A — دستورات probe (تکرارپذیر)
```bash
TOKEN="<BOT_TOKEN>"
for M in transcribeVoice getTranscription transcribe speechToText voiceToText; do
curl -s -X POST "https://tapi.bale.ai/bot$TOKEN/$M" -H "Content-Type: application/json" -d '{}'; echo
done
curl -s -X POST "https://tapi.bale.ai/bot$TOKEN/getMe" -H "Content-Type: application/json" -d '{}'
```
خروجی مورد انتظار: پنج 404 «handler not found» و یک getMe سالم. (همین تست را قبل از هر تصمیم مجدداً اجرا کنید — شاید بله متد اضافه کرده باشد.)
## ضمیمه B — اسکلت adapter (قابل استفاده برای هر سه موتور)
```python
# bale_stt/adapter.py — قرارداد OpenAI-compatible برای بات
import os
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
app = FastAPI()
INTERNAL_KEY = os.environ["BALE_STT_INTERNAL_KEY"]
ENGINE = os.environ.get("BALE_STT_ENGINE", "grpc") # grpc | aichat | playwright
@app.get("/healthz")
def healthz():
return {"status": "ok", "engine": ENGINE}
@app.post("/v1/audio/transcriptions")
async def transcribe(file: UploadFile = File(...), model: str = Form("bale-stt"),
language: str = Form("fa"), authorization: str = Header("")):
if authorization != f"Bearer {INTERNAL_KEY}":
raise HTTPException(status_code=401)
audio = await file.read()
if ENGINE == "grpc":
text = await grpc_transcribe(audio) # ← aiobale-py + RPC کشف‌شده
elif ENGINE == "aichat":
text = await aichat_transcribe(audio) # ← ارسال به چت AI و استخراج متن
else:
text = await playwright_transcribe(audio)
return {"text": text or ""}
# نکته: هرگز استثنا را بیرون نده؛ در خطا {"text": ""} برگردان تا بات fail-soft بماند.
```
## ضمیمه C — منابع
- aiobale-py: [PyPI](https://pypi.org/project/aiobale-py/) · [GitHub](https://github.com/aminmadaniofficial/aiobale) · [مستندات](https://aminmadaniofficial.github.io/aiobale/)
- مستندات رسمی بات بله: [docs.bale.ai](https://docs.bale.ai)
- سرویس ثالث STT: [majidapi.ir](https://majidapi.ir)
- گزارش‌های ایجنت شرکت: `bale_stt_integration_report.md` و `bale_stt_integration_report_2.md` (کنار همین فایل)
## ضمیمه D — چک‌لیست سریع ایجنت (TL;DR)
1. [ ] قواعد بخش ۰ را خواندم.
2. [ ] T1 (payload ویس) → G1.
3. [ ] T2 (کپچر) → G2 → مسیر (R2 / R3 / R4) انتخاب شد.
4. [ ] T3+T4 (اسکیما + اسپایک) → G3.
5. [ ] T-extra (اوراکل AI Chat) اگر مسیر R3 مدنظر است.
6. [ ] T5 (تأخیر واقعی) → G4.
7. [ ] T7 (دقت) → G5 — **بدون این، ساخت ممنوع.**
8. [ ] T8 (ToS — انسانی) → G6.
9. [ ] ساخت adapter (۵.۱) + ادغام env (۵.۵) + تست‌های پذیرش (بخش ۶).
10. [ ] سواک (T6/T9) → G7 → استقرار کامل. هر شکست ⇒ جدول همان دروازه ⇒ اقدام جایگزین یا STOP.
+256
View File
@@ -0,0 +1,256 @@
# گزارش جامع: اعتبارسنجی گزارش‌ها و راهکار کرال موتور STT بله
> **تاریخ:** ۱۵ مهر ۱۴۰۵
> **موضوع:** افزودن متن تولیدشده توسط قابلیت «تبدیل ویس به متن» پیام‌رسان بله به‌عنوان یک کاندید در خط‌لوله چندمدله STT بات تدارکات
> **ورودی‌ها:** سه سند ارائه‌شده (تحلیل جامع، پلی‌بوک اجرایی، تحلیل مقایسه‌ای) + راستی‌آزمایی مستقل وب
> **محدودیت:** `integration_report` شماره ۱ و ۲ در دسترس من نبود و فقط از روی نقل‌قول‌های همین اسناد قضاوت شده‌اند. دستورهای کشف (بخش ۴) در محیط من اجرا نشده‌اند، چون شبکه sandbox بسته بود.
---
## ۱. خلاصه مدیریتی
1. **Bot API رسمی بله STT ندارد.** همه اسناد در این مورد درست می‌گویند.
2. **تناقض اصلی** (مهندسی معکوس «غیرعملی است» در برابر «ساده است») هیچ‌کدام کاملاً درست نیست. مسیر شدنی است، ولی «افزودن یک متد» نیست و نیاز به یک فاز کشف دارد.
3. **دو فرض اثبات‌نشده** کل مسیر را تحت تأثیر قرار می‌دهد:
- دکمه «تبدیل به متن» روی **وب** بله وجود دارد (هر سه سند فرض کرده‌اند، هیچ منبعی تأیید نکرده).
- پکیج `aiobale-py` نسخه 0.3.9 فعال است (پیدا نشد؛ به بخش ۲ مراجعه کنید).
4. **کشف جدید:** پکیج `bale-sdk` با ۶۰۷ RPC تایپ‌شده و پروتوهای بازیابی‌شده. احتمال وجود RPC ترنسکریپشن در آن بالاست. ولی مجوز آن اختصاصی است.
5. **بزرگ‌ترین صرفه‌جویی:** آزمون دقت را **قبل از ساخت** و به‌صورت **دستی** روی ۳۰ ویس واقعی انجام دهید. اگر بله از خط‌لوله فعلی بهتر نبود، پروژه لازم نیست.
6. **ریسک حقوقی/ToS** (استفاده خودکار از حساب کاربری) تصمیم مدیریتی است و باید قبل از استقرار روشن شود.
---
## ۲. اعتبارسنجی گزارش‌ها
### ۲.۱ جدول ادعاها
| ادعا | نتیجه | توضیح |
|---|---|---|
| Bot API رسمی STT ندارد | ✅ تأیید | پروب‌ها همگی ۴۰۴ داده‌اند و تایپ‌های SDKها برای Voice فقط شناسه فایل و مدت‌زمان دارند. **تذکر:** ۴۰۴ برای نام‌های حدسی چیزی درباره فیلد مخفی در payload نمی‌گوید؛ تست T1 (ویس به یک بات تستی و خواندن JSON کامل) هنوز لازم است (۳۰ دقیقه). |
| بله قابلیت تبدیل ویس به متن دارد | ✅ با شرط | نسخه جدید بله این قابلیت را برای اندروید و iOS منتشر کرده. وجود دکمه در **وب** تأییدنشده است و ریسک اصلی مسیر Playwright همین است. |
| مهندسی معکوس «غیرعملی» است (گزارش ۲) | ⚠️ بدبینانه | کلاینت‌های متن‌باز مهندسی‌معکوس‌شده وجود دارند. اما استدلال «Protobuf خودتوصیف است» نادرست است: wire format بدون `.proto` فقط تا حدی (شماره فیلد و نوع خام، بدون نام و معنا) دیکود می‌شود. |
| مهندسی معکوس «در حد افزودن یک متد» است (پلی‌بوک) | ⚠️ خوش‌بینانه | کشف RPC، نگاشت فیلدها و پایداری نشست کار واقعی است. تخمین «نیم‌روز» برای T2 ریسکی است. |
| `aiobale-py` v0.3.9 فعال است و نام `aiobale` را پکیج خالی گرفته | ❌ تأییدنشده / احتمالاً غلط | `aiobale-py` را پیدا نکردم. `aiobale` یک کتابخانه واقعی است (MIT، مستندات `docs.aiobale.ir`، نسخه‌های 0.1.x). فورک‌هایی مثل Autobale می‌گویند نسخه اصلی نگهداری نمی‌شود و باگ دارد. قبل از وابستگی به هر نام، `pip index versions` را اجرا کنید. |
| `bale-sdk` (کشف جدید) | 🔎 مهم | از کلاینت وب بله مهندسی معکوس شده؛ ۶۰۷ RPC در ۵۲ سرویس، پروتوهای بازیابی‌شده، احراز با JWT یا OTP؛ نسخه 0.1.1 (ژوئن ۲۰۲۶). **مجوز اختصاصی و «همه حقوق محفوظ»**. |
| تأخیر «زیر ۱ ثانیه» | ❌ رد | همه موافقند. اعداد ۱–۴ ثانیه (R2) و ۴–۸ ثانیه (Playwright) هم **اندازه‌گیری نشده‌اند**؛ فقط T5 عدد واقعی می‌دهد. |
| AI Chat بله ویس را «پیاده» می‌کند (R3) | ⚠️ تأییدنشده و نامطلوب | حتی اگر درست باشد، خروجی «متن + تفسیر مدل» است و برای consensus مناسب نیست. با کنار گذاشتن موافقم. |
| RAM ‏۳۵۰–۵۰۰ مگابایت برای Playwright | ✅ معقول | برای بات تک‌کاربره قابل‌تحمل است. |
| Playwright در ۱–۲ روز | ⚠️ خوش‌بینانه | وابسته به وجود دکمه وب، چالش‌های ورود و پایداری سلکتورها. |
| ساختار Gate / State-machine پلی‌بوک | ✅ قوی | بهترین بخش اسناد. ایراد: دروازه دقت (T7) بعد از ساخت آمده، در حالی که دستی و قبل از ساخت شدنی است. |
| ریسک بن، ToS و حریم خصوصی | ✅ درست و حیاتی | فقط پلی‌بوک به آن پرداخته است. |
| ترجیح WebSocket به POST gRPC (پلی‌بوک) | ✅ منطقی | کلاینت رسمی بله POST gRPC را عمدتاً برای چند متد احراز هویت استفاده می‌کند؛ استفاده زیاد از آن غیرعادی به نظر می‌رسد. |
| «Playwright اول» (سند مقایسه‌ای) در برابر «R2 اول» (پلی‌بوک) | ⚠️ هر دو مشروط | بدون نتیجه کشف روز اول قابل تصمیم نیست. |
### ۲.۲ ایرادهای کد adapter در سند اول
- کلید پیش‌فرض ضعیف (`bale_secure_token`) و مقایسه کلید با `!=` به‌جای مقایسه ثابت‌زمان.
- بلعیدن همه استثناها بدون لاگ.
- بازگرداندن ۲۰۰ با متن خالی: ممکن است بات آن را یک کاندید معتبر (خالی) در consensus بشمارد. باید در `bot/app/stt.py` بررسی شود که پاسخ ۵xx و متن خالی چطور مدیریت می‌شوند. توصیه: در خطا ۵۰۳ برگردد.
- نبود قفل ترتیبی، سقف روزانه و تأخیر تصادفی در خود adapter.
### ۲.۳ نکات فنی اصلاح‌شده
- فریم gRPC-Web پیشوند ۵ بایتی دارد (۱ بایت فلگ + ۴ بایت طول). این برای **POST gRPC-Web** درست است؛ اینکه فریم‌های WebSocket بله هم همان قالب را دارند فرض اثبات‌نشده‌ای است و باید در T2 دیده شود.
- هاست‌ها و نام ماژول‌هایی مثل `maviz-ws.bale.ai` و `SpeechTranscript` از تحلیل باندل آمده‌اند و در وب ایندکس نشده‌اند. باید با چشم در کپچر تأیید شوند.
---
## ۳. راهکارها
| راهکار | توضیح | رم | خلوص متن | ریسک | حکم |
|---|---|---|---|---|---|
| **A. مسیر رسمی** | درخواست API یا همکاری از بله | — | — | — | هزینه صفر؛ قبل از هر کار غیررسمی یک ایمیل بزنید |
| **B. RPC مستقیم** | کلاینت کاربری روی WebSocket با `bale-sdk` یا `aiobale` + RPC ترنسکریپت | ≈ ۲۰ MB | خالص | متوسط (تغییر پروتکل، بن) | **بهترین نتیجه فنی**، مشروط به کشف RPC |
| **C. Playwright** | Chromium headless روی `web.bale.ai` با پروفایل پایدار | ۳۵۰–۵۰۰ MB | خالص | کم–متوسط | مقاوم، **فقط اگر دکمه وب موجود باشد** |
| **D. AI Chat** | ویس به چت هوش مصنوعی بله و استخراج از پاسخ | ≈ ۲۰ MB | ❌ متن + تفسیر | بالا | کنار گذاشته شود |
| **E. سرویس ثالث** | پروکسی‌های STT با ادعای بله | — | نامشخص | حریم خصوصی/پایداری | کنار گذاشته شود |
| **F. اندروید امولاتور** | اپ بله + uiautomator | زیاد | خالص | بالا (شکننده) | آخرین چاره اگر B و C ممکن نشد |
---
## ۴. مسیر دقیق اجرا
### گام ۰: آزمون دقت دستی (نیم‌روز، بدون کد)
۳۰ ویس واقعی تدارکاتی (با رضایت فرستندگان؛ صداها را بلندمدت نگه ندارید) را در اپ بله دستی به متن تبدیل کنید و سه حالت را مقایسه کنید:
1. خروجی فعلی بات (whisper/سایرین + consensus)
2. فقط متن بله
3. consensus با اضافه‌شدن بله
سه معیار: WER متن، دقت استخراج اقلام (نام کالا، برند، تعداد، واحد) و تعداد موارد سخت لهجه‌ای که با بله درست شده‌اند.
**معیار پیشنهادی:** کاهش نسبی خطای استخراج حدود ۱۵٪ یا بیشتر، یا حل حداقل ۲ مورد سخت. در غیر این صورت **STOP**.
### گام ۱: کشف سریع (حدود ۳۰ دقیقه)
```bash
python -m venv .v && . .v/bin/activate
pip index versions aiobale-py # وجود یا عدم وجود را بررسی کنید
pip download bale-sdk --no-deps --no-binary :all: && tar xzf bale_sdk-*.tar.gz
grep -rniE "transcri|speech|asr|stt" bale_sdk-*/proto bale_sdk-*/docs | head -50
```
همچنین در پایتون `from bale import ALL_RPCS` را با همان کلیدواژه‌ها فیلتر کنید.
همزمان **T1**: یک بات تستی مستقل بسازید، یک ویس بفرستید و JSON کامل آپدیت را بخوانید. اگر فیلد ترنسکریپشن وجود داشت، پروژه کرالر لازم نیست.
### گام ۲: وب و کپچر (نیم‌روز تا یک روز)
1. با **یک حساب اختصاصی شرکت** روی `web.bale.ai` وارد شوید و بررسی کنید دکمه «تبدیل به متن» روی ویس هست یا نه (روی ویس دریافتی و ارسالی).
2. DevTools تب Network را با Preserve log و فیلتر WS و Fetch/XHR ضبط کنید و هنگام کلیک روی دکمه ذخیره کنید:
- URL کامل و ترابری (unary یا WS)
- هدر احراز
- `req.bin` و `resp.bin`
- زمان پاسخ (همان تأخیر واقعی ASR)
3. ۵ تا ۱۰ ویس متنوع (کوتاه، بلند، لهجه‌دار) را تکرار کنید و در `findings.md` خلاصه کنید.
4. دیکود آفلاین: پیشوند ۵ بایتی را بردارید، سپس `protoc --decode_raw` یا `protoscope`.
### گام ۳: انتخاب مسیر
| وضعیت | مسیر |
|---|---|
| RPC در `bale-sdk` یا کپچر پیدا شد | **B** |
| RPC پیدا نشد ولی دکمه وب هست | **C** |
| هیچ‌کدام | **F** یا توقف |
| ترنسکرایب نیاز به امضا یا اثر انگشت مرورگر دارد | **C** |
**الگوی پیشنهادی برای B:** بات خودتان با `sendVoice(file_id)` ویس را برای حساب کرالر بفرستد (کرالر یک‌بار بات را `/start` کرده). این‌طور نیازی به دانلود و آپلود مجدد نیست و `message_id` بومی در اختیار کرالر است. پس از دریافت متن، پیام حذف شود. اگر RPC به `message_id` نیاز نداشت، مستقیم فایل یا بایت داده شود.
**مجوز:** `aiobale` مجوز MIT دارد ولی نگهداری نمی‌شود (فورک‌ها: Autobale و BaleClient). `bale-sdk` مجوز اختصاصی دارد؛ برای استفاده تجاری از نویسنده اجازه بگیرید.
### گام ۴: ساخت adapter سخت‌شده
قرارداد OpenAI-compatible (همان `POST /v1/audio/transcriptions` که بات انتظار دارد) و موتور قابل‌سوییچ با `BALE_STT_ENGINE=grpc|playwright`. پیشنهاد کد:
```python
import os, secrets, asyncio, logging
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
log = logging.getLogger("bale-stt")
app = FastAPI()
KEY = os.environ["BALE_STT_INTERNAL_KEY"] # بدون مقدار پیش‌فرض
DAILY_CAP = int(os.environ.get("BALE_STT_DAILY_CAP", "100"))
lock = asyncio.Lock() # اجرای ترتیبی
count_today = 0 # در عمل با ریست روزانه
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.post("/v1/audio/transcriptions")
async def transcribe(file: UploadFile = File(...), model: str = Form("bale-stt"),
language: str = Form("fa"), authorization: str = Header("")):
if not secrets.compare_digest(authorization, f"Bearer {KEY}"):
raise HTTPException(401)
if count_today >= DAILY_CAP:
raise HTTPException(429)
async with lock:
try:
text = await run_engine(await file.read()) # + تأخیر تصادفی ۱ تا ۳ ثانیه
except Exception:
log.exception("bale stt failed")
raise HTTPException(503) # تا بات فقط همین کاندید را حذف کند
if not text:
raise HTTPException(503)
return {"text": text}
```
اتصال به بات (بدون تغییر کد):
```env
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=<INTERNAL_KEY>
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
```
> پرووایدر فقط وقتی فعال می‌شود که هم `BASE_URL` و هم `API_KEY` پر باشند.
### گام ۵: عملیات و ایمنی
- فقط **شماره اختصاصی شرکت**؛ هرگز شماره شخصی.
- `session_file` یا پروفایل مرورگر روی ولوم داکر و هرگز در گیت.
- **OTP تنها گام انسانی** است. در ابطال نشست: هشدار به ادمین و هدایت ترافیک به سایر پرووایدرها.
- اجرای ترتیبی، فاصله تصادفی ۱ تا ۳ ثانیه، سقف روزانه، و ترجیح WebSocket به POST gRPC.
- حذف فوری پیام اسکرچ پس از استخراج و عدم نگهداری فایل صوتی روی دیسک.
- پایش: نرخ موفقیت، p50 و p95 تأخیر، انواع خطا، وضعیت نشست. هشدار اگر نرخ خطا در ۳۰ دقیقه بیش از ۲۰٪ شد.
- **Rollback:** خالی کردن `STT_PROVIDER_3_*` در env بات و توقف کانتینر. بات فوراً به وضعیت قبل برمی‌گردد.
- سکرت‌هایی که در `docker-compose.yml` کامیت شده‌اند (توکن بات، کلید DeepSeek) باید rotate شوند و `DRAFT_TOKEN_SECRET` پیش‌فرض ضعیف را عوض کنید.
- روی برنچ‌های دیگران (`prod-v7` و غیره) دخالت نکنید و بدون تأیید مالک چیزی پوش نشود.
### گام ۶: اندازه‌گیری و سواک
- **T5:** ۵۰ تا ۱۰۰ ویس ترتیبی با فاصله انسانی؛ پذیرش پیشنهادی: موفقیت ≥ ۹۵٪ و p95 ≤ ۸ ثانیه.
- **سواک یک‌هفته‌ای** با حجم کم (حدود ۱۰۰ ترنسکرایب در روز): ثبت تعداد ورود مجدد، چالش‌های امنیتی و عمر واقعی توکن.
---
## ۵. دروازه‌های تصمیم (خلاصه)
| دروازه | شرط عبور | در صورت شکست |
|---|---|---|
| G0 دقت دستی | بهبود ≈ ۱۵٪ یا ۲ مورد سخت | **STOP** |
| G1 (T1) | فیلد ترنسکریپشن نبود | اگر بود: پایان، مستقیم وصل شود |
| G2 کشف | RPC یا دکمه وب پیدا شد | F یا توقف |
| G3 پروتوتایپ | درخواست ساخته و پاسخ خوانده شد | سوییچ به C |
| G4 نرخ و پایداری | ≥ ۹۵٪ موفقیت، p95 ≤ ۸ ثانیه | کاهش نرخ و backoff؛ در صورت بن: توقف |
| G6 ToS | تصمیم مکتوب مدیریت | **STOP** یا حجم بسیار محدود |
| G7 سواک | نشست سالم یک هفته | rollback و بازگشت به G4 |
---
## ۶. زمان‌بندی واقع‌بینانه
| مرحله | زمان |
|---|---|
| گام ۰ و ۱ (دقت دستی + کشف سریع) | نیم‌روز |
| گام ۲ (وب و کپچر) | ۱ روز |
| ساخت B یا C | ۲ تا ۴ روز |
| T5 و سواک | ۱ هفته |
---
## ۷. رجیستر ریسک
| ریسک | احتمال | اثر | مقابله |
|---|---|---|---|
| بن یا rate-limit حساب | متوسط | توقف مسیر | حجم کم، WS-first، سقف روزانه، حساب اختصاصی قابل‌تعویض |
| تغییر پروتکل یا UI بله | متوسط | قطع سرویس | هشدار خطا، فالبک، سوییچ موتور با env، rollback با env |
| انقضای نشست و نیاز به OTP | بالا (دوره‌ای) | توقف موقت | ولوم نشست + هشدار + runbook ورود انسانی |
| دکمه وب وجود ندارد | نامعلوم | ناممکن‌شدن C | بررسی در گام ۲ قبل از ساخت |
| دقت ناکافی نسبت به هزینه | نامعلوم | هزینه بی‌فایده | G0 قبل از ساخت |
| ToS / مسائل حقوقی | نامعلوم | توقف اجباری | تصمیم مکتوب مدیریت (G6)؛ ایمیل به بله (A) |
| مجوز اختصاصی `bale-sdk` | بالا | ممنوعیت استفاده | اجازه از نویسنده یا استفاده از `aiobale` (MIT) |
| حریم خصوصی صدا | کم | اعتباری | حذف فوری پیام‌ها، عدم نگهداری فایل |
| وابستگی به یک فرد نگهدارنده | متوسط | رکود | مستندسازی و مالک رسمی |
---
## ۸. دروازه‌های انسانی
1. OTP ورود حساب (بار اول و هر انقضا).
2. تصمیم ToS / حقوقی.
3. جمع‌آوری نمونه ویس واقعی با رضایت.
4. تأیید نهایی استقرار و مرج توسط مهندس ارشد.
---
## ۹. توصیه نهایی
1. **همین هفته:** گام ۰ (دقت دستی) + ایمیل به بله + گام ۱ (کشف سریع و T1).
2. **اگر دقت توجیه داشت:** گام ۲ و انتخاب B یا C بر اساس شواهد.
3. **معماری:** adapter با قرارداد OpenAI و موتور قابل‌سوییچ؛ بات دست‌نخورده می‌ماند.
4. **تصمیم مدیریتی** درباره ToS قبل از استقرار.
5. اگر هر کدام از G0 یا G6 شکست خورد، **توقف** و ادامه با پرووایدرهای فعلی.
---
## منابع
- aiobale (PyPI): https://pypi.org/project/aiobale
- bale-sdk (PyPI): https://pypi.org/project/bale-sdk/
- Autobale (فورک aiobale): https://pypi.org/project/autobale/
- خبر انتشار نسخه بله با قابلیت تبدیل پیام صوتی به متن (خبرگزاری آنا): https://ana.ir/fa/news/994908
- مستندات رسمی بات بله: https://docs.bale.ai
@@ -0,0 +1,159 @@
# گزارش فنی و امکان‌سنجی: ادغام قابلیت صوت به متن (STT) بله در سامانه تدارکات هوشمند
> [!NOTE]
> **مخاطب گزارش:** تیم فنی، معماران سیستم و مدیران محصول
> **موضوع:** بررسی، مقایسه دو رویکرد فنی و نقشه راه پیاده‌سازی اتصال به موتور تبدیل ویس به متن پیام‌رسان «بله»
> **هدف نهایی:** افزودن بله به عنوان یکی از مدل‌های کاندید در خط لوله STT چندمدله (Multi-Provider STT) جهت افزایش دقت استخراج اقلام تدارکات
---
## ۱. خلاصه مدیریتی (Executive Summary)
سامانه هوشمند تدارکات در حال حاضر مجهز به موتور پردازش موازی صوت (**Multi-Provider STT**) است که قادر است فایل‌های صوتی دریافتی از کاربران را هم‌زمان به چندین ارائه‌دهنده (نظیر Whisper، Soniox یا سرویس‌های ابری) ارسال کرده و از طریق مدل تحلیلی DeepSeek یک خروجی یکپارچه و دقیق (**Consensus Text**) به همراه اقلام استعلام استخراج کند.
برای افزایش هرچه بیشتر دقت در زبان فارسی و لهجه‌های محلی، استفاده از **موتور صوت‌به‌متن بومی پیام‌رسان بله** به عنوان یکی از مدل‌های کاندید مطرح شده است. از آن‌جا که پلتفرم توسعه‌دهندگان بله (Bale Bot API) متد مستقیمی برای فراخوانی API ترنسکرایب ارائه نمی‌دهد، دو روش اجرایی اصلی برای دستیابی به این موتور وجود دارد:
1. **روش اول (اتوماسیون رابط کاربری وب / Headless Browser):** شبیه‌سازی رفتار کاربر در مرورگر و کلیک روی دکمه تبدیل وویس.
2. **روش دوم (مهندسی معکوس پروتکل کلاینت / Direct Client API):** شبیه‌سازی درخواست‌های شبکه (WebSocket/HTTP) کلاینت بله بدون نیاز به مرورگر.
---
## ۲. پس‌زمینه فنی زیرساخت بله (Technical Context)
| بخش | وضعیت دسترسی | توضیحات |
| :--- | :--- | :--- |
| **Bale Bot API (رسمی)** | فاقد متد STT | فقط امکان دانلود فایل صوتی خام (`voice` / `audio`) با `file_id` را فراهم می‌کند. |
| **کلاینت‌های کاربری بله (Web / Android)** | دارای موتور فعال STT | روی هر ویس یک دکمه تبدیل صوت به متن وجود دارد که با فشرده شدن آن، صوت روی سرورهای بله به متن تبدیل می‌شود. |
| **دستیار هوش مصنوعی بله** | پشتیبانی از ورودی صوتی | کاربر با ارسال ویس به چت هوش مصنوعی بله، متن پیاده‌شده به همراه پاسخ تحلیلی را دریافت می‌کند. |
**نتیجه:** برای بهره‌برداری از این قابلیت، بات تدارکات باید در لایه ارتباطی خود، یک نشست کاربری (User Session) در بله داشته باشد تا بتواند خدمات تبدیل صوت کلاینت را فراخوانی کند.
---
## ۳. روش اول: اتوماسیون رابط کاربری وب (Headless Browser Scraping)
در این رویکرد، یک مرورگر کنترل‌شده بدون واسط گرافیکی (مانند Chromium تحت فریم‌ورک **Playwright**) در یک کانتینر اختصاصی داکر مستقر شده و در نسخه وب بله (`web.bale.ai`) فعال نگه داشته می‌شود.
### معماری جریان داده (Architecture Flow)
```mermaid
sequenceDiagram
autonumber
participant Bot as Procurement Bot
participant Worker as Browser Worker (Playwright)
participant BaleWeb as web.bale.ai (Chromium)
participant BaleServer as سرورهای پردازش صوت بله
Bot->>Worker: ارسال فایل صوتی (voice.mp3)
Worker->>BaleWeb: بارگذاری فایل در چت کلاینت
BaleWeb->>BaleServer: ارسال صوت و درخواست تبدیل
BaleServer-->>BaleWeb: بازگرداندن متن پیاده‌شده به صفحه
Worker->>BaleWeb: استخراج متن از DOM (.transcription-text)
Worker-->>Bot: بازگرداندن متن خروجی به خط لوله STT
```
### مؤلفه‌های پیاده‌سازی:
1. **مدیریت نشست پایدار (Persistent Profile):**
- پوشه پروفایل مرورگر (`userDataDir`) بر روی یک داکر ولوم ماندگار (`/data/bale_browser_session`) قرار می‌گیرد.
- ورود اولیه فقط یک‌بار توسط تیم فنی با شماره تلفن اختصاصی و کد OTP پیامکی انجام می‌شود و کوکی‌ها و توکن‌های LocalStorage/IndexedDB برای همیشه حفظ می‌گردند.
2. **انتظار هوشمند بر اساس رخدادهای DOM:**
- استفاده از سلکتورهای مقاوم با `waitForSelector` یا `MutationObserver` روی باکس متن ترنسکرایب.
3. **مزایا و معایب:**
- 🟢 **تضمین تطابق با سرور:** چون مرورگر رسمی در حال اجرا است، هیچ‌گونه ریسک ناسازگاری پکت‌های شبکه وجود ندارد.
- 🟢 **پایداری در برابر تغییرات داخلی API:** حتی اگر بله فریم‌ورک پکت‌های وب‌سوکت خود را عوض کند، تا زمانی که دکمه در UI وجود دارد سیستم کار می‌کند.
- 🔴 **مصرف بالای منابع:** هر نمونه Chromium حدود ۳۰۰ تا ۵۰۰ مگابایت رم اشغال می‌کند.
- 🔴 **تاخیر زمانی (Latency):** فرآیند رندر صفحه، شبیه‌سازی آپلود و رویداد کلیک حدود ۳ تا ۶ ثانیه زمان می‌برد.
---
## ۴. روش دوم: مهندسی معکوس شبکه و پروتکل کلاینت (Direct Client API)
در این روش، بدون استفاده از مرورگر سنگین، کلاینت پایتونی سبک ما مستقیماً همان درخواست‌های سوکت یا HTTP کلاینت رسمی بله را بازسازی و فراخوانی می‌کند.
### معماری جریان داده (Architecture Flow)
```mermaid
sequenceDiagram
autonumber
participant Bot as Procurement Bot
participant Adapter as Bale Client Protocol Adapter (Python)
participant BaleCore as هسته سرور بله (WebSocket / RPC)
Bot->>Adapter: ارسال بایت‌های فایل صوتی
Adapter->>BaleCore: فراخوانی مستقیم متد Transcribe (از طریق سوکت یا HTTP)
BaleCore-->>Adapter: پاسخ مستقیم باینری/JSON متن
Adapter-->>Bot: متن نهایی در کمتر از ۱ ثانیه
```
### مؤلفه‌های پیاده‌سازی:
1. **آنالیز ترافیک شبکه (Traffic Inspection):**
- تحلیل تب Network مرورگر در `web.bale.ai`:
- استخراج مسیر اتصال (غالباً `wss://api.bale.ai/v1/users/websocket` یا اندپوینت‌های REST).
- استخراج هدرهای نشست از `localStorage` کلاینت وب (مانند `access_token`, `device_id`, `session_key`).
- کشف پکت یا درخواست RPC مسئول تبدیل صوت (شامل شناسه پیام `message_id` و شناسه فایل صوتی `file_id`).
2. **کلاینت ناهمگام پایتون (`httpx` / `websockets`):**
- اتصال دائمی و سبک به زیرساخت پیام‌رسانی بله.
- ارسال مستقیم درخواست بدون بارگذاری فایل‌های گرافیکی، جاوااسکریپت و CSS مرورگر.
3. **مزایا و معایب:**
- 🟢 **سرعت خیره‌کننده (Ultra-low Latency):** پاسخگویی معمولاً در **کمتر از ۱ ثانیه** انجام می‌شود.
- 🟢 **مصرف ناچیز منابع (Lightweight):** مصرف حافظه رم زیر ۱۰ مگابایت.
- 🟢 **ظرفیت پردازش همزمان بالا (High Concurrency):** امکان ارسال همزمان ده‌ها درخواست بدون افت پرفورمنس.
- 🔴 **حساسیت به پروتکل:** در صورت بازنویسی پروتکل یا فیلدهای باینری کلاینت بله، ممکن است نیاز به به‌روزرسانی پکت‌ها باشد.
---
## ۵. ماتریس مقایسه جامع فنی (Technical Decision Matrix)
| معیار ارزیابی | روش اول: اتوماسیون وب (Playwright) | روش دوم: مهندسی معکوس پروتکل کلاینت | اهمیت برای سیستم |
| :--- | :---: | :---: | :---: |
| **تاخیر پاسخ‌دهی (Latency)** | ۳ تا ۶ ثانیه | **زیر ۱ ثانیه** ⚡️ | بسیار بالا (تاثیر مستقیم روی UX وویس) |
| **مصرف حافظه RAM** | ۳۵۰ الی ۵۰۰ مگابایت | **کمتر از ۱۵ مگابایت** 🪶 | متوسط به بالا |
| **هزینه پردازشی CPU** | متوسط تا بالا (رندرینگ) | بسیار پایین (درخواست شبکه) | متوسط |
| **پیچیدگی پیاده‌سازی اولیه** | کم (طی چند ساعت با سلکتور) | متوسط (نیازمند اسنیف و دیکود پکت) | موقت |
| **پایداری نشست کاربری (Session)** | ذخیره در پوشه کاربری مرورگر | ذخیره توکن در متغیر محیطی / دیتابیس | بالا |
| **ریسک تغییرات توسط بله** | کم (مقاوم در برابر تغییر پروتکل) | نیازمند پایش در تغییر نسخه‌های ماژور | متوسط |
| **قابلیت مقیاس‌پذیری (Scalability)** | محدود (محدودیت منابع مرورگر) | نامحدود (ایسنک و چندنخی) | بالا |
---
## ۶. نحوه اتصال به پایپ‌لاین STT چندمدله فعلی
سامانه تدارکات در حال حاضر قابلیت دریافت چند کاندیدا را دارد. با اتصال بله، خروجی به شکل زیر در پردازش قرار می‌گیرد:
```mermaid
graph TD
Voice[وویس دریافتی کاربر از بله] --> FFMPEG[تبدیل یک‌باره به MP3]
FFMPEG --> S1[مدل ۱: Whisper-Large]
FFMPEG --> S2[مدل ۲: Soniox / OpenRouter]
FFMPEG --> S3[مدل ۳: موتور صوت هوش مصنوعی بله]
S1 --> Aggregator[تجمیع نتایج کاندیداها]
S2 --> Aggregator
S3 --> Aggregator
Aggregator --> DeepSeek[تحلیلگر هوشمند DeepSeek]
DeepSeek --> Final[متن اصلاح‌شده Consensus Text + استخراج اقلام استعلام JSON]
```
---
## ۷. نقشه راه پیشنهادی دو مرحله‌ای برای تیم توسعه (Recommended Roadmap)
> [!TIP]
> **استراتژی برد-برد (Fast Win + High Performance):**
> برای اینکه از سریع‌ترین حالت ممکن با کمترین ریسک بهره‌مند شویم، فرآیند اجرای فازبندی‌شده زیر پیشنهاد می‌شود:
### فاز ۱: ممیزی و اکتشاف ترافیک شبکه (کمتر از ۱ روز کاری)
1. باز کردن `web.bale.ai` با تب Network و کلیک روی دکمه تبدیل ویس.
2. تعیین ماهیت درخواست:
- اگر درخواست یک **HTTP POST ساده** یا یک فریم متنی وب‌سوکت با Authorization Header باشد:
👉 **روش دوم بلافاصله پیاده‌سازی می‌شود** و به بالاترین سرعت و بهره‌وری ممکن می‌رسیم.
- اگر پروتکل شامل امضاها و فریم‌های رمزگذاری‌شده پیچیده باشد:
👉 به سراغ اجرای فاز ۲ (روش اول) می‌رویم.
### فاز ۲: استقرار سرویس اتوماسیون (Fallback تضمین‌شده)
1. ساخت کانتینر داکر سبک `playwright-worker` در کنار کانتینر `procurement-bot`.
2. اتصال امن این کانتینر از طریق یک اندپوینت داخلی ساده:
`POST http://bale-stt-worker:8080/transcribe`
3. قرار گرفتن این سرویس به عنوان مدل سوم در متغیر محیطی:
`PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt-worker:8080/v1`
---
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
@@ -0,0 +1,137 @@
# گزارش فنی و امکان‌سنجی: ادغام قابلیت صوت به متن (STT) بله در سامانه تدارکات هوشمند
> [!NOTE]
> **مخاطب گزارش:** تیم فنی، معماران سیستم و مدیران محصول
> **موضوع:** بررسی، مقایسه دو رویکرد فنی و نقشه راه پیاده‌سازی اتصال به موتور تبدیل ویس به متن پیام‌رسان «بله»
> **هدف نهایی:** افزودن بله به عنوان یکی از مدل‌های کاندید در خط لوله STT چندمدله (Multi-Provider STT) جهت افزایش دقت استخراج اقلام تدارکات
---
## ۱. خلاصه مدیریتی (Executive Summary)
سامانه هوشمند تدارکات در حال حاضر مجهز به موتور پردازش موازی صوت (**Multi-Provider STT**) است که قادر است فایل‌های صوتی دریافتی از کاربران را هم‌زمان به چندین ارائه‌دهنده (نظیر Whisper، Soniox یا سرویس‌های ابری) ارسال کرده و از طریق مدل تحلیلی DeepSeek یک خروجی یکپارچه و دقیق (**Consensus Text**) به همراه اقلام استعلام استخراج کند.
برای افزایش هرچه بیشتر دقت در زبان فارسی و لهجه‌های محلی، استفاده از **موتور صوت‌به‌متن بومی پیام‌رسان بله** به عنوان یکی از مدل‌های کاندید مطرح شده است. از آن‌جا که پلتفرم توسعه‌دهندگان بله (Bale Bot API) متد مستقیمی برای فراخوانی API ترنسکرایب ارائه نمی‌دهد، دو روش اجرایی اصلی برای دستیابی به این موتور وجود دارد:
1. **روش اول (اتوماسیون رابط کاربری وب / Headless Browser):** شبیه‌سازی رفتار کاربر در مرورگر و کلیک روی دکمه تبدیل وویس.
2. **روش دوم (مهندسی معکوس پروتکل کلاینت / Direct Client API):** شبیه‌سازی درخواست‌های شبکه (WebSocket/HTTP) کلاینت بله بدون نیاز به مرورگر.
---
## ۲. پس‌زمینه فنی زیرساخت بله (Technical Context)
| بخش | وضعیت دسترسی | توضیحات |
| :--- | :--- | :--- |
| **Bale Bot API (رسمی)** | فاقد متد STT | فقط امکان دانلود فایل صوتی خام (`voice` / `audio`) با `file_id` را فراهم می‌کند. |
| **کلاینت‌های کاربری بله (Web / Android)** | دارای موتور فعال STT | روی هر ویس یک دکمه تبدیل صوت به متن وجود دارد که با فشرده شدن آن، صوت روی سرورهای بله به متن تبدیل می‌شود. |
| **دستیار هوش مصنوعی بله** | پشتیبانی از ورودی صوتی | کاربر با ارسال ویس به چت هوش مصنوعی بله، متن پیاده‌شده به همراه پاسخ تحلیلی را دریافت می‌کند. |
**نتیجه:** برای بهره‌برداری از این قابلیت، بات تدارکات باید در لایه ارتباطی خود، یک نشست کاربری (User Session) در بله داشته باشد تا بتواند خدمات تبدیل صوت کلاینت را فراخوانی کند.
---
## ۳. روش اول: اتوماسیون رابط کاربری وب (Headless Browser Scraping)
در این رویکرد، یک مرورگر کنترل‌شده بدون واسط گرافیکی (مانند Chromium تحت فریم‌ورک **Playwright**) در یک کانتینر اختصاصی داکر مستقر شده و در نسخه وب بله (`web.bale.ai`) فعال نگه داشته می‌شود.
### معماری جریان داده (Architecture Flow)
```mermaid
sequenceDiagram
autonumber
participant Bot as Procurement Bot
participant Worker as Browser Worker (Playwright)
participant BaleWeb as web.bale.ai (Chromium)
participant BaleServer as سرورهای پردازش صوت بله
Bot->>Worker: ارسال فایل صوتی (voice.mp3)
Worker->>BaleWeb: بارگذاری فایل در چت کلاینت
BaleWeb->>BaleServer: ارسال صوت و درخواست تبدیل
BaleServer-->>BaleWeb: بازگرداندن متن پیاده‌شده به صفحه
Worker->>BaleWeb: استخراج متن از DOM (.transcription-text)
Worker-->>Bot: بازگرداندن متن خروجی به خط لوله STT
```
### مؤلفه‌های پیاده‌سازی:
1. **مدیریت نشست پایدار (Persistent Profile):**
- پوشه پروفایل مرورگر (`userDataDir`) بر روی یک داکر ولوم ماندگار (`/data/bale_browser_session`) قرار می‌گیرد.
- ورود اولیه فقط یک‌بار توسط تیم فنی با شماره تلفن اختصاصی و کد OTP پیامکی انجام می‌شود و کوکی‌ها و توکن‌های LocalStorage/IndexedDB برای همیشه حفظ می‌گردند.
2. **انتظار هوشمند بر اساس رخدادهای DOM:**
- استفاده از سلکتورهای مقاوم با `waitForSelector` یا `MutationObserver` روی باکس متن ترنسکرایب.
3. **مزایا و معایب:**
- 🟢 **تضمین تطابق با سرور:** چون مرورگر رسمی در حال اجرا است، هیچ‌گونه ریسک ناسازگاری پکت‌های شبکه وجود ندارد.
- 🟢 **پایداری در برابر تغییرات داخلی API:** حتی اگر بله فریم‌ورک پکت‌های وب‌سوکت خود را عوض کند، تا زمانی که دکمه در UI وجود دارد سیستم کار می‌کند.
- 🔴 **مصرف بالای منابع:** هر نمونه Chromium حدود ۳۰۰ تا ۵۰۰ مگابایت رم اشغال می‌کند.
- 🔴 **تاخیر زمانی (Latency):** فرآیند رندر صفحه، شبیه‌سازی آپلود و رویداد کلیک حدود ۳ تا ۶ ثانیه زمان می‌برد.
---
## ۴. روش دوم: مهندسی معکوس شبکه و پروتکل کلاینت (Direct Client Protocol / gRPC-Web)
در این روش، بدون استفاده از مرورگر سنگین، تلاش می‌شود تا کلاینت پایتونی مستقیماً همان درخواست‌های شبکه کلاینت رسمی بله را شبیه‌سازی و فراخوانی کند.
### یافته‌های واقعی حاصل از بازرسی و تحلیل سورس‌کد لایو وب بله (`web.bale.ai`):
تیم ما اسکریپت‌های باندل تولیدی بله (`/static/js/index.4aefbadad3.js` و بسته‌های وابسته) را مستقیماً تحلیل کرد که نتایج فنی مهم زیر حاصل شد:
1. **پروتکل زیرساختی:** بله از APIهای متنی ساده (مانند REST یا JSON) استفاده **نمی‌کند**؛ بلکه کل ارتباطات کلاینت وب بر پایه **gRPC-Web همراه با Protocol Buffers دودویی (`@bufbuild/protobuf`)** پیاده‌سازی شده است.
2. **آدرس‌های Gateway سرورهای بله:**
- سرور gRPC پروداکشن: `https://maviz-ws.bale.ai`
- اتصال وب‌سوکت پروداکشن: `wss://maviz-ws.bale.ai/ws/?uid={user_id}`
- محیط‌های آزمایشی: `beta-ws.bale.ai` و `next-ws.bale.ai`
3. **شناسه‌ها و فیچرهای کشف‌شده در کد:**
- شناسه ماژول پیاده‌سازی صوت: `u.SpeechTranscript = "SpeechTranscript"`
- ماژول چت هوش مصنوعی: `u.AIChat = "AIChat"` با روت صفحه `/${L.AI_CHAT}`
- دامنه‌های مربوطه: `domain_chat_ai: "app.all.domain_chat_ai"` و `chat_ai_ttl`
### ارزیابی فنی و چالش‌های روش دوم:
- **پیچیدگی بالای پیاده‌سازی:** به دلیل استفاده از پکت‌های فشرده دودویی Protobuf، برای فراخوانی مستقیم در پایتون باید فایل‌های تعاریف اینترفیس (`.proto`) بله به صورت کامل مهندسی معکوس و دیکود شوند و فریم‌های ۵ بایتی استاندارد gRPC-Web در کلاینت پایتون کدگذاری گردند.
- **ریسک شکنندگی در تغییر اسکیما:** با کوچک‌ترین تغییر در شماره فیلدهای Protobuf یا متدهای سرور بله در نسخه‌های جدید، کلاینت مهندسی معکوس‌شده از کار می‌افتد.
---
## ۵. ماتریس مقایسه جامع فنی (Technical Decision Matrix)
| معیار ارزیابی | روش اول: اتوماسیون وب (Playwright) | روش دوم: مهندسی معکوس پروتکل کلاینت | اهمیت برای سیستم |
| :--- | :---: | :---: | :---: |
| **تاخیر پاسخ‌دهی (Latency)** | ۳ تا ۶ ثانیه | **زیر ۱ ثانیه** ⚡️ | بسیار بالا (تاثیر مستقیم روی UX وویس) |
| **مصرف حافظه RAM** | ۳۵۰ الی ۵۰۰ مگابایت | **کمتر از ۱۵ مگابایت** 🪶 | متوسط به بالا |
| **هزینه پردازشی CPU** | متوسط تا بالا (رندرینگ) | بسیار پایین (درخواست شبکه) | متوسط |
| **پیچیدگی پیاده‌سازی اولیه** | کم (طی چند ساعت با سلکتور) | متوسط (نیازمند اسنیف و دیکود پکت) | موقت |
| **پایداری نشست کاربری (Session)** | ذخیره در پوشه کاربری مرورگر | ذخیره توکن در متغیر محیطی / دیتابیس | بالا |
| **ریسک تغییرات توسط بله** | کم (مقاوم در برابر تغییر پروتکل) | نیازمند پایش در تغییر نسخه‌های ماژور | متوسط |
| **قابلیت مقیاس‌پذیری (Scalability)** | محدود (محدودیت منابع مرورگر) | نامحدود (ایسنک و چندنخی) | بالا |
---
## ۶. نحوه اتصال به پایپ‌لاین STT چندمدله فعلی
سامانه تدارکات در حال حاضر قابلیت دریافت چند کاندیدا را دارد. با اتصال بله، خروجی به شکل زیر در پردازش قرار می‌گیرد:
```mermaid
graph TD
Voice[وویس دریافتی کاربر از بله] --> FFMPEG[تبدیل یک‌باره به MP3]
FFMPEG --> S1[مدل ۱: Whisper-Large]
FFMPEG --> S2[مدل ۲: Soniox / OpenRouter]
FFMPEG --> S3[مدل ۳: موتور صوت هوش مصنوعی بله]
S1 --> Aggregator[تجمیع نتایج کاندیداها]
S2 --> Aggregator
S3 --> Aggregator
Aggregator --> DeepSeek[تحلیلگر هوشمند DeepSeek]
DeepSeek --> Final[متن اصلاح‌شده Consensus Text + استخراج اقلام استعلام JSON]
```
---
## ۷. نقشه راه پیشنهادی دو مرحله‌ای برای تیم توسعه (Recommended Roadmap)
> [!TIP]
> **نتیجه تست و تحلیل تجربی ترافیک:**
> بر اساس آنالیز دقیق سورس‌کد لایو `web.bale.ai` که انجام شد، ماهیت درخواست‌های بله مشخص گردید: ارتباطات به صورت فریم‌های دودویی **Protobuf + gRPC-Web** است. بنابراین مهندسی معکوس پکت‌ها نیازمند استخراج و بازسازی مستمر فایل‌های `.proto` اختصاصی بله است که نگهداری آن را بسیار شکننده می‌کند.
>
> **نتیجه‌گیری و پیشنهاد نهایی:**
> مناسب‌ترین، مطمئن‌ترین و پایدارترین گزینه برای استقرار در محصول، **روش اول (اتوماسیون مرورگر با Playwright Worker)** است:
> ۱. مرورگر به صورت طبیعی از وب‌سوکت و پروتکل Protobuf بله پشتیبانی می‌کند و نیازی به رمزگشایی پکت‌ها نیست.
> ۲. با یک سشن پایدار در داکر، وویس در صفحه پیاده شده و متن خروجی به شکل مطمئن به خط لوله Multi-STT تحویل داده می‌شود.
> ۳. پیاده‌سازی آن در قالب یک کانتینر ایزوله (`bale-stt-worker`) با یک اندپوینت سبک `POST /transcribe` سریعاً انجام می‌پذیرد.
---
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
@@ -0,0 +1,499 @@
# سند جامع، تحلیلی و عملیاتی کرالر تبدیل صوت‌به‌متن (STT) بله
> **نسخه:** ۳.۰ (جامع، تجمیعی و نهایی)
> **مخاطب اول:** مدیر پروژه (تصمیم‌گیری کلان، ماتریس‌های مقایسه‌ای، جریان‌های داده و فلوهای اجرایی)
> **مخاطب دوم:** ایجنت هوش مصنوعی مجری (شناخت پروژه، معماری فنی، ساختار پروتکل، کدهای مرجع و چک‌لیست راه‌اندازی)
> **وضعیت نیازمندی‌ها:** محدودیت حریم خصوصی موقتاً برداشته شده؛ صحت و کیفیت بالای موتور بله در استعلام‌های تدارکاتی توسط کاربر اثبات شده؛ وجود دکمه تبدیل متن در وب (`web.bale.ai`) تأیید شده؛ رویکرد Playwright به عنوان **فالبک آخر (Last Resort)** و رویکرد RPC به عنوان **اولویت اول** تعیین شده است؛ هیچ تخمین زمانی تقویمی وجود ندارد چون تمامی پیاده‌سازی‌ها توسط ایجنت انجام می‌شود.
---
# بخش اول: برای مدیر پروژه (دید کلان، مقایسه‌ها و فلوهای تصویری)
## ۱. صورت‌مسئله و دلایل توجیهی پروژه
در سامانه استعلام هوشمند تدارکات، کاربران پیام‌های صوتی (ویس) حاوی درخواست‌های پیچیده خرید (شامل نام کالا، برند، مشخصات فنی، مقادیر، واحدها و اصطلاحات بازار) ارسال می‌کنند.
بات تدارکات برای رونویسی این پیام‌ها از یک **خط‌لوله چندمدله (Multi-Provider STT Pipeline)** استفاده می‌کند؛ به این معنا که ویس همزمان به چند سرویس تبدیل صوت ارسال شده و متن‌های دریافت‌شده در اختیار یک مدل تحلیلی هوش مصنوعی (DeepSeek NLU) قرار می‌گیرد تا با ایجاد اجماع (Consensus)، خطاهای واژگانی برطرف و ساختار استاندارد استعلام استخراج گردد.
### چرا کرال کردن موتور بله ارزش بالایی دارد؟
1. **کیفیت اثبات‌شده در آزمون واقعی:** تست‌های تجربی روی ویس‌های واقعی استعلام تدارکات نشان داد که موتور تشخیص گفتار بله تسلط بسیار بالایی بر محاورات عامیانه فارسی، اصطلاحات بازاری و لهجه‌ها دارد.
2. **فقدان متد در رابط رسمی (Bot API):** در مستندات رسمی بله (`docs.bale.ai`)، شیء `Voice` ارسالی به ربات‌ها فاقد فیلد ترنسکریپت بوده و تنها مشخصاتی نظیر `file_id` را برمی‌گرداند؛ بنابراین دسترسی به این موتور صرفاً از طریق شبیه‌سازی نشست کاربری امکان‌پذیر است.
3. **تأیید وجود دکمه در وب بله:** بررسی محیط `web.bale.ai` اثبات کرد که قابلیت تبدیل ویس به متن در کلاینت تحت وب بله فعال است؛ این یافته امکان کپچر ترافیک و مهندسی معکوس و نیز استفاده از وب به عنوان پشتیبان را تضمین می‌کند.
4. **دستور مدیریت پروژه در چینش اولویت‌ها:**
* **اولویت اول:** ارتباط مستقیم در لایه شبکه و پروتکل کاربری (RPC / WebSocket) به دلیل مصرف ناچیز منابع (کمتر از ۲۰ مگابایت رم) و سرعت استخراج بالا.
* **فالبک آخر (Last Resort):** کرال کردن بصری صفحه چت با Playwright صرفاً در شرایطی که ارتباط شبکه‌ای به بن‌بست برسد.
---
## ۲. ماتریس مقایسه‌ای جامع ۸ راهکار موجود
برای ایجاد دید همه‌جانبه، تمامی راهکارهای مطرح در اکوسیستم فنی در جدول زیر کالبدشکافی شده‌اند (بدون ستون‌های زائد زمانی؛ با تمرکز بر رفتار سیستمی):
| # | راهکار فنی | نحوه عملکرد | مصرف حافظه RAM | خلوص متن خروجی | پایداری در برابر تغییرات | ریسک عملیاتی | اولویت در پروژه ما |
| :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: |
| **۱** | **کلاینت شبکه و RPC بله (Web RPC / WS)** | فراخوانی مستقیم متد کشف‌شده `GetTranscript` در کلاینت کاربری پایتون | کمتر از ۲۰ مگابایت | متن خالص گفتار | متوسط (وابسته به ثبات متد) | احتمال نیاز به تمدید نشست (OTP) | ⭐⭐⭐ **اولویت اول و اصلی** |
| **۲** | **اتوماسیون Playwright در `web.bale.ai`** | مرورگر بدون سربرگ (Headless)، کلیک روی دکمه و خواندن متن از DOM | ۳۵۰ تا ۵۰۰ مگابایت | متن خالص گفتار | بالا (مقاوم به پکت‌های شبکه) | مصرف رم و سربار رندر المان‌ها | ⚠️ **فالبک نهایی (Last Resort)** |
| **۳** | **وب‌سرویس‌های تجاری ایرانی (فارس‌آوا / آوانگار)** | اتصال رسمی به API شرکت‌های پردازش صوت بومی (عامراندیش، پارت‌آی و...) | ناچیز (سمت سرور ثالث) | متن خالص گفتار | بسیار بالا (SLA تجاری) | ایجاد هزینه اشتراک ریالی برای دقایق صوت | ⭐⭐ **پیشنهاد به عنوان کاندید موازی** |
| **۴** | **مدل متن‌باز خودمیزبان (Faster-Whisper)** | استقرار مدل `whisper-v3-turbo` با واژگان تدارکات روی سرور داخلی | ۱ تا ۳ گیگابایت | متن خالص گفتار | پایدار مطلق (بدون وابستگی) | نیاز به پردازنده گرافیکی (GPU) مناسب | ⭐⭐ **پرووایدر پشتیبان و مستقل** |
| **۵** | **وب‌سرویس‌های واسط غیررسمی (MajidAPI و...)** | ارسال لینک ویس به پراکسی‌های عمومی رایگان | ناچیز (سمت سرور ثالث) | وابسته به موتور واسط | بسیار پایین و ناپایدار | فقدان SLA، احتمال قطعی ناگهانی و نشت صوت | ❌ **رد شده (غیرقابل اتکا)** |
| **۶** | **چت هوش مصنوعی بله (اوراکل)** | ارسال ویس به ربات رسمی هوش مصنوعی درون بله | کمتر از ۲۰ مگابایت | **ناخالص** (متن + تحلیل LLM) | بسیار پایین | پاسخ تفسیری، تاخیر ۵ تا ۱۵ ثانیه‌ای | ❌ **رد شده (تخریب اجماع)** |
| **۷** | **شبیه‌ساز اندروید (Appium / UIAutomator)** | اجرای اپلیکیشن اندروید بله روی ماشین مجازی و اسکرپ متن | بیش از ۲ گیگابایت | متن خالص گفتار | شکننده | سربار بسیار سنگین CPU/RAM سرور | ❌ **رد شده** |
| **۸** | **سرویس‌های ابری خارجی (OpenAI / Chirp 2)** | ارسال فایل به API رسمی Whisper یا Google Chirp | ناچیز (سمت سرور ثالث) | متن خالص گفتار | بسیار بالا | وابستگی به ارز و ریسک تحریم/اختلال شبکه | ⭐ **پرووایدر کمکی در صورت دسترسی** |
---
## ۳. فلوهای جامع تصویری (Architecture & Sequence Flows)
### فلو ۱: جریان کلان پردازش ویس در سیستم تدارکات (End-to-End System Flow)
این دیاگرام نشان می‌دهد ویس ارسالی کاربر چگونه در خط‌لوله موازی پردازش شده و کاندید بله بدون ایجاد وقفه در سیستم ثبت استعلام می‌نشیند:
```mermaid
sequenceDiagram
autonumber
actor User as کاربر تدارکات
participant MainBot as بات اصلی تدارکات (Bale Bot API)
participant STTPipeline as موتور چندمدله (bot/app/stt.py)
participant BaleAdapter as میکروسرویس آداپتور بله (bale-stt:8080)
participant OtherProviders as سایر پرووایدرها (OpenAI / محلی)
participant ConsensusNLU as مدل استخراج هوشمند (DeepSeek NLU)
User->>MainBot: ارسال پیام صوتی استعلام خرید
MainBot->>STTPipeline: دریافت ویس و تبدیل فرمت به MP3 (16kHz Mono)
par استعلام موازی از پرووایدرهای فعال
STTPipeline->>BaleAdapter: POST /v1/audio/transcriptions (بایت‌های ویس)
STTPipeline->>OtherProviders: POST /v1/audio/transcriptions
end
critical پردازش در کانتینر ایزوله کرالر بله
BaleAdapter->>BaleAdapter: کنترل صف ترتیبی و اعمال تاخیر رفتاری (Jitter)
BaleAdapter->>BaleAdapter: ارسال صوت به اکانت کرالر و فراخوانی متد GetTranscript
BaleAdapter-->>BaleAdapter: دریافت متن پیاده‌شده و حذف پیام اسکرچ
end
BaleAdapter-->>STTPipeline: بازگشت متن کاندید بله {"text": "..."}
OtherProviders-->>STTPipeline: بازگشت متن سایر کاندیدها
STTPipeline->>ConsensusNLU: ارسال تمام کاندیدها به مدل اجماع‌گیر
ConsensusNLU-->>MainBot: استخراج اقلام کالا، برند، مشخصات و تعداد
MainBot-->>User: ارسال پیش‌فاکتور استعلام یا تاییدیه به کاربر
```
---
### فلو ۲: جریان رله صوتی در کرالر (Relay Bot Pattern Flow)
برای جلوگیری از فرآیند پرریسک و پیچیده «آپلود مستقیم فایل از اکانت شخصی»، از الگوی **بات رله** استفاده می‌شود:
```mermaid
sequenceDiagram
autonumber
participant Adapter as هسته آداپتور FastAPI
participant RelayBot as بات رله اختصاصی (Bale Bot API)
participant BaleServer as سرورهای مرکزی بله
participant CrawlerUser as اکانت کاربری کرالر (WebSocket Client)
Adapter->>Adapter: دریافت فایل صوتی از خط‌لوله بات
Adapter->>RelayBot: فراخوانی sendVoice(chat_id=کرالر, voice=audio, caption=nonce)
RelayBot->>BaleServer: ارسال ویس با مجوز رسمی بات
BaleServer-->>CrawlerUser: دریافت رویداد پیام صوتی جدید (دارای Media و MessageId و OutPeer)
CrawlerUser->>BaleServer: فراخوانی مستقیم RPC: GetTranscript(Media, OutPeer, MessageId)
BaleServer-->>CrawlerUser: بازگشت پاسخ (mustWait / downloadSource)
opt در صورت فعال بودن mustWait
CrawlerUser->>CrawlerUser: پولینگ سبک یا انتظار دریافت رویداد تکمیلی
end
CrawlerUser->>BaleServer: فراخوانی DeleteMessage (حذف پیام برای حفظ بهداشت اکانت)
CrawlerUser-->>Adapter: تحویل متن خالص استخراج‌شده
```
---
### فلو ۳: فلو تاب‌آوری و عدم توقف بات (Fail-Safe & Graceful Degradation)
این فلو تضمین می‌کند که حتی در بدترین سناریوها (تغییر پروتکل بله، بن شدن اکانت یا قطعی سرور بله)، سیستم اصلی تدارکات دچار حتی یک ثانیه قطعی نخواهد شد:
```mermaid
flowchart TD
Start["کاربر ویس می‌فرستد"] --> Multi["اجرای همزمان transcribe_multi() در stt.py"]
Multi --> CallBale["ارسال درخواست به آداپتور بله"]
Multi --> CallOthers["ارسال درخواست به سایر پرووایدرها"]
CallBale --> CheckBale{"آیا خطایی رخ داد؟ (بن/تغییر پروتکل/تایم‌اوت)"}
CheckBale -- "بله" --> Resp503["آداپتور کد HTTP 503 برمی‌گرداند"]
Resp503 --> LogWarn["تابع _call_single_stt هشدار ثبت کرده و None بازمی‌گرداند"]
LogWarn --> IgnoreBale["کاندید بله بدون اختلال از لیست اجماع حذف می‌شود"]
CheckBale -- "خیر" --> Resp200["آداپتور متن رونویسی‌شده را تحویل می‌دهد"]
Resp200 --> AddBale["متن بله به لیست کاندیدها اضافه می‌شود"]
CallOthers --> RespOthers["متن‌های سایر پرووایدرها دریافت می‌شوند"]
IgnoreBale --> NLU["اجماع NLU روی سایر متن‌های موجود انجام می‌شود"]
AddBale --> NLU
RespOthers --> NLU
NLU --> Done["استعلام با موفقیت ثبت و پاسخ به کاربر داده می‌شود"]
subgraph Rollback["عملیات قطع دائمی (در صورت لزوم)"]
Env["خالی کردن متغیرهای PROCUREMENT_BOT_STT_PROVIDER_3_*"]
ZeroDowntime["سیستم بدون ری‌استارت یا دیپلوی مجدد به کار عادی ادامه می‌دهد"]
end
```
---
### فلو ۴: سلسله‌مراتب گزینش موتور در آداپتور (Engine Fallback Hierarchy)
```mermaid
graph TD
StartEngine["درخواست STT وارد آداپتور می‌شود"] --> ModeCheck{"بررسی تنظیم متغیر BALE_STT_ENGINE"}
ModeCheck -- "rpc (پیش‌فرض)" --> TryRPC["اجرای موتور E1: متد باینری GetTranscript"]
TryRPC --> CheckRPC{"آیا رونویسی با موفقیت انجام شد؟"}
CheckRPC -- "بله" --> ReturnText["بازگرداندن متن (HTTP 200)"]
CheckRPC -- "شکست در پروتکل شبکه" --> CheckFallback{"آیا فالبک خودکار مجاز است؟"}
CheckFallback -- "بله" --> TryPW["سوئیچ به موتور E2: اتوماسیون Playwright (فالبک آخر)"]
TryPW --> CheckPW{"آیا متن از وب استخراج شد؟"}
CheckPW -- "بله" --> ReturnText
CheckPW -- "خیر" --> Fail503["بازگرداندن خطای HTTP 503 (Fail-Soft)"]
CheckFallback -- "خیر" --> Fail503
ModeCheck -- "playwright" --> TryPW
```
---
# بخش دوم: برای ایجنت هوش مصنوعی مجری (Technical Execution Specification)
## ۱. شناخت ساختار کدهای موجود در پروژه
ایجنت باید بداند بات تدارکات فعلی در فایل‌های زیر پیاده‌سازی شده و قراردادهای آن به چه صورت است:
### ۱.۱ فایل [bot/app/stt.py](file:///c:/Users/Reza/Desktop/v1.5/bot/app/stt.py)
* **قرارداد ارتباطی:** تابع `_call_single_stt` ریکوئست `POST` به اندپوینت `{base_url}/audio/transcriptions` با هدر `Authorization: Bearer {api_key}` ارسال می‌کند.
* **فایل ارسالی:** فرم چندبخشی (Multipart) شامل کلید `file` با باینری صوتی MP3 (16kHz Mono)، `model` و `language="fa"`.
* **مدیریت پاسخ:**
* در صورت دریافت کد `200` و وجود کلید `text` غیرخالی، مقدار به عنوان کاندید معتبر ثبت می‌شود.
* در صورت دریافت هر کد خطایی (مانند `503`) یا بروز استثنا، صرفاً لاگ هشدار ثبت شده و مقدار `None` بازمی‌گردد.
* **تابع `transcribe_multi`:** تمام تسک‌ها را با `asyncio.gather(..., return_exceptions=True)` اجرا می‌کند؛ یعنی شکست یک پرووایدر هرگز کل فرآیند را متوقف نمی‌کند.
### ۱.۲ فایل [bot/app/config.py](file:///c:/Users/Reza/Desktop/v1.5/bot/app/config.py)
* متغیرهای محیطی با پیشوند `PROCUREMENT_BOT_STT_PROVIDER_N_*` (که N اعداد ۱ تا ۳ است) خوانده می‌شوند.
* یک پرووایدر تنها زمانی فعال می‌شود که **هر دو فیلد `BASE_URL` و `API_KEY` مقداردهی شده باشند**. بنابراین با خالی کردن این دو فیلد، پرووایدر بله فوراً از مدار خارج می‌شود (Rollback آنی).
---
## ۲. مشخصات قطعی کشف‌شده از پروتکل بله
در بررسی و راستی‌آزمایی مخازن PyPI روی پکیج `bale-sdk` (نسخه 0.1.1)، متد و ساختار فیلدهای ترنسکریپشن دقیقاً استخراج شده است:
* **فضای نام و سرویس:** `bale.turing.v1.AI`
* **متد باینری:** `GetTranscript`
* **اسکیمای درخواست (`GetTranscriptRequest`):**
```protobuf
message GetTranscriptRequest {
Media voice = 1; // آبجکت فایل صدا (شناسه و خصوصیات باینری)
OutPeer outPeer = 2; // مقصد چت (پیر چت شخصی یا بات رله)
MessageId messageId = 3;// شناسه پیام ثبت‌شده در چت
}
```
* **اسکیمای پاسخ (`GetTranscriptResponse`):**
```protobuf
message GetTranscriptResponse {
bool mustWait = 1; // فلگ بولی نشان‌دهنده در صف بودن پردازش سرور
DownloadSource downloadSource = 2; // منبع یا لینک بافر متنی خروجی
}
```
---
## ۳. ارزیابی کتابخانه‌های پایتون و استراتژی Clean-Room
| نام پکیج | نسخه در PyPI | مجوز حقوقی | وضعیت استفاده فنی در پروژه ما |
| :--- | :---: | :---: | :--- |
| **`bale-sdk`** | 0.1.1 | اختصاصی (All Rights Reserved) | **استفاده به عنوان مرجع پروتکل؛** به دلیل مجوز اختصاصی، کدهای آن کپی مستقیم نمی‌شوند بلکه ساختار پیام‌ها بر اساس کپچر بازسازی می‌گردد. |
| **`autobale`** | 1.0.0 | **MIT** (متن‌باز) | **مناسب برای هسته ارتباطی؛** فورک فعال و بازنویسی‌شده از `aiobale` که بر بستر WebSocket و Dispatcher ناهمگام کار می‌کند. |
| **`aiobale-py`** | 0.3.9 | عمومی | دارای لایه وب‌سوکت و هندلرهای باینری مبتنی بر `blackboxprotobuf` (`bbpb`). |
| **`bbpb` / `blackboxprotobuf`** | آخرین نسخه | MIT | ابزار دیکود و انکود فریم‌های باینری پروتوباف بدون نیاز به کامپایل فایلهای `.proto`. |
---
## ۴. ورودی‌های انسانی مورد نیاز ایجنت (Agent Inputs)
ایجنت قبل از راه‌اندازی، این ۵ مورد را از انسان دریافت می‌کند:
1. `BALE_PHONE`: شماره سیم‌کارت شرکتی اختصاصی جهت ورود به اکانت کاربری.
2. `BALE_OTP_CODE`: کد پیامکی ۶ رقمی یک‌بارمصرف در زمان اجرای اسکریپت ورود اولیه.
3. `BALE_RELAY_BOT_TOKEN`: توکن بات رله اختصاصی که از BotFather بله دریافت شده است.
4. `BALE_STT_INTERNAL_KEY`: کلید امنیتی تصادفی برای هدر `Authorization: Bearer <KEY>`.
5. `CRAWLER_USER_ID`: شناسه عددی کاربری اکانت کرالر (جهت ارسال پیام توسط بات رله).
---
## ۵. کدهای مرجع میکروسرویس آداپتور (`bale-stt`)
### ۵.۱ ساختار پروژه ایجنت
```
bale-stt/
├── adapter/
│ ├── main.py # میکروسرویس FastAPI سازگار با قرارداد OpenAI
│ ├── counters.py # مدیریت سقف روزانه با دیتابیس پایدار SQLite
│ ├── config.py # تنظیمات محیطی بدون مقدار پیش‌فرض خطرناک
│ ├── engines/
│ │ ├── base.py # اینترفیس استاندارد موتورها
│ │ ├── rpc.py # موتور اولویت ۱: ارتباط RPC و الگوی بات رله
│ │ └── playwright.py # موتور فالبک آخر: اتوماسیون صفحه وب بله
│ ├── Dockerfile
│ └── requirements.txt
├── docker-compose.stt.yml
└── .env.example
```
### ۵.۲ مدیریت سقف روزانه (`adapter/counters.py`)
```python
import os
import sqlite3
from contextlib import closing
from datetime import date
DB_PATH = os.getenv("BALE_STT_DB", "/app/state/usage.db")
def _init_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH, timeout=5)
con.execute("CREATE TABLE IF NOT EXISTS usage (day TEXT PRIMARY KEY, count INTEGER NOT NULL)")
return con
def get_today_count() -> int:
with closing(_init_db()) as con:
row = con.execute("SELECT count FROM usage WHERE day = ?", (date.today().isoformat(),)).fetchone()
return row[0] if row else 0
def increment_today_count() -> None:
with closing(_init_db()) as con, con:
con.execute(
"INSERT INTO usage(day, count) VALUES (?, 1) "
"ON CONFLICT(day) DO UPDATE SET count = count + 1",
(date.today().isoformat(),),
)
```
### ۵.۳ کد اصلی آداپتور (`adapter/main.py`)
```python
import os
import secrets
import asyncio
import logging
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
from counters import get_today_count, increment_today_count
from engines.base import get_engine
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("bale-stt-adapter")
app = FastAPI(title="Bale STT OpenAI Adapter")
INTERNAL_KEY = os.environ["BALE_STT_INTERNAL_KEY"]
DAILY_CAP = int(os.getenv("BALE_STT_DAILY_CAP", "200"))
TIMEOUT_SECONDS = float(os.getenv("BALE_STT_TIMEOUT", "25.0"))
# قفل همزمانی برای پردازش تک‌اکانت کاربری
account_lock = asyncio.Lock()
engine = get_engine()
@app.get("/healthz")
def health_check():
return {
"status": "ok",
"engine": engine.name,
"used_today": get_today_count(),
"cap": DAILY_CAP
}
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile = File(...),
model: str = Form("bale-stt"),
language: str = Form("fa"),
authorization: str = Header("")
):
# ۱. اعتبارسنجی امن کلید احراز هویت (مقاوم به Timing Attack)
expected_header = f"Bearer {INTERNAL_KEY}"
if not secrets.compare_digest(authorization.encode(), expected_header.encode()):
raise HTTPException(status_code=401, detail="Unauthorized")
# ۲. کنترل سقف مصرف مجاز روزانه
if get_today_count() >= DAILY_CAP:
log.warning("Daily STT capacity reached (%d)", DAILY_CAP)
raise HTTPException(status_code=429, detail="Daily quota exhausted")
audio_bytes = await file.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="Empty audio payload")
# ۳. ورود به صف ترتیبی با فاصله زمانی تصادفی انسانی
async with account_lock:
try:
# اعمال Jitter بین ۱ تا ۲.۵ ثانیه
await asyncio.sleep(1.0 + (secrets.randbelow(15) / 10.0))
# فراخوانی موتور فعال با سقف زمانی مجاز
text = await asyncio.wait_for(
engine.transcribe(audio_bytes, filename=file.filename or "voice.mp3"),
timeout=TIMEOUT_SECONDS
)
cleaned_text = (text or "").strip()
if not cleaned_text:
raise RuntimeError("Received empty transcript from Bale")
increment_today_count()
log.info("Transcription successful (length=%d)", len(cleaned_text))
return {"text": cleaned_text}
except Exception as exc:
log.exception("Transcription failed in engine %s: %s", engine.name, exc)
# پرتاب 503 حیاتی است: باعث می‌شود bot/app/stt.py کاندید را نادیده بگیرد
raise HTTPException(status_code=503, detail="Bale STT Service Temporarily Unavailable")
```
### ۵.۴ موتور RPC با الگوی بات رله (`adapter/engines/rpc.py`)
```python
import os
import uuid
import asyncio
import httpx
from engines.base import BaseEngine
class RpcRelayEngine(BaseEngine):
name = "rpc"
def __init__(self):
self.bot_token = os.environ["BALE_RELAY_BOT_TOKEN"]
self.crawler_user_id = int(os.environ["BALE_CRAWLER_USER_ID"])
self.api_url = f"https://tapi.bale.ai/bot{self.bot_token}"
async def transcribe(self, audio: bytes, filename: str) -> str:
nonce = str(uuid.uuid4())[:8]
# ۱. ارسال ویس با Bot API رسمی به چت اکانت کرالر
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{self.api_url}/sendVoice",
data={"chat_id": self.crawler_user_id, "caption": nonce},
files={"voice": ("voice.ogg", audio, "audio/ogg")}
)
if resp.status_code != 200:
raise RuntimeError(f"Relay bot sendVoice failed: {resp.text}")
sent_message_id = resp.json()["result"]["message_id"]
try:
# ۲. دریافت رویداد پیام در کلاینت کاربری و فراخوانی GetTranscript
from driver.bale_client import get_client
bale_client = await get_client()
transcript_text = await bale_client.call_get_transcript(
message_id=sent_message_id,
nonce=nonce,
timeout=18.0
)
return transcript_text
finally:
# ۳. پاکسازی بهداشتی: حذف پیام از چت
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(
f"{self.api_url}/deleteMessage",
json={"chat_id": self.crawler_user_id, "message_id": sent_message_id}
)
```
---
## ۶. تنظیمات داکر و یکپارچه‌سازی با بات اصلی
### ۶.۱ فایل `adapter/Dockerfile`
```dockerfile
FROM python:3.11-slim
WORKDIR /app
ENV TZ=Asia/Tehran PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
```
### ۶.۲ فایل `docker-compose.stt.yml`
```yaml
services:
bale-stt:
build:
context: ./adapter
dockerfile: Dockerfile
container_name: bale-stt-adapter
restart: unless-stopped
environment:
- TZ=Asia/Tehran
- BALE_STT_ENGINE=rpc
- BALE_STT_INTERNAL_KEY=${BALE_STT_INTERNAL_KEY}
- BALE_STT_DAILY_CAP=200
- BALE_RELAY_BOT_TOKEN=${BALE_RELAY_BOT_TOKEN}
- BALE_CRAWLER_USER_ID=${BALE_CRAWLER_USER_ID}
volumes:
- bale_session_data:/app/session
- bale_usage_data:/app/state
networks:
- bot_network
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"]
interval: 30s
timeout: 5s
retries: 3
volumes:
bale_session_data:
bale_usage_data:
networks:
bot_network:
external: true
```
### ۶.۳ تنظیمات متغیرهای محیطی در فایل `.env` بات اصلی
اتصال به بات اصلی **بدون دستکاری حتی ۱ خط از کدهای پایتون بات** انجام می‌پذیرد:
```env
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=${BALE_STT_INTERNAL_KEY}
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
PROCUREMENT_BOT_STT_PROVIDER_3_TIMEOUT=30
```
---
## ۷. چک‌لیست گام‌به‌گام پیاده‌سازی خودکار توسط ایجنت
ایجنت مجری برای به سرانجام رساندن پروژه، این چک‌لیست عملیاتی را بدون معطلی و به ترتیب اجرا می‌کند:
* [ ] **گام ۱ (لاگین کاربری و ذخیره سشن):** اجرای اسکریپت CLI برای ثبت شماره `BALE_PHONE`، دریافت کد `BALE_OTP_CODE` از انسان، صدور توکن پایدار JWT و ذخیره در مسیر `/app/session/session.json`.
* [ ] **گام ۲ (تست اتصال بات رله):** تست ارسال یک صوت آزمایشی با `sendVoice` از بات رله به `BALE_CRAWLER_USER_ID` و اطمینان از دریافت وب‌سوکت در کلاینت کرالر.
* [ ] **گام ۳ (اعتبارسنجی فراخوانی `GetTranscript`):** پاس دادن `Media` و `MessageId` به متد باینری و دریافت رشته متن فارسی.
* [ ] **گام ۴ (بیلد کانتینر آداپتور FastAPI):** بیلد ایمیج داکر و اجرای محلی روی پورت 8080.
* [ ] **گام ۵ (تست تطابق با قرارداد OpenAI):** ارسال ریکوئست تستی با `curl` به `/v1/audio/transcriptions` و اعتبارسنجی پاسخ `{"text": "..."}`.
* [ ] **گام ۶ (تست سناریوی خطا):** قطع عمدی اتصال بله و حصول اطمینان از بازگشت کد `HTTP 503` (جهت تضمین عدم توقف بات اصلی).
* [ ] **گام ۷ (فعال‌سازی در بات تدارکات):** قرار دادن مقادیر در `.env` بات اصلی.
* [ ] **گام ۸ (تست سراسری E2E):** ارسال پیام صوتی استعلام قیمت در پیام‌رسان بله و مشاهده لاگ‌های `bot.stt` مبنی بر رونویسی موفق کاندید بله و اجماع در مدل DeepSeek NLU.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,432 @@
# گزارش جامع: ارزیابی پروژه‌های متن‌باز و سرویس‌های شخص ثالث، و ترکیب بهینه برای کرالر STT بله
> **تاریخ:** ۱۶ مهر ۱۴۰۵ (8 Oct 2026)
> **ورودی‌ها:** دو گزارش مدیر پروژه
> - **گزارش A:** «ارزیابی تحلیلی راهکارهای استخراج صوت‌به‌متن در بله: سرویس‌های شخص ثالث، کتابخانه‌ها، پروژه‌های متن‌باز و جایگزین‌ها»
> - **گزارش B:** `bale_stt_executive_roadmap_report3.md` (سند عملیاتی برای مدیر پروژه و ایجنت مجری)
>
> **دامنه (طبق درخواست):** حریم خصوصی و امنیت در این گزارش بررسی نشده و بعداً تصمیم‌گیری می‌شود. فقط فهرست کامل راهکارها، ارزیابی، الگوبرداری از متن‌باز و بهترین ترکیب ارائه شده است.
>
> **روش کار:** هر دو گزارش را خواندم و ادعاهای قابل راستی‌آزمایی را با منابع عمومی (PyPI، مستندات رسمی بله، arXiv، Hugging Face، GitHub) تطبیق دادم. هر ادعا یکی از سه وضعیت دارد:
> ✅ **تأیید شد** | 🟡 **تأییدنشده / فقط استنباط** | ❌ **با شواهد در تناقض است**
>
> **محدودیت مهم:** شبکه sandbox من بسته است؛ پس نتوانستم پکیج‌ها را نصب کنم یا پروتوها را بخوانم. هر جا بگویم «تأییدنشده»، یعنی در منابع عمومی قابل مشاهده برای من نبود، نه لزوماً اینکه غلط باشد.
---
## ۱. خلاصه اجرایی
### پاسخ کوتاه به چهار سؤال شما
| سؤال | پاسخ کوتاه |
|---|---|
| **نظر درباره گزارش A (متن‌باز و شخص ثالث)** | تحلیل خوبی است و دسته‌بندی ۸ راهکار درست است. ولی چند ادعای مهم آن تأییدنشده یا غلط است: نام و وضعیت پکیج `aiobale-py`، وجود متد `GetTranscript` (فقط ادعا شده، من نتوانستم ببینم)، و برتری «مدل متن‌باز» بر سرویس‌های تجاری. داده‌های مستقل خلاف این برتری را نشان می‌دهد (بخش ۶). |
| **آیا دیگران همین مشکل را داشته‌اند و چطور حل کرده‌اند؟** | **هیچ پروژه عمومی‌ای که دقیقاً خروجی STT خود بله را استخراج کند پیدا نکردم.** همان الگوهای نزدیک وجود دارد: کلاینت‌های کاربری مهندسی‌معکوس‌شده (`aiobale`، `bale-sdk`، فورک‌ها)، سلف‌بات‌ها و اسکرپرهای مبتنی بر Selenium/Playwright، و کسب‌وکارهایی که سراغ ASR مستقل رفته‌اند (بخش ۳). |
| **اگر الگوبرداری یا شخص ثالث بخواهیم، چه کنیم؟** | از متن‌باز، لایه‌های انتقال و نشست و ابزار دیکود پروتوباف را بردارید، نه خود «ترنسکرایب» را. از شخص ثالث، چند ASR را به‌عنوان کاندید موازی کنار بله بگذارید (بخش ۵ و ۶). |
| **بهترین ترکیب؟** | **«بله + یک ASR تجاری فارسی + یک مدل خودمیزبان با واژگان تخصصی + LLM اجماع‌گیر با کاتالوگ کالا»**، با کرالر بله به‌صورت adapter قابل‌سوییچ. جزئیات در بخش ۷. |
### سه یافته‌ای که تصمیم شما را تغییر می‌دهد
1. **«نیازی به آپلود ویس نیست.»** بات خودتان با `sendVoice(file_id)` می‌تواند همان ویس کاربر را برای حساب کرالر بفرستد. این‌طور فایل را کرالر دانلود و آپلود نمی‌کند. این طراحی از گزارش B ساده‌تر و کم‌ریسک‌تر است (بخش ۷.۲).
2. **داده بنچمارک مستقل نشان می‌دهد ASR تجاری فارسی حدود ۱۴ امتیاز WER از بهترین متن‌باز جلوتر است** (مقاله PSRB، بخش ۶.۲). این با ادعای گزارش A که «متن‌باز پایدارترین راهکار» است، همخوان نیست. البته روی داده عمومی بوده، نه واژگان تدارکات شما.
3. **کد مرجع گزارش B شبه‌کد است، نه کد اجراشدنی.** توابعی مثل `client.send_voice` و `client.raw_call` در مستندات عمومی `bale-sdk` وجود ندارند (بخش ۲.۲).
---
## ۲. ارزیابی دو گزارش مدیر پروژه
### ۲.۱ گزارش A: متن‌باز و شخص ثالث
**نقاط قوت**
- پوشش گسترده: ۸ راهکار در یک ماتریس مقایسه (تأخیر، رم، خلوص متن، پایداری، هزینه).
- تفکیک درست «چه چیزی را از بله بگیریم» و «چه چیزی را مستقل بسازیم».
- پیشنهاد معماری دومسیره (بله + مسیر مستقل) درست و عملی است.
- معرفی `bale-sdk` و توجه به مجوز اختصاصی آن (نکته‌ای که گزارش‌های قبلی کم‌رنگ داشتند).
- اشاره درست به اینکه Playwright روی وب بله الگوی جاافتاده‌ای است.
**نقاط ضعف**
- چند ادعای کلیدی بدون منبع است و در بررسی من تأیید نشد (جدول زیر).
- مقایسه دقت متن‌باز با تجاری فقط بر اساس WER خودگزارشی مدل‌ها روی Common Voice است. این عدد روی ویس‌های واقعی و محاوره‌ای معنی ندارد.
- ارائه‌دهندگان تجاری مهم فارسی (Avanegar/Partai، Aipa) و Google Chirp 2 اصلاً در گزارش نیستند.
- تحلیل خطای شخص ثالث ناقص است (مثلاً قیمت‌ها بدون تاریخ و منبع).
### جدول راستی‌آزمایی ادعاهای گزارش A
| ادعا | وضعیت | یافته من |
|---|---|---|
| Bot API رسمی STT ندارد | ✅ | مستندات رسمی `docs.bale.ai`: شیء `Voice` فقط `file_id` و `file_unique_id` دارد و هیچ متدی برای ترنسکریپت نیست. **اصلاح:** گزارش می‌گوید «حجم و مدت‌زمان» هم هست؛ در مستندات رسمی نیست (SDKهای شخص ثالث فیلد `duration` را اضافه کرده‌اند). |
| `bale-sdk` از `mtp1376`، نسخه 0.1.1، ۶۰۷ RPC در ۵۲ سرویس، پوشه `proto/` | ✅ | صفحه PyPI (27 Jun 2026) دقیقاً همین را می‌گوید. |
| `bale-sdk` مجوز اختصاصی «All Rights Reserved» دارد | ✅ | تأیید شد. خود README هم می‌گوید استفاده فقط با حساب‌ها و داده‌هایی که مجاز هستید و مطابق ToS بله باشد. |
| سرویس `bale.turing.v1.AI` و متد `GetTranscript` با فیلدهای ۱ تا ۳ | 🟡 | **نتوانستم از منبع عمومی تأیید کنم.** فایل‌های `docs/API_COVERAGE.md` و `docs/API_FA.md` در صفحه PyPI لینک شده‌اند ولی برای من باز نشد. این مهم‌ترین ادعای هر دو گزارش است و باید در ۳۰ دقیقه اول خودتان تأیید شود (بخش ۹). |
| `aiobale-py` توسط امین مدنی، نسخه 0.3.x، متکی بر `blackboxprotobuf` | 🟡 | `aiobale-py` را پیدا نکردم. آنچه پیدا کردم: `aiobale` (Enalite، MIT، آخرین نسخه 0.1.5 در Aug 2025). توضیح خودش می‌گوید «از صفر و بدون فایل proto ساخته شده». |
| `aiobale` اولیه رها شده | ✅ | آخرین انتشار Aug 2025؛ فورک‌ها (`autobale`، `BaleClient`) می‌گویند نگهداری نمی‌شود و باگ دارد. |
| `autobale` توسط علی عمادی، نسخه 1.0.0، MIT | ✅ | تأیید شد (مخزن `AliEmadii/autobale`). |
| `BaleClient` | — | در گزارش نیست، ولی وجود دارد: فورک دیگری از aiobale (`Foad-KhAb/baleclient`، Python 3.11+). |
| `bale-mcp-server` روی `aiobale-py` | 🟡/❌ | پروژه‌ای به نام `bale-mcp` پیدا کردم که **با توکن بات رسمی** و `tapi.bale.ai` کار می‌کند، نه کلاینت کاربری. شاید پروژه دیگری باشد. |
| `bale-chat-exporter` با نشست کلاینت | 🟡 | اسکرپر Playwright + BeautifulSoup برای Bale Web پیدا کردم (خروجی اکسل). هم‌پروژه‌بودن با آنچه گزارش می‌گوید تأییدنشده است، ولی **الگوی Playwright روی وب بله اثبات‌شده است.** |
| `Bale-Self-Bot` در GitLab چابکان | 🟡 | آن را پیدا نکردم. ولی `baleself` (PyPI، `zellias/bale-self-bot`) یک سلف‌بات مبتنی بر **Selenium** است و الگو را تأیید می‌کند. |
| MajidAPI یک پراکسی روی موتورهای عمومی است | 🟡 | endpoint را در منابع قابل جست‌وجو پیدا نکردم. «پراکسی روی موتور عمومی» استنباط گزارش است، نه چیزی که ثابت شده باشد. هرچه باشد، SLA ندارد. |
| فارس‌آوا (عامراندیش): ۱۰ هزار ساعت داده، نصب On-Premise، ساعتی ۴۵–۶۰ هزار تومان | 🟡 | قیمت و جزئیات را مستقل تأیید نکردم؛ قیمت‌ها زمان‌دارند. قبل از تصمیم، استعلام رسمی بگیرید. |
| آی‌او تایپ دقت «بالای ۹۸٪» | 🟡 | ادعای بازاریابی است. روی ویس تدارکات خودتان اندازه بگیرید. |
| `hezarai/whisper-small-fa` روی CPU زیر ۲ ثانیه | 🟡 | محتمل برای ویس کوتاه، ولی مدل small روی فارسی محاوره‌ای ضعیف است (بخش ۶). |
| `nezamisafa/whisper-v3-turbo-persian-v1.0` با WER ‏۲۹.۹٪ | 🟡 | این مدل را نتوانستم باز کنم. مدل مشابه `vhdm/whisper-v3-turbo-persian-v1.1` ‏WER ‏14.07٪ می‌دهد ولی روی **دادگان خودش**؛ اعداد WER بین دادگان‌های مختلف قابل مقایسه نیستند. |
| Faster-Whisper تا ۴ برابر سریع‌تر | ✅ (تا حد دانسته‌های من) | ادعای رسمی خود پروژه است. در مقاله PSRB هم Faster-Whisper هالوسینیشن کمتری از Whisper خام داشت. |
| «مدل‌های متن‌باز پایدارترین راهکار فنی‌اند» | ❌ | با داده PSRB سازگار نیست: ASR تجاری فارسی و Chirp 2 به‌طور محسوس دقیق‌ترند (بخش ۶.۲). متن‌باز «مستقل‌ترین» است، نه لزوماً «دقیق‌ترین». |
### ۲.۲ گزارش B: نقشه راه اجرایی
**نقاط قوت**
- دیاگرام‌های توالی و جریان تاب‌آوری خوب و قابل‌تحویل به مدیر پروژه‌اند.
- اصلاحات قبلی اعمال شده است: `compare_digest`، سقف روزانه، قفل ترتیبی، ۵۰۳ در خطا و متن خالی.
- ورودی‌های انسانی روشن‌اند و «تأیید دکمه روی وب» به‌عنوان ورودی گذاشته شده است.
- Rollback با خالی‌کردن env درست است.
**ایرادها و ریسک‌های اجرایی**
| مورد | توضیح |
|---|---|
| **کد مرجع شبه‌کد است** | `client.send_voice(...)`، `client.raw_call(service=..., method=...)` و `sent_msg.voice.to_media()` در README عمومی `bale-sdk` نیست. جدول متدهای سطح‌بالای آن ارسال رسانه ندارد. مدل دسترسی RPC خام آن این‌طور است: «namespace برابر snake_case بخش آخر نام سرویس». یعنی `bale.messaging.v2.Messaging` → `client.messaging`. پس فراخوانی احتمالی `bale.turing.v1.AI` باید چیزی شبیه `client.ai.GetTranscript(...)` باشد (**استنباط من**). |
| **آپلود ویس** | کارِ آپلود فایل و ساخت پیام صوتی (Media) را سند فرض گرفته ولی طراحی نکرده. این بخش از خود RPC ترنسکریپت سخت‌تر است. راه ساده‌تر در بخش ۷.۲ آمده. |
| **پاسخ `GetTranscript`** | طبق خود سند، پاسخ `bool mustWait` و یک `DownloadSource` دارد، نه رشته متن. یعنی احتمالاً متن باید از یک لینک/منبع دانلود خوانده شود. مثال کد این را مدیریت نمی‌کند. |
| **کلید پیش‌فرض** | `os.environ.get("BALE_STT_INTERNAL_KEY", "bale_internal_secret")` هنوز مقدار پیش‌فرض دارد؛ بدون env هم بالا می‌آید. با `os.environ[...]` خطا بدهد. |
| **شمارنده روزانه** | متغیر درون‌حافظه است: با ریست کانتینر صفر می‌شود و ریست روزانه هم ندارد (پس بعد از رسیدن به سقف تا ری‌استارت بعدی می‌ماند). |
| **`.env` نمونه** | `API_KEY=bale_internal_secret` یعنی همان مقدار پیش‌فرض ضعیف. |
| **مسیرهای محلی** | لینک‌ها به `file:///c:/Users/Reza/Desktop/...` اشاره دارند. در اشتراک سند باید پاک شوند. |
| **«بدون وقفه»** | ادعای «بدون حتی ۱ ثانیه قطعی» فقط وقتی درست است که `stt.py` در مواجهه با ۵۰۳ همان پرووایدر را بی‌خطا رد کند. این را باید در کد بات بررسی کرد، نه از روی سند. |
---
## ۳. آیا دیگران هم همین مشکل را داشته‌اند؟ و چطور حل کرده‌اند؟
### ۳.۱ نتیجه کلی
**پروژه عمومیِ مشخصی که خروجی STT خود بله را برای یک بات استخراج کند پیدا نکردم** (گزارش A هم همین را می‌گوید). آنچه وجود دارد الگوهای مجاور است:
| الگو | نمونه‌های تأییدشده | درسی که برای ما دارد |
|---|---|---|
| **A. بات رسمی + ASR مستقل** | بات‌های بله روی Webhook (مثلاً `hamedcode/bale-ai-bot` روی Cloudflare Workers)، بات‌های مشتری‌مداری با LLM | رایج‌ترین راه. بله فقط کانال پیام است؛ صوت دانلود و به موتور دیگر داده می‌شود. |
| **B. کلاینت کاربری مهندسی‌معکوس‌شده** | `aiobale` (MIT)، فورک‌های `autobale` و `BaleClient`، `bale-sdk` (اختصاصی، ۶۰۷ RPC) | نشان می‌دهد نگهداری نشست کاربری و تماس با RPCهای داخلی شدنی است، ولی پروژه‌ها تازه‌اند و بعضی رها می‌شوند. |
| **C. اتوماسیون مرورگر** | `baleself` (Selenium)، اسکرپر چت Bale Web با Playwright | روی وب بله جاافتاده است، ولی معمولاً برای کارهای ساده مثل پاسخ خودکار و بک‌آپ. |
| **D. سوءاستفاده خلاقانه از زیرساخت** | `BaleVPN` (تونل روی تماس صوتی بله) | نشان می‌دهد جامعه تا کجا از لایه‌های داخلی استفاده می‌کند؛ برای STT به کار ما نمی‌آید. |
| **E. ابزارهای مدیریتی روی Bot API** | `bale-mcp`، SDKهای PHP/Node/Python/Swift/C# | فقط Bot API؛ بدون هیچ راهی برای رسیدن به ترنسکریپت. |
### ۳.۲ برداشت از همین شواهد
1. **راه مشترک جامعه این است که STT را بیرون از بله انجام می‌دهند.** هیچ‌کس STT بله را «کرال» نکرده یا اگر کرده منتشر نشده است. پس در هر حال ریسک بالاتر و شواهد کمتری برای مسیر شماست.
2. **زیرساخت لازم برای کرالر وجود دارد** (انتقال WebSocket، OTP، نشست، ارسال و دانلود فایل)؛ پس این پروژه «ساخت از صفر» نیست.
3. **پایداری کتابخانه‌ها ضعیف است** (aiobale رها شد، `bale-sdk` نسخه 0.1.1 است). هر معماری باید فرض کند اجزای متن‌باز ممکن است از کار بیفتند.
4. **تنها چیزی که کل شرط‌بندی را توجیه می‌کند، دقت بله روی ویس‌های شماست.** بدون آزمون دستی (بخش ۸، گام ۰) نباید ساخت شروع شود.
---
## ۴. کاتالوگ کامل راهکارها
برای هر راهکار: چه کار می‌کند، مزیت، عیب، تلاش (تخمین من)، وضعیت تأیید.
### گروه ۱: استفاده از STT خود بله
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|---|---|---|---|---|---|---|
| **S1** | **مسیر رسمی/تجاری با بله** | ایمیل یا تماس با تیم توسعه‌دهندگان/بیزینس بله برای API رسمی STT یا قرارداد | پایدار، بدون ریسک بن | احتمال موافقت کم؛ زمان‌بر | ۱ ایمیل | هیچ مستند عمومی‌ای برای وجود چنین API نیست |
| **S2** | **RPC مستقیم با `bale-sdk`** | استفاده از پروتوها و کلاینت آماده، فراخوانی `GetTranscript` | سریع‌ترین مسیر فنی؛ پروتو آماده | **مجوز اختصاصی**؛ نسخه 0.1.1؛ ادعای RPC تأییدنشده | ۱–۲ روز | 🟡 |
| **S3** | **RPC مستقیم با `aiobale` / `autobale` / `BaleClient` (MIT) + پروتوی خودساخته** | انتقال و نشست و آپلود از کتابخانه MIT؛ تعریف `GetTranscript` را از کپچر ترافیک خودتان در `web.bale.ai` بسازید | بدون مشکل مجوز؛ قابل‌نگهداری | کشف RPC و تعریف پروتو کار می‌برد؛ کتابخانه‌ها کم‌نگهداری‌اند | ۳–۵ روز | ✅ ابزارها؛ 🟡 RPC |
| **S4** | **Playwright روی `web.bale.ai`** | کرومیوم headless با پروفایل پایدار؛ ارسال ویس، کلیک دکمه، خواندن DOM | مقاوم در برابر تغییر پروتکل؛ الگوی اثبات‌شده | ۳۵۰–۵۰۰MB رم؛ شکننده در برابر تغییر UI؛ وابسته به وجود دکمه در وب | ۱–۲ روز | ✅ الگو؛ 🟡 دکمه وب |
| **S5** | **ترکیب «مرورگر برای ورود، پروتکل برای کار»** | Playwright فقط ورود/تمدید توکن (JWT) را انجام می‌دهد؛ کار اصلی را کلاینت WebSocket سبک می‌کند | از سبک‌بودن S3 و پایداری ورود S4 بهره می‌برد | پیچیدگی دو لایه | ۴–۶ روز | پیشنهاد من |
| **S6** | **شبیه‌ساز اندروید + uiautomator** | اپ بله در شبیه‌ساز، خواندن متن از رابط | مستقل از وب | رم >2GB؛ شکننده؛ سخت‌ترین نگهداری | ۵+ روز | آخرین چاره |
| **S7** | **چت هوش مصنوعی بله (Oracle)** | ارسال ویس به بات هوش مصنوعی بله و گرفتن پاسخ | بدون کشف RPC | خروجی «متن + تفسیر»؛ تأخیر ۵–۱۵ ثانیه؛ اینکه ویس را پیاده می‌کند هم تأییدنشده | ۱ روز | 🟡 |
### گروه ۲: سرویس‌های شخص ثالث
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|---|---|---|---|---|---|---|
| **T1** | **پراکسی‌های غیررسمی (MajidAPI و مشابه)** | فراخوانی REST با لینک ویس | چند ساعت | بدون SLA؛ موتور نامشخص؛ ورودی URL (ویس باید لینک عمومی شود) | ساعتی | 🟡 |
| **T2** | **ASR تجاری فارسی** | **Avanegar/Partai** (API: `api.ivira.ai/partai/speechRecognition`)، **Aipa**، عامراندیش (فارس‌آوا)، IOType | دقت بالا روی فارسی؛ پشتیبانی؛ گاهی On-Premise | هزینه ریالی؛ قرارداد | ۱ روز (adapter) | ✅ Avanegar/Aipa در بنچمارک؛ 🟡 قیمت‌ها |
| **T3** | **ASR ابری بین‌المللی** | Google **Chirp 2**، Azure Speech، OpenAI Whisper API، Groq Whisper | Chirp 2 تقریباً هم‌سطح بهترین تجاری فارسی | هزینه ارزی؛ محدودیت دسترسی؛ بسته‌به شرایط شبکه | ۱ روز | ✅ Chirp/Azure در بنچمارک |
### گروه ۳: مدل‌های متن‌باز خودمیزبان
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|---|---|---|---|---|---|---|
| **O1** | **Faster-Whisper large-v3 (+ `initial_prompt`/hotwords واژگان تدارکات)** | سرور OpenAI-compatible داخلی | مستقل؛ قابل‌شخصی‌سازی با واژگان؛ بهترین متن‌باز در بنچمارک | WER ≈ 34٪ روی فارسی واقعی؛ نیاز به GPU برای سرعت | ۱ روز | ✅ |
| **O2** | **Whisper large-v3-turbo فاین‌تیون فارسی** (مثل `vhdm/whisper-v3-turbo-persian-v1.1`) | مدل فاین‌تیون‌شده روی دادگان فارسی | سریع‌تر؛ احتمالاً دقیق‌تر روی گفتار تمیز | ارقام خودگزارشی و روی دادگان متفاوت؛ باید روی داده شما سنجید | ۱ روز | 🟡 |
| **O3** | **NeMo FastConformer فارسی (NVIDIA)** | `stt_fa_fastconformer_hybrid_large` | مدل مستقل از Whisper؛ دیکودر CTC | در بنچمارک WER ≈ 45٪ (ضعیف‌تر از Whisper) | ۲ روز | ✅ |
| **O4** | **Vosk فارسی (`vosk-model-fa-0.5`)** | Kaldi/DNN-HMM سبک، CPU | بسیار سبک؛ آفلاین | WER ≈ 45٪ | ساعتی | ✅ |
| **O5** | **SLPL wav2vec2 فارسی (دانشگاه شریف)** | XLS-R فاین‌تیون‌شده | مناسب برای تحقیق و ensemble | WER ≈ 47٪ | ۲ روز | ✅ |
| **O6** | **Seamless M4T v2** | مدل چندزبانه | — | احتمال بازنویسی/هالوسینیشن (Interpretation Error در مقاله PSRB) | ۲ روز | ✅ مشکل |
### گروه ۴: لایه‌های بالاتر (نه جایگزین، بلکه تقویت‌کننده)
| # | راهکار | توضیح |
|---|---|---|
| **L1** | **LLM اجماع‌گیر با کاتالوگ** | کاندیدها + فهرست کالا/برند/واحد دیتابیس تدارکات را به LLM بدهید تا خروجی ساختاریافته و تصحیح‌شده بسازد. (مقاله PSRB هم می‌گوید LLMها خطاهای جزئی ASR را خوب اصلاح می‌کنند.) |
| **L2** | **نرمال‌سازی فارسی** | یکسان‌سازی «ی/ک»، ارقام، نیم‌فاصله (ZWNJ) و اعداد حرفی. PSRB نشان می‌دهد مرزبندی کلمه و نیم‌فاصله WER را بالا می‌برد. |
| **L3** | **فاین‌تیون روی داده خودتان** | ویس‌های واقعی + متن نهایی تأییدشده توسط کاربر → LoRA روی Whisper. بلندمدت‌ترین سرمایه‌گذاری برای استقلال از بله. |
| **L4** | **مسیریابی بر اساس اعتماد** | اگر کاندیدها در اعداد/واحدها اختلاف دارند، از کاربر تأیید بگیرید (UX)، نه حدس بزنید. |
---
## ۵. اگر از پروژه‌های متن‌باز الگوبرداری کنیم: چه چیزی را می‌توان برداشت؟
| مؤلفه | منبع | مجوز | چه چیزی بردارید | ملاحظات |
|---|---|---|---|---|
| انتقال WebSocket، ورود با OTP، ذخیره نشست، Dispatcher | `aiobale` / `autobale` / `BaleClient` | MIT ✅ | همه چیز؛ حتی می‌توانید فورک کنید | `aiobale` رها شده؛ `autobale` فعال‌تر ادعا می‌شود. نسخه را قفل کنید |
| ارسال/دانلود فایل و رسانه | همان کتابخانه‌ها (گزارش A: حدود ۸۰ متد) | MIT | آپلود و ساخت پیام صوتی | اگر ایده بخش ۷.۲ را بپذیرید (بات `sendVoice` بفرستد) اصلاً لازم نیست |
| **تعریف RPC و پروتوها** | `bale-sdk` (`proto/`) | **اختصاصی ❌** | **فقط به‌عنوان مرجع و با اجازه نویسنده**؛ کپی مستقیم ممنوع | راه تمیز: از کپی خود کپچر `web.bale.ai` پروتو بسازید. ابزارها: `bbpb`/`blackboxprotobuf`، `protoscope`، `protoc --decode_raw` |
| دیکود بدون `.proto` | `blackboxprotobuf` (NCC Group؛ پکیج رسمی PyPI با نام `bbpb`) | MIT | تحلیل payloadهای ناشناخته | wire format خودتوصیف نیست؛ نام فیلد را باید خودتان حدس بزنید |
| الگوی DOM و صفحه‌خوانی بله | اسکرپر Playwright چت (GitHub) و `baleself` (Selenium) | بررسی مجوز هر مخزن | سلکتورها، انتظار برای بارگذاری، اسکرول | سلکتورها با هر آپدیت UI می‌شکنند |
| سرور OpenAI-compatible برای Whisper | Faster-Whisper، پروژه‌های سرور مبتنی بر آن | معمولاً MIT/Apache | سرور داخلی با همان `/v1/audio/transcriptions` | همان قرارداد adapter بات؛ تغییر کد لازم نیست |
| نرمال‌سازی و ارزیابی فارسی | مقاله و دادگان PSRB (`PartAI/PSRB` در Hugging Face)، `hazm` | بررسی | معیار SW-WER، فهرست خطاهای رایج فارسی | دادگان عمومی فقط زیرمجموعه است |
### قواعد پیشنهادی الگوبرداری
1. **تمیز‌کاری حقوقی:** منطق را از متن‌باز MIT بردارید و تعریف RPC را از کپچر خودتان تولید کنید (clean-room).
2. **لایه‌بندی:** adapter فقط قرارداد OpenAI را بشناسد؛ هیچ import از `bale*` بیرون از ماژول `driver/` نباشد.
3. **قفل نسخه و آینه:** نسخه دقیق کتابخانه‌ها را pin و کپی داخلی نگه دارید (رهاشدن مخزن، مثل `aiobale`، اتفاق افتاده است).
4. **تست قرارداد:** با یک «ویس طلایی» شبانه بررسی کنید پاسخ هنوز ساختار قبلی را دارد.
---
## ۶. اگر شخص ثالث بخواهیم: چه کار کنیم؟
### ۶.۱ ساده‌ترین کار: هر سرویس یک «پرووایدر» در `stt.py`
بات شما اکنون پرووایدرهای شماره‌دار با قرارداد OpenAI دارد. پس هر شخص ثالث را یا مستقیم (اگر OpenAI-compatible است) یا با یک adapter کوچک اضافه کنید. مزیت: چند ASR موازی، اجماع LLM، و حذف خودکار پرووایدر خراب (۵۰۳).
### ۶.۲ داده مستقل برای انتخاب: بنچمارک PSRB (مقاله arXiv:2505.21230)
مقاله «PSRB: A Comprehensive Benchmark for Evaluating Persian ASR Systems» (مرکز تحقیقات Part AI، May 2025) ده سیستم را روی گفتار واقعی (۵۸٪ خودانگیخته، ۷۱٪ غیررسمی، ۵۳٪ نویزی/تلفنی) سنجیده است.
| مدل | نوع | CER | WER | SW-WER |
|---|---|---|---|---|
| **Avanegar** | تجاری فارسی | **8.75** | **19.30** | **15.68** |
| **Google Chirp v2** | ابری چندزبانه | 9.05 | 19.92 | 15.99 |
| **Aipa** | تجاری فارسی | 10.43 | 24.64 | 19.83 |
| Faster-Whisper large-v3 | متن‌باز | 13.72 | 33.93 | 26.77 |
| Azure | ابری چندزبانه | 15.78 | 33.94 | 28.06 |
| Seamless M4T v2 | متن‌باز | 22.30 | 38.85 | 34.76 |
| Whisper large-v3 | متن‌باز | 18.92 | 41.49 | 36.97 |
| Vosk fa 0.5 | متن‌باز | 23.96 | 44.62 | 39.41 |
| NeMo FastConformer fa | متن‌باز | 19.42 | 44.85 | 37.58 |
| SLPL wav2vec2 | متن‌باز | 19.06 | 46.74 | 39.16 |
**نکات خواندن این جدول:**
- **ASR تجاری فارسی (و Chirp 2) تقریباً ۱۴ امتیاز WER از بهترین متن‌باز جلوتر است.** این یعنی ادعای «متن‌باز پایدارترین راهکار» در گزارش A دقیق نیست. متن‌باز مستقل‌تر است، نه دقیق‌تر.
- **سوگیری احتمالی:** نویسندگان از Part AI هستند و از آدرس API در پاورقی مقاله، Avanegar ظاهراً محصول همان شرکت است. پس عدد Avanegar را «بالاترین حد خوش‌بینانه» بدانید.
- **داده‌ها عمومی‌اند، نه واژگان تدارکات.** نام کالا، برند، واحد و اعداد در محاوره سخت‌ترین بخش‌اند و فقط آزمون روی ویس‌های خودتان تصمیم‌گیر است.
- مقاله می‌گوید خطا در **گفتار غیررسمی و لهجه‌های محلی** (مثلاً مشهدی، یزدی) بیشتر است و **Whisper/Seamless هالوسینیشن و بازنویسی** دارند. هر دو برای متن تدارکات خطرناک‌اند، چون عدد یا واحد اشتباه معنی سفارش را عوض می‌کند.
- ترکیب «مرتبه اول: دو سیستم متفاوت» معمولاً از «یک سیستم قوی» پایدارتر است. اجماع LLM هم برای همین است.
### ۶.۳ ارزیابی شخص ثالث: روش پیشنهادی (بدون وابستگی به ادعاهای فروشنده)
1. **مجموعه طلایی:** ۱۰۰ ویس واقعی (حداقل ۳۰) با رونوشت انسانی و جدول کالا/تعداد/واحد.
2. **اجرای همه کاندیدها** روی همان ویس‌ها: بله (دستی در گام ۰)، Avanegar، Aipa، Chirp 2، Faster-Whisper، مدل فاین‌تیون فارسی.
3. **سه معیار:** CER (برای فارسی مناسب‌تر از WER)، WER و مهم‌تر **F1 استخراج اقلام** (نام کالا، تعداد، واحد).
4. **انتخاب بر اساس «بهبود اجماع»،** نه دقت تکی: هر کاندید را به مجموعه فعلی اضافه کنید و ببینید خطای نهایی استخراج اقلام چقدر کم می‌شود.
5. **هزینه:** هزینه به ازای هر ویس ۱۰–۳۰ثانیه‌ای در ماه برای حجم واقعی شما.
### ۶.۴ چه سرویسی را کجا بگذاریم؟ (پیشنهاد مقدماتی)
| نقش | کاندید | چرا |
|---|---|---|
| پرووایدر ۱ (تجاری فارسی) | Avanegar یا Aipa | بهترین در بنچمارک فارسی و لهجه‌ها |
| پرووایدر ۲ (ابری) | Chirp 2 (اگر دسترسی ممکن است) | تقریباً هم‌سطح؛ مکمل متفاوت |
| پرووایدر ۳ (خودمیزبان) | Faster-Whisper large-v3 + واژگان تدارکات | مستقل؛ قابل بهبود با داده خودتان |
| پرووایدر ۴ (بله) | کرالر بله (S3/S4/S5) | تنها کاندیدی که تفاوت واقعی (آموزش‌دیده روی داده‌های متفاوت) دارد؛ ارزش آن را گام ۰ تعیین می‌کند |
| پشتیبان سبک | Vosk | فقط برای اینکه همیشه یک خروجی داشته باشیم |
---
## ۷. بهترین ترکیب برای کرالر شما
### ۷.۱ اصول طراحی
1. **کرالر بله «یک کاندید» است، نه ستون فقرات.** اگر خراب شد، سیستم باید بدون آن ادامه دهد.
2. **حداقل دو کاندید غیر بله همیشه فعال** باشند تا اجماع بدون بله هم معنی داشته باشد.
3. **تصمیم بر اساس داده خودتان**، نه ادعای مقاله و فروشنده.
4. **هر چیز بله پشت یک قرارداد ثابت (OpenAI-compatible)** باشد تا موتور داخلی عوض شود و بات دست نخورد.
5. **داده را جمع کنید** تا در بلندمدت از بله مستقل شوید (L3).
### ۷.۲ معماری پیشنهادی
```mermaid
flowchart TD
U[کاربر: ویس] --> BOT[بات تدارکات - Bot API رسمی]
BOT --> PRE[پیش‌پردازش: 16kHz mono، VAD، مدت]
PRE --> P1[پرووایدر 1: ASR تجاری فارسی]
PRE --> P2[پرووایدر 2: Chirp 2 / ابری]
PRE --> P3[پرووایدر 3: Faster-Whisper + واژگان]
PRE --> RELAY[رله ویس به حساب کرالر: sendVoice با file_id]
RELAY --> CR[کرالر بله: adapter]
CR -->|انتخاب موتور با env| E1[موتور RPC: GetTranscript]
CR -->|فالبک| E2[موتور Playwright]
CR --> DEL[حذف پیام رله]
P1 --> NORM[نرمال‌سازی فارسی: ZWNJ، ارقام، یی/کاف]
P2 --> NORM
P3 --> NORM
E1 --> NORM
E2 --> NORM
NORM --> LLM[LLM اجماع‌گیر + کاتالوگ کالا/برند/واحد]
LLM --> CONF{اختلاف در عدد/واحد؟}
CONF -- خیر --> OUT[ثبت استعلام]
CONF -- بله --> ASK[از کاربر تأیید بگیر]
ASK --> OUT
OUT --> DATA[(ذخیره ویس + متن تأییدشده برای فاین‌تیون)]
```
### ۷.۳ ایده کلیدی: «رله» به‌جای آپلود
گزارش B فرض می‌کند کرالر ویس را به **Saved Messages** خودش آپلود می‌کند. راه ساده‌تر و سالم‌تر:
1. حساب کرالر یک‌بار به بات شما `/start` می‌زند (گفتگوی خصوصی بات↔کرالر).
2. بات با Bot API رسمی `sendVoice(chat_id=<id حساب کرالر>, voice=<file_id همان ویس کاربر>)` می‌فرستد. طبق مستندات رسمی، ارسال با `file_id` بدون آپلود مجدد و بدون محدودیت است. (`file_id` مخصوص همان بات است؛ اینجا همان بات می‌فرستد، پس مشکلی نیست.)
3. حساب کرالر (کلاینت کاربری) آپدیت پیام جدید را می‌گیرد؛ **شیء Media ویس، `OutPeer` و `message_id` همه همان‌جاست.** دقیقاً سه ورودی `GetTranscript` (Media voice، OutPeer، MessageId).
4. کرالر `GetTranscript` را صدا می‌زند، متن را می‌گیرد.
5. پیام با `deleteMessage` رسمی بات (برای پیام خروجی در گفتگوی خصوصی مجاز است، تا ۴۸ ساعت) یا از سمت کرالر حذف می‌شود.
**مزایا:** بدون RPC آپلود؛ بدون ساخت `Media` از صفر؛ بدون الگوی شبه‌انسانی «آپلود فایل» روی حساب؛ ID پیام و peer واقعی‌اند.
**ریسک:** اینکه RPC روی ویس **دریافتی** هم کار کند (در اپ، دکمه روی ویس دریافتی هم هست؛ ولی تأییدش با آزمون واقعی لازم است).
### ۷.۴ لایه‌بندی موتور کرالر (به ترتیب اولویت)
| اولویت | موتور | شرط | یادداشت |
|---|---|---|---|
| ۱ | **E1: RPC مستقیم** (transport از `aiobale`/`autobale`، پروتوی خودساخته) | RPC تأیید شد | سبک (<۲۰MB)، متن خالص |
| ۲ | **E1+: ورود با مرورگر، کار با پروتکل (S5)** | ورود/تمدید JWT ناپایدار باشد | Playwright فقط برای توکن |
| ۳ | **E2: Playwright کامل** | RPC تأیید نشد ولی دکمه وب هست | رم ۳۵۰–۵۰۰MB |
| ۴ | شبیه‌ساز اندروید | هیچ‌کدام ممکن نشد | فقط در صورت ارزش بالای بله |
اگر RPC در باندل وب بله وجود دارد، **به احتمال زیاد دکمه هم در وب هست** (استنباط من: گزارش‌ها می‌گویند پروتوها از کلاینت وب بازیابی شده‌اند). ولی تا خودتان نبینید فرض نکنید.
### ۷.۵ تقویت اجماع (جایی که بیشترین دقت را می‌گیرید)
| اقدام | اثر مورد انتظار | هزینه |
|---|---|---|
| تزریق **کاتالوگ کالا/برند/واحد** به LLM اجماع‌گیر | بیشترین کاهش خطای استخراج اقلام | کم |
| `initial_prompt`/hotwords واژگان تدارکات در Faster-Whisper | بهبود نام برندها و اصطلاح‌ها | کم |
| نرمال‌سازی فارسی (ZWNJ، ارقام) پیش از اجماع | کاهش اختلاف‌های ساختگی بین کاندیدها | کم |
| قاعده «اختلاف عدد/واحد ⇒ تأیید از کاربر» | حذف سفارش اشتباه | متوسط (UX) |
| ثبت ویس + متن تأییدشده → LoRA روی Whisper | استقلال بلندمدت؛ بهبود روی لهجه و واژگان شما | بالا (ولی ارزشمندترین) |
| VAD پیش از ASR | کاهش هالوسینیشن Whisper روی سکوت/نویز (پیشنهاد مقاله PSRB) | کم |
### ۷.۶ سناریوهای تصمیم
| اگر… | آنگاه… |
|---|---|
| گام ۰: بله بهتر از مجموعه فعلی نیست | **کرالر را نسازید.** سرمایه را روی ASR تجاری + فاین‌تیون بگذارید. |
| بله کمی بهتر است (< ۱۰٪) | فقط مسیر سبک E1 را بسازید؛ Playwright و شبیه‌ساز را رها کنید. |
| بله به‌طور چشمگیر بهتر است (> ۱۵٪) | E1 + فالبک E2 + مسیر رسمی S1 را موازی دنبال کنید. |
| RPC پیدا نشد و دکمه وب نیست | S6 فقط اگر ارزش بله زیاد باشد؛ در غیر این صورت منصرف شوید. |
| بله خوب است ولی ناپایدار | از خروجی بله به‌عنوان **برچسب‌گذار** برای ساخت دادگان فاین‌تیون استفاده کنید (L3) تا وابستگی کم شود. |
---
## ۸. نقشه راه پیشنهادی
| روز | کار | خروجی | دروازه |
|---|---|---|---|
| ۰ | **آزمون دستی دقت:** ۳۰ ویس واقعی را در اپ بله به متن تبدیل کنید و با کاندیدهای فعلی مقایسه کنید | جدول CER/WER و F1 اقلام | G0: بهبود معنی‌دار یا توقف |
| ۰ | جست‌وجوی `GetTranscript` در `bale-sdk`؛ تأیید دکمه روی `web.bale.ai`؛ ایمیل به بله (S1) | یک صفحه یافته | G1: RPC یا دکمه وجود دارد |
| ۱ | کپچر ترافیک وب و ساخت پروتوی خودتان | `req.bin`/`resp.bin`، `findings.md` | G2 |
| ۲–۳ | پروتوتایپ E1 با رله بات→کرالر | یک ویس → یک متن | G3 |
| ۳–۴ | adapter سخت‌شده (۵۰۳، سقف روزانه پایدار، قفل، لاگ) | اتصال به `stt.py` | — |
| ۴–۵ | **موازی:** پرووایدرهای شخص ثالث (Avanegar/Aipa/Chirp، Faster-Whisper) | سنجش روی مجموعه طلایی | G4: بهبود اجماع |
| ۶–۱۰ | سواک ۵۰–۱۰۰ ویس + مانیتورینگ + مجموعه طلایی شبانه | گزارش پایداری | G5: استقرار |
| ماه ۲+ | جمع‌آوری داده و LoRA | مدل داخلی | کاهش وابستگی |
**KPIها:** F1 استخراج اقلام، CER، نرخ اختلاف عدد/واحد، نرخ موفقیت کرالر، p95 تأخیر، هزینه به ازای ویس.
---
## ۹. ضمیمه: دستورها و نمونه‌ها
### ۹.۱ کشف سریع (۳۰ دقیقه)
```bash
python -m venv .v && . .v/bin/activate
pip index versions aiobale-py # آیا واقعاً وجود دارد؟
pip index versions aiobale autobale baleclient bale-sdk
pip download bale-sdk --no-deps --no-binary :all:
tar xzf bale_sdk-*.tar.gz
grep -rniE "transcri|turing|speech|asr|stt" bale_sdk-*/proto bale_sdk-*/docs | head -50
```
در پایتون:
```python
from bale import ALL_RPCS
for r in ALL_RPCS:
s = str(r).lower()
if any(k in s for k in ("transcri", "turing", "speech")):
print(r)
```
### ۹.۲ ساخت پروتو از کپچر خودتان
1. `web.bale.ai` را با حساب اختصاصی باز کنید، DevTools → Network (فیلتر WS و Fetch).
2. روی ویس «تبدیل به متن» را بزنید؛ درخواست/پاسخ را ذخیره کنید.
3. پیشوند ۵ بایتی gRPC-Web را بردارید (برای POST gRPC-Web؛ در WebSocket بله قالب را باید ببینید).
4. `protoc --decode_raw < req.bin` یا `protoscope`.
### ۹.۳ اسکلت رله بات→کرالر (شبه‌کد؛ نام متدها را با کتابخانه واقعی تطبیق دهید)
```python
# سمت بات (Bot API رسمی)
async def relay_voice_to_crawler(voice_file_id: str, crawler_user_id: int) -> int:
msg = await bot_api("sendVoice", chat_id=crawler_user_id, voice=voice_file_id)
return msg["message_id"]
# سمت کرالر (کلاینت کاربری) - شبه‌کد
@client.on(NewMessage) # فقط از بات خودمان
async def on_voice(event):
media, peer, mid = event.media, event.peer, event.id
resp = await client.ai.GetTranscript(voice=media, outPeer=peer, messageId=mid) # نام‌ها = استنباط
while resp.mustWait:
await asyncio.sleep(1.5)
resp = await client.ai.GetTranscript(voice=media, outPeer=peer, messageId=mid)
text = await read_download_source(resp.downloadSource)
await client.delete_messages(peer, [mid])
return text
```
### ۹.۴ قرارداد adapter (بدون تغییر در بات)
- ورودی: `POST /v1/audio/transcriptions` (multipart: `file`, `model`, `language`).
- خروجی موفق: `{"text": "..."}`.
- هر خطا یا متن خالی: **HTTP 503** (نه ۲۰۰ با متن خالی).
- کلید داخلی: از env الزامی، بدون مقدار پیش‌فرض؛ مقایسه با `secrets.compare_digest`.
- سقف روزانه و صف ترتیبی؛ شمارنده را پایدار (Redis/فایل) و روزانه ریست‌شونده بگیرید.
---
## ۱۰. پرسش‌های باز (قبل از شروع ساخت پاسخ دهید)
1. `GetTranscript` واقعاً در `bale-sdk` وجود دارد؟ (۳۰ دقیقه، بخش ۹.۱)
2. دکمه «تبدیل به متن» روی `web.bale.ai` برای ویس **دریافتی** هست؟
3. خروجی پاسخ: متن مستقیم است یا `DownloadSource` که باید دانلود شود؟
4. `stt.py` با ۵۰۳ و متن خالی چه می‌کند؟
5. حجم واقعی ویس‌های شما در روز و طول متوسط آن‌ها چقدر است؟ (برای هزینه و سقف)
6. آیا می‌توانید ۳۰–۱۰۰ ویس واقعی با رونوشت انسانی تهیه کنید؟ (بدون آن هیچ مقایسه‌ای معتبر نیست.)
7. آیا دسترسی شبکه به Chirp 2 / ارائه‌دهندگان خارجی از سرورهای شما ممکن است؟
8. نگهداری: چه کسی مالک کرالر است اگر بله پروتکل را عوض کرد؟
---
## ۱۱. منابع
- مستندات رسمی بات بله: https://docs.bale.ai/
- `bale-sdk` (PyPI): https://pypi.org/project/bale-sdk/ و https://pypi.org/project/bale-sdk/0.1.1/
- `aiobale` (PyPI): https://pypi.org/project/aiobale ، مستندات: https://docs.aiobale.ir
- `autobale` (PyPI): https://pypi.org/project/autobale/
- `BaleClient` (PyPI): https://pypi.org/project/BaleClient/
- `baleself` (Selenium): https://pypi.org/project/baleself/
- `bale-mcp` (PyPI): https://pypi.org/project/bale-mcp/
- بات هوش مصنوعی بله روی Cloudflare Workers: https://github.com/hamedcode/bale-ai-bot
- BaleVPN: https://github.com/2077DevWave/BaleVPN
- Blackbox Protobuf (NCC Group): https://www.github.com/nccgroup/blackboxprotobuf
- PSRB (Persian ASR benchmark): https://arxiv.org/pdf/2505.21230
- مدل فارسی Whisper (نمونه): https://huggingface.co/vhdm/whisper-large-fa-v1
- خبر انتشار نسخه بله با قابلیت تبدیل ویس به متن (آنا): https://ana.ir/fa/news/994908
+3
View File
@@ -94,6 +94,9 @@ class ErpClient:
async def list_projects(self, page_size: int = 100) -> list[dict]: 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 [] return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
async def create_project(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).get("data") or {}
async def list_categories(self, page_size: int = 100) -> list[dict]: 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 [] return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
+273
View File
@@ -0,0 +1,273 @@
"""Centralized market conventions & domain defaults for procurement bot.
This module isolates all procurement business heuristics and defaults (equal
installments calculation, end-of-solar-month due dates, validity rules per
material, project assignment logic, official vs unofficial defaults).
drafts.py and nlu.py consume this module directly.
"""
from datetime import date, datetime, timedelta, timezone
# ---------------------------------------------------------------------------
# Jalali Calendar math (Pure Python, zero-dependency)
# ---------------------------------------------------------------------------
JALALI_MONTH_NAMES = [
"", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"
]
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
gy2 = gy if gm > 2 else gy - 1
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
jy = -1595 + (33 * (days // 12053))
days %= 12053
jy += 4 * (days // 1461)
days %= 1461
if days > 365:
jy += (days - 1) // 365
days = (days - 1) % 365
if days < 186:
jm = 1 + (days // 31)
jd = 1 + (days % 31)
else:
jm = 7 + ((days - 186) // 30)
jd = 1 + ((days - 186) % 30)
return jy, jm, jd
def j2g(jy: int, jm: int, jd: int) -> tuple[int, int, int]:
jy2 = jy + 1595
days = -355668 + (365 * jy2) + ((jy2 // 33) * 8) + (((jy2 % 33) + 3) // 4) + jd
if jm < 7:
days += (jm - 1) * 31
else:
days += ((jm - 7) * 30) + 186
gy = 400 * (days // 146097)
days %= 146097
if days > 36524:
days -= 1
gy += 100 * (days // 36524)
days %= 36524
if days >= 365:
days += 1
gy += 4 * (days // 1461)
days %= 1461
if days > 365:
gy += (days - 1) // 365
days = (days - 1) % 365
gd = days + 1
sal_a = [0, 31, 29 if ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
gm = 0
while gm < 13 and gd > sal_a[gm]:
gd -= sal_a[gm]
gm += 1
return gy, gm, gd
def is_jalali_leap(jy: int) -> bool:
return (((jy + 38) * 31) % 128) <= 31
def jalali_month_days(jy: int, jm: int) -> int:
if 1 <= jm <= 6:
return 31
elif 7 <= jm <= 11:
return 30
elif jm == 12:
return 30 if is_jalali_leap(jy) else 29
return 30
def today_jalali(ref_date: date | None = None) -> tuple[int, int, int]:
d = ref_date or date.today()
return g2j(d.year, d.month, d.day)
def jalali_to_iso(jy: int, jm: int, jd: int) -> str:
gy, gm, gd = j2g(jy, jm, jd)
return f"{gy:04d}-{gm:02d}-{gd:02d}"
def jalali_format(jy: int, jm: int, jd: int) -> str:
return f"{jy:04d}/{jm:02d}/{jd:02d}"
def jalali_month_name(jm: int) -> str:
return JALALI_MONTH_NAMES[jm] if 1 <= jm <= 12 else ""
# ---------------------------------------------------------------------------
# Market Conventions & Constants
# ---------------------------------------------------------------------------
DEFAULT_IS_OFFICIAL = True
# Standard validity days in volatile markets (materials)
DEFAULT_VALIDITY_DAYS_MAP: dict[str, int] = {
"آهن": 1,
"میلگرد": 1,
"تیرآهن": 1,
"ورق": 1,
"پروفیل": 2,
"سیمان": 3,
"گچ": 7,
"لوله": 7,
"اتصالات": 7,
"شیرآلات": 7,
"شن": 7,
"ماسه": 7,
}
FALLBACK_VALIDITY_DAYS: int = 7
def compute_validity_date(materials: list[str] | None = None, explicit_days: int | None = None,
ref_date: date | None = None) -> tuple[str, str]:
"""Compute (iso_date, jalali_date_str) based on material or explicit days."""
base = ref_date or date.today()
if explicit_days is not None and explicit_days > 0:
days = explicit_days
else:
days = FALLBACK_VALIDITY_DAYS
if materials:
for m in materials:
m_str = (m or "").lower()
for k, v in DEFAULT_VALIDITY_DAYS_MAP.items():
if k in m_str:
days = min(days, v)
target = base + timedelta(days=days)
jy, jm, jd = g2j(target.year, target.month, target.day)
return f"{target.year:04d}-{target.month:02d}-{target.day:02d}", jalali_format(jy, jm, jd)
# ---------------------------------------------------------------------------
# Payment Terms Generators (Heuristics)
# ---------------------------------------------------------------------------
def build_equal_installments(
total_amount: float,
count: int,
ref_date: date | None = None,
) -> list[dict]:
"""Build equal installments milestones.
- 1st milestone: today (or ref_date)
- 2nd..nth milestones: end of subsequent Jalali months
Amounts: total_amount / count, rounded to thousands, with remainder added to last milestone.
"""
if count < 1:
count = 1
base = ref_date or date.today()
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
base_per_installment = int(total_amount // count) if total_amount > 0 else 0
remainder = int(total_amount - (base_per_installment * count)) if total_amount > 0 else 0
milestones: list[dict] = []
# Milestone 1: today
m1_iso = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
m1_jalali = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
milestones.append({
"due_date": m1_iso,
"due_date_jalali": m1_jalali,
"amount": base_per_installment,
"note": f"قسط اول (امروز - {curr_jd} {jalali_month_name(curr_jm)})",
})
# Milestone 2..N: end of consecutive Jalali months
target_jy = curr_jy
target_jm = curr_jm
for i in range(2, count + 1):
target_jm += 1
if target_jm > 12:
target_jm = 1
target_jy += 1
end_day = jalali_month_days(target_jy, target_jm)
iso_str = jalali_to_iso(target_jy, target_jm, end_day)
jalali_str = f"{target_jy}/{target_jm:02d}/{end_day:02d}"
amt = base_per_installment + (remainder if i == count else 0)
m_name = jalali_month_name(target_jm)
milestones.append({
"due_date": iso_str,
"due_date_jalali": jalali_str,
"amount": amt,
"note": f"قسط {i} (پایان {m_name})",
})
return milestones
def build_cash_today_termsheet(
total_amount: float,
discount_percent: float = 0.0,
ref_date: date | None = None,
) -> dict:
"""Build cash today payment termsheet."""
base = ref_date or date.today()
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
iso_date = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
jalali_str = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
discount_val = 0.0
payable = total_amount
if discount_percent > 0 and total_amount > 0:
discount_val = round((total_amount * discount_percent) / 100.0)
payable = total_amount - discount_val
disc_label = f" (تخفیف {int(discount_percent)}٪)" if discount_percent > 0 else ""
return {
"label": f"پرداخت یکباره امروز{disc_label}",
"discount_type": "percent" if discount_percent > 0 else "none",
"discount_value": discount_percent if discount_percent > 0 else 0.0,
"milestones": [
{
"due_date": iso_date,
"due_date_jalali": jalali_str,
"amount": payable,
"note": "تسویه نقدی یکباره (امروز)",
}
],
"description": f"مبلغ فاکتور: {int(total_amount):,} | تخفیف: {int(discount_val):,} | خالص پرداختی: {int(payable):,}",
}
def propose_project_distribution(project_names: list[str], items: list[dict]) -> list[tuple[str, str]]:
"""Propose assignment of items to projects when multiple projects exist.
Returns list of (item_material_name, assigned_project_name).
"""
clean_projects = [p.strip() for p in project_names if p and p.strip()]
if not clean_projects or not items:
return []
proposals: list[tuple[str, str]] = []
p_len = len(clean_projects)
for idx, it in enumerate(items):
assigned_p = clean_projects[idx % p_len]
mat_name = it.get("material_name") or f"قلم {idx + 1}"
proposals.append((mat_name, assigned_p))
return proposals
# ---------------------------------------------------------------------------
# Authorized Whitelist Users (Static Role Configuration)
# ---------------------------------------------------------------------------
AUTHORIZED_USERS: dict[int, dict] = {
1461166742: {
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
"display_name": "مدیر تدارکات",
"role": "procurement_manager",
"is_admin": True,
},
1839839922: {
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
"display_name": "همکار / تست",
"role": "procurement_manager",
"is_admin": False,
},
}
+154 -8
View File
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import cast, func, select, String, update from sqlalchemy import cast, func, select, String, update
from app import defaults
from app.db import SessionLocal from app.db import SessionLocal
from app.models import BotDraft from app.models import BotDraft
from app.nlu import normalize_text from app.nlu import normalize_text
@@ -23,6 +24,7 @@ from app.nlu import normalize_text
KIND_QUOTE = "quote" KIND_QUOTE = "quote"
KIND_SUPPLIER = "supplier" KIND_SUPPLIER = "supplier"
KIND_MATERIAL = "material" KIND_MATERIAL = "material"
KIND_PROJECT = "project"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -70,6 +72,22 @@ async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None:
return (await session.execute(stmt)).scalar_one_or_none() return (await session.execute(stmt)).scalar_one_or_none()
async def get_by_ambiguity_message_id(chat_id: int, message_id: int) -> tuple[BotDraft | None, dict | None]:
"""Find the active draft and specific ambiguity dict associated with message_id."""
async with SessionLocal() as session:
stmt = (
select(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
.order_by(BotDraft.updated_at.desc())
)
drafts = (await session.execute(stmt)).scalars().all()
for d in drafts:
for amb in (d.payload or {}).get("ambiguities") or []:
if amb.get("message_id") == message_id:
return d, amb
return None, None
async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | 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).""" """Resolve the short id used in callback_data (first 8 hex of the UUID)."""
async with SessionLocal() as session: async with SessionLocal() as session:
@@ -154,9 +172,10 @@ def new_quote_payload() -> dict:
return { return {
"supplier_name": None, "supplier_id": None, "supplier_name": None, "supplier_id": None,
"project_name": None, "project_id": None, "project_names": [], "project_ids": [], "project_name": None, "project_id": None, "project_names": [], "project_ids": [],
"is_official": True, "notes": None, "expiry_date": None, "is_official": defaults.DEFAULT_IS_OFFICIAL, "notes": None, "expiry_date": None,
"items": [], "termsheets": [], "items": [], "termsheets": [],
"options": {}, "proforma_file": None, "options": {}, "proforma_file": None,
"ambiguities": [],
} }
@@ -206,11 +225,15 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
if not payload.get("project_names"): if not payload.get("project_names"):
payload["project_names"] = [payload["project_name"]] payload["project_names"] = [payload["project_name"]]
changed = True changed = True
set_field("is_official", patch.get("is_official")) set_field("is_official", patch.get("is_official", defaults.DEFAULT_IS_OFFICIAL))
if patch.get("notes"): if patch.get("notes"):
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip() payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
changed = True changed = True
if patch.get("expiry_date"): if patch.get("expiry_days"):
m_names = [it.get("material_name") for it in payload.get("items") or [] if it.get("material_name")]
iso_val, _ = defaults.compute_validity_date(materials=m_names, explicit_days=int(patch["expiry_days"]))
set_field("expiry_date", iso_val)
elif patch.get("expiry_date"):
set_field("expiry_date", patch["expiry_date"]) set_field("expiry_date", patch["expiry_date"])
for raw in patch.get("items") or []: for raw in patch.get("items") or []:
@@ -219,10 +242,8 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
if name: if name:
best, best_ratio = None, 0.0 best, best_ratio = None, 0.0
for item in payload["items"]: 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"]): if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
continue 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"]): if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
continue continue
ratio = _similarity(name, item.get("material_name")) ratio = _similarity(name, item.get("material_name"))
@@ -249,15 +270,78 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
if spec_str and not target.get("notes"): if spec_str and not target.get("notes"):
target["notes"] = spec_str target["notes"] = spec_str
changed = True changed = True
for key in ("unit_name", "brand_name", "notes"): for key in ("unit_name", "brand_name", "project_name", "notes"):
if raw.get(key): if raw.get(key):
val = str(raw[key]).strip() val = str(raw[key]).strip()
if target.get(key) != val: if target.get(key) != val:
target[key] = val target[key] = val
changed = True changed = True
# Calculate total amount of items
total_amount = sum(
(float(it.get("quantity") or 0) * float(it.get("unit_price") or 0))
for it in payload.get("items") or []
)
for raw in patch.get("payment_terms") or []: for raw in patch.get("payment_terms") or []:
p_type = raw.get("type") or ""
label = (raw.get("label") or "شرایط پرداخت").strip() label = (raw.get("label") or "شرایط پرداخت").strip()
# 1. Cash today with discount
if p_type == "cash_today" or (raw.get("is_today") and not raw.get("installments_count")):
disc_pct = float(raw.get("discount_percent") or raw.get("discount_value") or 0.0)
entry = defaults.build_cash_today_termsheet(total_amount, discount_percent=disc_pct)
if label and "یکباره" not in label and "نقد" not in label:
entry["label"] = label
existing = [t for t in payload["termsheets"] if "نقدی" in t.get("label", "") or "امروز" in t.get("label", "")]
if existing:
existing[0].update(entry)
else:
payload["termsheets"].append(entry)
changed = True
# 2. Equal installments -> Generate Ambiguity sub-message and record termsheet
elif p_type == "installments" or raw.get("installments_count"):
count = int(raw.get("installments_count") or 3)
milestones = defaults.build_equal_installments(total_amount, count=count)
lines = [f"• {m['note']}: {int(m['amount']):,} تومان" for m in milestones]
amb_id = f"terms_{count}_split"
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
payload["termsheets"].append({
"label": label or f"{count} قسط در طول زمان",
"discount_type": "none",
"discount_value": 0,
"milestones": [],
"description": None,
})
ambs = payload.setdefault("ambiguities", [])
existing_amb = next((a for a in ambs if a.get("id") == amb_id), None)
proposed = {
"payment_terms": [{
"label": label or f"{count} قسط در طول زمان",
"milestones": milestones,
"discount_type": "none",
"discount_value": 0,
}]
}
if existing_amb:
existing_amb["details"] = "\n".join(lines)
existing_amb["proposed_patch"] = proposed
else:
ambs.append({
"id": amb_id,
"kind": "payment_schedule",
"title": f"زمان‌بندی {count} قسط پرداخت",
"details": "\n".join(lines),
"status": "pending",
"proposed_patch": proposed,
})
changed = True
# 3. Explicit milestones from user or general label
else:
milestones = [] milestones = []
for m in raw.get("milestones") or []: for m in raw.get("milestones") or []:
if m.get("due_date") or m.get("amount"): if m.get("due_date") or m.get("amount"):
@@ -273,10 +357,43 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
"milestones": milestones, "milestones": milestones,
"description": (raw.get("description") or "").strip() or None, "description": (raw.get("description") or "").strip() or None,
} }
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]): existing = [t for t in payload["termsheets"] if normalize_text(t.get("label", "")) == normalize_text(label)]
if existing:
if milestones:
existing[0]["milestones"] = milestones
else:
payload["termsheets"].append(entry) payload["termsheets"].append(entry)
changed = True changed = True
# 4. Multi-project ambiguity generation
proj_names = payload.get("project_names") or []
items = payload.get("items") or []
if len(proj_names) > 1 and len(items) > 1:
unassigned = any(not it.get("project_name") for it in items)
if unassigned:
proposals = defaults.propose_project_distribution(proj_names, items)
if proposals:
lines = [f"• {mat} 👈 پروژه {prj}" for mat, prj in proposals]
amb_id = "proj_split"
ambs = payload.setdefault("ambiguities", [])
existing_amb = next((a for a in ambs if a.get("id") == amb_id), None)
proposed = {
"items": [{"material_name": mat, "project_name": prj} for mat, prj in proposals]
}
if existing_amb:
existing_amb["details"] = "\n".join(lines)
existing_amb["proposed_patch"] = proposed
else:
ambs.append({
"id": amb_id,
"kind": "project_assignment",
"title": f"تخصیص اقلام به پروژه‌های {' و '.join(proj_names)}",
"details": "\n".join(lines),
"status": "pending",
"proposed_patch": proposed,
})
changed = True
return changed return changed
@@ -292,7 +409,13 @@ def compute_quote_missing(payload: dict) -> list[dict]:
add("supplier", "تأمین‌کننده", sup_ok, True) add("supplier", "تأمین‌کننده", sup_ok, True)
# In DB: project_id is nullable (optional) # In DB: project_id is nullable (optional)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False) has_proj = bool(
payload.get("project_id")
or payload.get("project_ids")
or payload.get("project_name")
or payload.get("project_names")
)
add("project", "پروژه", has_proj, False)
# In DB: expiry_date is nullable (optional) # In DB: expiry_date is nullable (optional)
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False) add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
@@ -478,5 +601,28 @@ def material_missing(payload: dict) -> list[dict]:
] ]
def new_project_payload() -> dict:
return {"name": None, "description": None, "options": {}}
def merge_project_patch(payload: dict, patch: dict) -> bool:
src = patch.get("project") 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("description") and payload.get("description") != str(src["description"]).strip():
payload["description"] = str(src["description"]).strip()
changed = True
return changed
def project_missing(payload: dict) -> list[dict]:
return [
{"key": "name", "label": "نام پروژه", "ok": bool(payload.get("name")), "blocking": True},
{"key": "description", "label": "توضیحات پروژه", "ok": bool(payload.get("description")), "blocking": False},
]
def short_id() -> str: def short_id() -> str:
return uuid.uuid4().hex[:8] return uuid.uuid4().hex[:8]
+126 -21
View File
@@ -18,7 +18,7 @@ from app.storage import save_proforma_file
from app.stt import transcribe, transcribe_multi from app.stt import transcribe, transcribe_multi
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
from . import commands, callbacks, quote, supplier, material, queries, decisions from . import commands, callbacks, quote, supplier, material, project, queries, decisions
logger = logging.getLogger("bot.handlers") logger = logging.getLogger("bot.handlers")
@@ -39,6 +39,10 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
user = await _gate(ctx, chat_id, message)
if user is None:
return
text = (message.get("text") or message.get("caption") or "").strip() text = (message.get("text") or message.get("caption") or "").strip()
source = "text" source = "text"
voice_transcripts: list[dict] | None = None voice_transcripts: list[dict] | None = None
@@ -56,10 +60,6 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
return return
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through 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
reply_to = message.get("reply_to_message") reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None reply_to_msg_id = reply_to.get("message_id") if reply_to else None
@@ -98,6 +98,12 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft # BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
# ========================================================================= # =========================================================================
if reply_to_msg_id is not None: if reply_to_msg_id is not None:
# Check if replying to an ambiguity message (Requirement 2.b)
amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id)
if amb_draft is not None and target_amb is not None:
await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id)
return
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id) target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None: if target_draft is None:
await ctx.send( await ctx.send(
@@ -221,6 +227,8 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
await supplier.commit(ctx, chat_id, user, active) await supplier.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_MATERIAL: elif active.kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, active) await material.commit(ctx, chat_id, user, active)
elif active.kind == draft_store.KIND_PROJECT:
await project.commit(ctx, chat_id, user, active)
elif intent == "discard_draft": elif intent == "discard_draft":
active = target_draft or await draft_store.get_active(chat_id) active = target_draft or await draft_store.get_active(chat_id)
if active: if active:
@@ -242,10 +250,14 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
await supplier.render(ctx, chat_id, active) await supplier.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_MATERIAL: elif active.kind == draft_store.KIND_MATERIAL:
await material.render(ctx, chat_id, active) await material.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_PROJECT:
await project.render(ctx, chat_id, active)
elif intent == "add_supplier": elif intent == "add_supplier":
await supplier.handle_entities(ctx, chat_id, user, patch, source) await supplier.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "add_material": elif intent == "add_material":
await material.handle_entities(ctx, chat_id, user, patch, source) await material.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "add_project":
await project.handle_entities(ctx, chat_id, user, patch, source)
elif intent == "status_query": elif intent == "status_query":
await queries.handle_query(ctx, chat_id, user, patch) await queries.handle_query(ctx, chat_id, user, patch)
elif intent == "accept_quote": elif intent == "accept_quote":
@@ -260,25 +272,58 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
# helpers # helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from datetime import datetime, timezone
import json
from pathlib import Path
UNREGISTERED_FILE = Path(__file__).resolve().parent.parent / "unregistered_users.json"
def _record_unregistered_user(chat_id: int, message: dict) -> None:
from_user = message.get("from") or {}
record = {
"chat_id": chat_id,
"username": from_user.get("username"),
"first_name": from_user.get("first_name"),
"last_name": from_user.get("last_name"),
"timestamp": datetime.now(timezone.utc).isoformat(),
"text_preview": (message.get("text") or "")[:100],
}
records = []
try:
if UNREGISTERED_FILE.exists():
content = UNREGISTERED_FILE.read_text(encoding="utf-8").strip()
if content:
records = json.loads(content)
if not isinstance(records, list):
records = []
except Exception:
records = []
records.append(record)
try:
UNREGISTERED_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
logger.warning("Could not write to unregistered_users.json: %s", e)
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None: 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.""" """Whitelist gate (Casdoor bypassed). Unregistered users are blocked and logged."""
from . import links from app.defaults import AUTHORIZED_USERS
link = await ctx.links.get(chat_id) # 1. Whitelist check (only registered whitelist IDs)
if link and link.user_id: if chat_id in AUTHORIZED_USERS:
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False) u = AUTHORIZED_USERS[chat_id]
if chat_id in ctx.admin_chat_ids: return LinkedUser(
return LinkedUser(chat_id, None, None, None, is_admin=True) chat_id=chat_id,
user_id=u.get("user_id"),
display_name=u.get("display_name"),
role=u.get("role", "procurement_manager"),
is_admin=u.get("is_admin", True),
)
flow = ctx.pending.get(chat_id) # 2. Block unregistered user and record log
if flow and flow.kind == "link_password": _record_unregistered_user(chat_id, message)
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "") await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
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 return None
@@ -323,3 +368,63 @@ def _draft_summary(draft) -> str | None:
else: else:
summary = json.dumps(payload, ensure_ascii=False)[:500] summary = json.dumps(payload, ensure_ascii=False)[:500]
return summary return summary
async def _handle_ambiguity_user_reply(
ctx: BotContext, chat_id: int, user: LinkedUser,
draft, amb: dict, message: dict, incoming_msg_id: int | None
) -> None:
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b)."""
text = (message.get("text") or message.get("caption") or "").strip()
if not text and (message.get("voice") or message.get("audio")):
try:
audio_media = message.get("voice") or message.get("audio")
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
text = await transcribe(settings, audio_bytes)
except Exception:
text = ""
if text:
draft_summary = _draft_summary(draft)
result = await understand(settings, text, draft_summary)
patch = result.get("patch") or {}
# Apply user correction specifically to the target field
if amb.get("kind") == "payment_schedule":
if patch.get("payment_terms"):
draft_store.merge_quote_patch(draft.payload, patch)
else:
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
if not draft.payload.get("notes"):
draft.payload["notes"] = f"نحوه پرداخت: {text}"
else:
draft.payload["notes"] += f"\nنحوه پرداخت: {text}"
elif amb.get("kind") == "project_assignment":
if patch.get("items") or patch.get("project_name") or patch.get("project_names"):
draft_store.merge_quote_patch(draft.payload, patch)
else:
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
else:
draft_store.merge_quote_patch(draft.payload, patch)
amb["status"] = "applied"
await quote.resolve_payload(ctx, draft.payload)
await draft_store.save_payload(draft, mark_bale_synced=True)
# In-place edit main review card (Requirement 2.b)
await quote.render(ctx, chat_id, draft, fresh=False)
# Delete the ambiguity message (Requirement 2.b)
if amb.get("message_id"):
try:
await ctx.delete(chat_id, amb["message_id"])
except Exception:
pass
# Delete user's message to keep chat clean (Requirement 2.b)
if incoming_msg_id:
try:
await ctx.delete(chat_id, incoming_msg_id)
except Exception:
pass
+62 -10
View File
@@ -9,7 +9,7 @@ from app import drafts as draft_store
from app.runtime import LinkedUser from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc from app.texts import CANCELLED, UNLINKED, esc
from . import quote, supplier, material, decisions from . import quote, supplier, material, project, decisions
logger = logging.getLogger("bot.callbacks") logger = logging.getLogger("bot.callbacks")
@@ -29,12 +29,18 @@ async def handle_callback(ctx, cq: dict) -> None:
async def _gate(ctx, chat_id: int) -> LinkedUser | None: async def _gate(ctx, chat_id: int) -> LinkedUser | None:
link = await ctx.links.get(chat_id) from app.defaults import AUTHORIZED_USERS
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 AUTHORIZED_USERS:
if chat_id in ctx.admin_chat_ids: u = AUTHORIZED_USERS[chat_id]
return LinkedUser(chat_id, None, None, None, is_admin=True) return LinkedUser(
await ctx.send(chat_id, UNLINKED) chat_id=chat_id,
user_id=u.get("user_id"),
display_name=u.get("display_name"),
role=u.get("role", "procurement_manager"),
is_admin=u.get("is_admin", True),
)
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
return None return None
@@ -57,6 +63,45 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
await decisions.handle_arrive_callback(ctx, chat_id, user, parts) await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
return return
# ---- ambiguity sub-messages (Requirement 2.a) -------------------------
if parts[0] == "amb" and len(parts) >= 4:
action, prefix, amb_id = parts[1], parts[2], parts[3]
draft = await draft_store.get_by_prefix(prefix, chat_id)
if draft is None:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
payload = draft.payload or {}
ambiguities = payload.get("ambiguities") or []
target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None)
if not target_amb:
return
if action == "ok":
proposed = target_amb.get("proposed_patch") or {}
draft_store.merge_quote_patch(payload, proposed)
target_amb["status"] = "applied"
await quote.resolve_payload(ctx, payload)
await draft_store.save_payload(draft, mark_bale_synced=True)
# In-place edit main review card
await quote.render(ctx, chat_id, draft, fresh=False)
# Delete ONLY this ambiguity message
if target_amb.get("message_id"):
try:
await ctx.delete(chat_id, target_amb["message_id"])
except Exception:
pass
elif action == "no":
target_amb["status"] = "rejected"
await draft_store.save_payload(draft, mark_bale_synced=True)
# Delete ONLY this ambiguity message
if target_amb.get("message_id"):
try:
await ctx.delete(chat_id, target_amb["message_id"])
except Exception:
pass
return
# ---- pick a candidate for a draft field ------------------------------- # ---- pick a candidate for a draft field -------------------------------
if parts[0] == "pk" and len(parts) == 4: if parts[0] == "pk" and len(parts) == 4:
draft = await draft_store.get_by_prefix(parts[1], chat_id) draft = await draft_store.get_by_prefix(parts[1], chat_id)
@@ -68,8 +113,13 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
# ---- draft commit / cancel -------------------------------------------- # ---- draft commit / cancel --------------------------------------------
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else "" 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"): if op in ("q", "s", "m", "p") and arg in ("ok", "ok2", "no"):
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op] kind = {
"q": draft_store.KIND_QUOTE,
"s": draft_store.KIND_SUPPLIER,
"m": draft_store.KIND_MATERIAL,
"p": draft_store.KIND_PROJECT,
}[op]
draft = await draft_store.get_by_prefix(prefix, chat_id) draft = await draft_store.get_by_prefix(prefix, chat_id)
if draft is None or draft.kind != kind: if draft is None or draft.kind != kind:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
@@ -89,8 +139,10 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2")) await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
elif kind == draft_store.KIND_SUPPLIER: elif kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, draft) await supplier.commit(ctx, chat_id, user, draft)
else: elif kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, draft) await material.commit(ctx, chat_id, user, draft)
elif kind == draft_store.KIND_PROJECT:
await project.commit(ctx, chat_id, user, draft)
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None: async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
+11 -1
View File
@@ -31,10 +31,13 @@ async def handle_command(ctx, chat_id: int, text: str) -> None:
async def _start(ctx, chat_id: int) -> None: async def _start(ctx, chat_id: int) -> None:
from app.defaults import AUTHORIZED_USERS
await ctx.send(chat_id, WELCOME) await ctx.send(chat_id, WELCOME)
if chat_id in AUTHORIZED_USERS or chat_id in ctx.admin_chat_ids:
return
link = await ctx.links.get(chat_id) link = await ctx.links.get(chat_id)
if not link or not link.user_id: if not link or not link.user_id:
if chat_id not in ctx.admin_chat_ids:
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:") await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
ctx.pending.set(chat_id, "link_username", {}) ctx.pending.set(chat_id, "link_username", {})
@@ -71,6 +74,13 @@ async def _cancel(ctx, chat_id: int) -> None:
async def _whoami(ctx, chat_id: int) -> None: async def _whoami(ctx, chat_id: int) -> None:
from app.defaults import AUTHORIZED_USERS
if chat_id in AUTHORIZED_USERS:
u = AUTHORIZED_USERS[chat_id]
role = ROLE_FA.get(u.get("role") or "", u.get("role") or "مدیر تدارکات")
await ctx.send(chat_id, f"اتصال (لیست سفید): {esc(u.get('display_name'))} — {role}")
return
link = await ctx.links.get(chat_id) link = await ctx.links.get(chat_id)
if chat_id in ctx.admin_chat_ids: if chat_id in ctx.admin_chat_ids:
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).") await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
+67
View File
@@ -0,0 +1,67 @@
"""Add-project flow: name + optional description."""
import logging
from app import drafts as draft_store
from app.api.client import ApiError
from app.drafts import (
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing,
)
from app.keyboards import simple_review_keyboard
from app.texts import ERR_GENERIC, esc, project_committed_report, project_review_text
logger = logging.getLogger("bot.project")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
draft = await draft_store.get_active(chat_id, KIND_PROJECT)
if draft is not None:
payload = draft.payload
merge_project_patch(payload, patch)
else:
payload = new_project_payload()
merge_project_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source)
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_PROJECT, [])
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
payload = draft.payload
missing = project_missing(payload)
text = project_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
missing = {m["key"]: m["ok"] for m in project_missing(payload)}
if not missing.get("name"):
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
return
body = {"name": payload["name"]}
if payload.get("description"):
body["description"] = payload["description"]
try:
created = await ctx.api.create_project(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_project failed")
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("projects")
await ctx.send(chat_id, project_committed_report(created))
+111 -4
View File
@@ -26,6 +26,17 @@ async def handle_entities(
draft = target_draft draft = target_draft
prev_review_msg_id = draft.review_message_id prev_review_msg_id = draft.review_message_id
draft.review_message_id = None draft.review_message_id = None
# Clean up old ambiguity sub-messages (Requirement 2.c)
old_ambs = (draft.payload or {}).get("ambiguities") or []
for a in old_ambs:
if a.get("message_id"):
try:
await ctx.delete(chat_id, a["message_id"])
except Exception:
pass
draft.payload["ambiguities"] = []
payload = draft.payload payload = draft.payload
merge_quote_patch(payload, patch) merge_quote_patch(payload, patch)
draft.source_message_id = incoming_msg_id draft.source_message_id = incoming_msg_id
@@ -70,11 +81,16 @@ async def resolve_payload(ctx, payload: dict) -> None:
else: else:
options.pop("sup", None) options.pop("sup", None)
resolved_projects = payload.setdefault("resolved_projects", {})
if not payload.get("project_id") and payload.get("project_name"): if not payload.get("project_id") and payload.get("project_name"):
res = await safe_resolve(ctx.resolver, "project", payload["project_name"]) res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
if res.resolved: if res.resolved:
payload["project_id"] = res.candidates[0].id c = res.candidates[0]
payload["project_name"] = res.candidates[0].name payload["project_id"] = c.id
payload["project_name"] = c.name
resolved_projects[c.name] = c.id
resolved_projects[payload["project_name"]] = c.id
if payload["project_id"] not in payload.get("project_ids", []): if payload["project_id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(payload["project_id"]) payload.setdefault("project_ids", []).append(payload["project_id"])
options.pop("prj", None) options.pop("prj", None)
@@ -86,7 +102,10 @@ async def resolve_payload(ctx, payload: dict) -> None:
for p_name in payload.get("project_names") or []: for p_name in payload.get("project_names") or []:
res = await safe_resolve(ctx.resolver, "project", p_name) res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved: if res.resolved:
p_id = res.candidates[0].id c = res.candidates[0]
p_id = c.id
resolved_projects[c.name] = p_id
resolved_projects[p_name] = p_id
if p_id not in payload.get("project_ids", []): if p_id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(p_id) payload.setdefault("project_ids", []).append(p_id)
if not payload.get("project_id"): if not payload.get("project_id"):
@@ -231,6 +250,9 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
source_message_id=reply_to or draft.source_message_id, source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True, mark_bale_synced=True,
) )
# Send pending ambiguity sub-messages as replies to the main review card
await send_pending_ambiguities(ctx, chat_id, draft)
else: else:
if not draft.review_message_id: if not draft.review_message_id:
return return
@@ -242,6 +264,36 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
await draft_store.set_bale_updated_at(draft.id) await draft_store.set_bale_updated_at(draft.id)
async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
from app.keyboards import ambiguity_keyboard
from app.texts import ambiguity_message_text
if not draft.review_message_id:
return
prefix = str(draft.id)[:8]
payload = draft.payload or {}
ambiguities = payload.get("ambiguities") or []
updated = False
for amb in ambiguities:
if amb.get("status") == "pending" and not amb.get("message_id"):
msg_text = ambiguity_message_text(amb.get("title", ""), amb.get("details", ""))
kb = ambiguity_keyboard(prefix, amb.get("id", ""))
sent = await ctx.send(
chat_id,
msg_text,
reply_markup=kb,
reply_to=draft.review_message_id,
)
if sent and sent.get("message_id"):
amb["message_id"] = sent["message_id"]
updated = True
if updated:
await draft_store.save_payload(draft, mark_bale_synced=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# commit # commit
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -250,6 +302,8 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
payload = draft.payload payload = draft.payload
await resolve_payload(ctx, payload) await resolve_payload(ctx, payload)
newly_created: dict = {}
# 1. Sequential pre-creation for new supplier if user wants to commit # 1. Sequential pre-creation for new supplier if user wants to commit
if not payload.get("supplier_id") and payload.get("supplier_name"): if not payload.get("supplier_id") and payload.get("supplier_name"):
cat_id = payload.get("supplier_category_id") cat_id = payload.get("supplier_category_id")
@@ -267,11 +321,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
sup_body["phone"] = payload["supplier_phone"] sup_body["phone"] = payload["supplier_phone"]
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id) created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
payload["supplier_id"] = created_sup["id"] payload["supplier_id"] = created_sup["id"]
newly_created["supplier"] = payload["supplier_name"]
ctx.resolver.invalidate("suppliers") ctx.resolver.invalidate("suppliers")
except Exception as e: except Exception as e:
logger.warning("Could not auto-create supplier: %s", e) logger.warning("Could not auto-create supplier: %s", e)
# 2. Sequential pre-creation for new materials # 2. Sequential pre-creation for new materials
created_mats = []
for i, item in enumerate(payload.get("items") or [], start=1): 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"): if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
try: try:
@@ -280,9 +336,60 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
"canonical_unit_id": int(item["unit_id"]), "canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id) }, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"]) _set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
created_mats.append(item["material_name"])
ctx.resolver.invalidate("materials") ctx.resolver.invalidate("materials")
except Exception as e: except Exception as e:
logger.warning("Could not auto-create material: %s", e) logger.warning("Could not auto-create material: %s", e)
if created_mats:
newly_created["materials"] = created_mats
# 3. Sequential pre-creation for new projects
created_prjs = []
all_p_names = []
if payload.get("project_names"):
for p in payload["project_names"]:
p_str = str(p).strip()
if p_str and p_str not in all_p_names:
all_p_names.append(p_str)
elif payload.get("project_name"):
p_str = str(payload["project_name"]).strip()
if p_str and p_str not in all_p_names:
all_p_names.append(p_str)
for it in payload.get("items") or []:
it_p = it.get("project_name")
if it_p:
it_p_str = str(it_p).strip()
if it_p_str and it_p_str not in all_p_names:
all_p_names.append(it_p_str)
resolved_map = payload.setdefault("resolved_projects", {})
for p_name in all_p_names:
p_id = resolved_map.get(p_name)
if not p_id:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
p_id = res.candidates[0].id
resolved_map[p_name] = p_id
else:
try:
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
p_id = created_prj.get("id")
if p_id:
resolved_map[p_name] = p_id
created_prjs.append(p_name)
ctx.resolver.invalidate("projects")
except Exception as e:
logger.warning("Could not auto-create project %s: %s", p_name, e)
if p_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
if created_prjs:
newly_created["projects"] = created_prjs
ready, blockers = quote_ready(payload) ready, blockers = quote_ready(payload)
body, problems = build_quote_request(payload, only_resolved_items=only_resolved) body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
@@ -346,4 +453,4 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
dropped = len(payload.get("items") or []) - len(body.get("line_items") or []) dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
if dropped > 0: if dropped > 0:
remaining.append(f"{dropped} قلم ناشناس حذف شد") remaining.append(f"{dropped} قلم ناشناس حذف شد")
await ctx.send(chat_id, quote_committed_report(created, payload, remaining)) await ctx.send(chat_id, quote_committed_report(created, payload, remaining, newly_created=newly_created))
+10 -1
View File
@@ -22,7 +22,7 @@ def _btn(text: str, data: str) -> dict:
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive) # 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]]: 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") op = {"quote": "q", "supplier": "s", "material": "m", "project": "p"}.get(kind, "q")
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]] rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
if has_partial_option: if has_partial_option:
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")]) rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
@@ -72,3 +72,12 @@ def arrive_keyboard() -> dict:
def cancel_keyboard() -> dict: def cancel_keyboard() -> dict:
return _kb([[_btn("❌ انصراف", "ac:no")]]) return _kb([[_btn("❌ انصراف", "ac:no")]])
def ambiguity_keyboard(prefix: str, amb_id: str) -> dict:
"""Inline buttons for ambiguity sub-message (≤64 bytes callback_data)."""
return _kb([
[_btn("✅ تأیید و اعمال در پیش‌نویس", f"amb:ok:{prefix}:{amb_id}")],
[_btn("❌ تایید نمی‌شود", f"amb:no:{prefix}:{amb_id}")],
])
+45 -15
View File
@@ -40,6 +40,7 @@ INTENTS = [
"show_draft", # «وضعیت پیش‌نویس؟» "show_draft", # «وضعیت پیش‌نویس؟»
"add_supplier", "add_supplier",
"add_material", "add_material",
"add_project",
"status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ... "status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ...
"accept_quote", # پذیرش یک استعلام "accept_quote", # پذیرش یک استعلام
"arrive_items", # اعلام رسیدن اقلام "arrive_items", # اعلام رسیدن اقلام
@@ -47,36 +48,55 @@ INTENTS = [
"unknown", "unknown",
] ]
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است. SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم ERP ساختمانی و صنعتی در ایران هستی.
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل: پیام‌های صوتی کاربران به زبان فارسی، با گویش و لهجه اصفهانی و با اصطلاحات محاوره‌ای کارگاهی و بازار بیان شده‌اند که نسخه‌های متنی متعددی توسط موتورهای تبدیل گفتار به متن (STT مانند Whisper و Vosk) از آن‌ها تولید شده است.
وظیفه تو تحلیل پیام، حل خطاهای شنیداری ناشی از لهجه و گویش، تولید متن اجماعی پالایش‌شده و استخراج دقیق داده‌های تدارکاتی است.
یک JSON و فقط JSON برگردان (بدون هرگونه متن، مارک‌داون یا توضیح اضافه) با این ساختار دقیق:
{ {
"consensus_text": "متن تجمیع‌شده و اصلاح‌شده کاربر (فارسی روان و بدون غلط شنیداری)", "consensus_text": "بیانیه رسمی، پالایش‌شده، فشرده و بدون تکیه‌کلام از تقاضای تدارکات کاربر",
"intent": یکی از {intents}, "intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده}, "patch": {شکل partial هر فعالیتی که کاربر داده},
"confidence": 0.0..1.0 "confidence": 0.0..1.0
} }
قوانین: قواعد حیاتی:
- اگر ورودی شامل چند نسخه تبدیل گفتار به متن (STT) از یک پیام صوتی کاربر است: ۱. قواعد آوایی و دستوری لهجه اصفهانی در رمزگشایی خطاهای STT:
۱. این نسخه‌ها از چند مدل مختلف برای افزایش دقت به دست آمده‌اند. آن‌ها را با هم مقایسه و مقابله کن. - شناسه مفعولی پیوسته: پسوند «ـشا» یا «ش ها» در پایان کلمات (مانند پولشا، بارشا) به معنای «آن را / آن‌ها را» است.
۲. خطاهای شنیداری هر نسخه را بر مبنای منطق تدارکات (نام کالاها، برندها، مشخصات فنی و واحدهای اندازه‌گیری) تصحیح و حل تعارض کن. - شناسه دوم شخص جمع (ت به د): پسوندهای «ـِدون»، «برادون»، «برهدون» به معنای «برایتان / به شما» هستند.
۳. متن نهایی اصلاح و تجمیع‌شده را در فیلد consensus_text قرار بده. - تخمین زمانی و مقداری: کلمه «اینا» پس از اعداد یا زمان (مثلاً «۱۰ روز اینا»، «۴۰ تا اینا») قید تخمین و به معنای «حدوداً» است و نباید با کلمات نامربوط (مانند روزنامه) اشتباه گرفته شود.
۴. استخراج قصد (intent) و موجودیت‌ها (patch) را بر اساس همین متن دقیق انجام بده. - حذف حشوهای مکالمه: اصطلاحات پرکننده («عرض به خدمتتون»، «پامون درمیاد»، «دونه‌ای اینا»، «شبت بخیر»، «همین») باید کاملاً از consensus_text پالایش شوند.
- اگر ورودی یک متن عادی است، همان متن را در consensus_text قرار بده. - حل تعارض نام‌ها در مدل‌های STT: در صورت تفاوت املایی نام تأمین‌کننده یا کالا بین چند مدل، اولویت با نگارشی است که با واژگان معتبر فارسی، نام‌های خانوادگی استاندارد و اقلام صنعتی/ساختمانی تطابق دارد.
۲. ساخت فیلد consensus_text:
- متن consensus_text نباید انشا یا نقل‌قول داستانی پر از تعارفات و حواشی باشد.
- کلیه احوالپرسی‌ها، تعارفات و تکیه‌کلام‌ها را کاملاً حذف کن.
- این متن باید یک «بیانیه رسمی، تمیز و ساختاریافته تدارکاتی» باشد که شامل: نوع اقدام (استعلام/خرید)، اقلام، مقادیر، مشخصات، قیمت‌ها، نام تأمین‌کننده، نام پروژه‌ها، مهلت اعتبار و خلاصه نحوه پرداخت باشد.
مثال سبک بیانیه رسمی: «استعلام خرید ۱۲۰ شاخه لوله پلیکا ۶ اینچ (فی ۴۵۰,۰۰۰ تومان) از بازرگانی البرز برای پروژه پردیس. مهلت اعتبار پیش‌فاکتور: ۵ روز. شرایط پرداخت: نقد با ۵٪ تخفیف.»
۳. تفکیک وظایف محاسباتی با defaults.py:
- تو فقط ساختار مفهومی را در patch استخراج می‌کنی؛ محاسبات عددی اقساط و تاریخ‌های تقویم شمسی بر عهده توابع سیستم است.
- برای اقساط: تعداد اقساط را در installments_count استخراج کن (مثلاً ۴).
- برای پرداخت نقدی: درصد تخفیف را در discount_percent استخراج کن (مثلاً ۵).
- برای مهلت اعتبار: تعداد روز را در expiry_days بگذار (مثلاً ۵).
۴. قوانین استخراج پروژه‌ها و اقلام:
- پروژه‌ها: اگر کاربر چند پروژه را ذکر کرد (مانند «برای پردیس و کارگاه مرکزی»)، تمام نام‌ها را عیناً در آرایه project_names قرار بده: ["پردیس", "کارگاه مرکزی"]. پروژه‌ها را با برند اشتباه نگیر.
- اقلام: material_name فقط نام کلی کالا (مانند «لوله پلیکا» یا «میلگرد»)؛ مشخصات فنی (مانند «۶ اینچ» یا «آجدار») در آرایه specs؛ تعداد در quantity؛ واحد در unit_name؛ و قیمت واحد در unit_price (عددی خالص، مثلاً ۴۵۰ هزار = 450000).
۵. قوانین عمومی قصدها (Intents):
- اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته. - اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته.
- اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft". - اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft".
- اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft". - اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft".
- اگر سؤال درباره وضعیت است → intent="status_query". - اگر سؤال درباره وضعیت است → intent="status_query".
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام. - اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام.
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمین‌کننده/استعلام و در patch.arrivals مقادیر. - اگر درباره اعلام رسیدن اقلام است → 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": شکل 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", {"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD",
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}], "items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "project_name": str, "notes": str}],
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]} "payment_terms": [{"label": str, "type": "installments|cash_today|deferred_check|other", "installments_count": int, "is_today": bool, "discount_percent": number, "discount_type": "none|percent|fixed", "discount_value": number, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number, "note": str}], "notes": str}]}
شکل patch برای intent="add_supplier": شکل patch برای intent="add_supplier":
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}} {"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
@@ -84,6 +104,9 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
شکل patch برای intent="add_material": شکل patch برای intent="add_material":
{"material": {"name": str, "unit_name": str, "topic": str}} {"material": {"name": str, "unit_name": str, "topic": str}}
شکل patch برای intent="add_project":
{"project": {"name": str, "description": str}}
شکل patch برای intent="status_query": شکل patch برای intent="status_query":
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}} {"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
@@ -136,6 +159,13 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
lines.append(f"- نسخه {idx} ({name}): «{t}»") lines.append(f"- نسخه {idx} ({name}): «{t}»")
lines.append("\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.") lines.append("\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
user_content = "\n".join(lines) user_content = "\n".join(lines)
elif isinstance(text, str) and ("مدل 1" in text or "مدل ۱" in text) and ("مدل 2" in text or "مدل ۲" in text):
user_content = (
"پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:\n"
+ normalize_digits(text)
+ "\n\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز."
)
first_text = text
else: else:
user_content = normalize_digits(str(text)) user_content = normalize_digits(str(text))
first_text = str(text) first_text = str(text)
+10 -4
View File
@@ -37,14 +37,18 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
scored = [] scored = []
for item in items: for item in items:
name = normalize_text(item.name) name = normalize_text(item.name)
ratio = difflib.SequenceMatcher(None, q, name).ratio()
if q == name: if q == name:
ratio = 1.0 ratio = 1.0
elif q in name: elif q in name and len(q) >= 3:
ratio += 0.35 ratio = 0.85 + 0.15 * (len(q) / len(name))
elif name in q and len(name) >= 3:
ratio = 0.85 + 0.15 * (len(name) / len(q))
else:
ratio = difflib.SequenceMatcher(None, q, name).ratio()
if ratio >= 0.72:
scored.append((ratio, item)) scored.append((ratio, item))
scored.sort(key=lambda pair: (-pair[0], pair[1].name)) scored.sort(key=lambda pair: (-pair[0], pair[1].name))
return [item for ratio, item in scored[:limit] if ratio >= 0.40] return [item for ratio, item in scored[:limit]]
async def _db_units() -> list[Candidate]: async def _db_units() -> list[Candidate]:
@@ -259,6 +263,8 @@ class Resolver:
top = candidates[0] top = candidates[0]
top_norm = normalize_text(top.name) top_norm = normalize_text(top.name)
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio() ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
if (norm_name in top_norm or top_norm in norm_name) and len(norm_name) >= 3:
ratio = max(ratio, 0.90)
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95): if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
return Resolution("one", [top]) return Resolution("one", [top])
return Resolution("many", candidates) return Resolution("many", candidates)
+55 -3
View File
@@ -210,6 +210,20 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
if cat_name: if cat_name:
lines.append(f"\n🏷️ موضوع: {cat_name}") lines.append(f"\n🏷️ موضوع: {cat_name}")
# 🏗️ پروژه(ها)
p_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else [])
resolved_projects = payload.get("resolved_projects") or {}
if p_names:
if len(p_names) == 1:
p = p_names[0]
badge = " ✅" if (p in resolved_projects or payload.get("project_id")) else " ✨ (پروژه جدید)"
lines.append(f"\n🏗️ پروژه: {p}{badge}")
else:
lines.append(f"\n🏗️ پروژه‌ها ({fa_num(len(p_names))} مورد):")
for p in p_names:
badge = " ✅" if p in resolved_projects else " ✨ (پروژه جدید)"
lines.append(f"▫️ {p}{badge}")
# 📦 اقلام # 📦 اقلام
items = payload.get("items") or [] items = payload.get("items") or []
total_amount = 0.0 total_amount = 0.0
@@ -232,7 +246,10 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
# Project # Project
item_proj = it.get("project_name") or default_proj item_proj = it.get("project_name") or default_proj
if item_proj: if item_proj:
lines.append(f" 🏗️ {item_proj}") p_badge = ""
if item_proj not in resolved_projects and not (payload.get("project_id") and len(p_names) <= 1):
p_badge = " ✨ (پروژه جدید)"
lines.append(f" 🏗️ {item_proj}{p_badge}")
else: else:
lines.append(" 🏗️ پروژه مشخص نشده") lines.append(" 🏗️ پروژه مشخص نشده")
@@ -368,7 +385,7 @@ def material_review_text(payload: dict, missing: list[dict]) -> str:
return "\n".join(lines) return "\n".join(lines)
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str: def quote_committed_report(created: dict, payload: dict, remaining: list[str], newly_created: dict | None = None) -> str:
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"] lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name") sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
if sup: if sup:
@@ -379,8 +396,21 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
sheets = created.get("termsheets") or [] sheets = created.get("termsheets") or []
if sheets: if sheets:
lines.append(f"شرایط پرداخت: {len(sheets)}") lines.append(f"شرایط پرداخت: {len(sheets)}")
if payload.get("project_id"): if payload.get("project_id") or payload.get("project_ids"):
lines.append("پروژه: پیوست شد") lines.append("پروژه: پیوست شد")
if newly_created:
new_lines = []
if newly_created.get("supplier"):
new_lines.append(f"🏢 تأمین‌کننده جدید ثبت شد: {esc(newly_created['supplier'])}")
if newly_created.get("projects"):
new_lines.append(f"🏗️ پروژه(های) جدید ثبت شد: {esc('، '.join(newly_created['projects']))}")
if newly_created.get("materials"):
new_lines.append(f"📦 کالای جدید ثبت شد: {esc('، '.join(newly_created['materials']))}")
if new_lines:
lines.append("\n✨ موارد جدید ایجادشده:")
lines += new_lines
if remaining: if remaining:
lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):") lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):")
lines += [f"⚠️ {esc(r)}" for r in remaining] lines += [f"⚠️ {esc(r)}" for r in remaining]
@@ -388,6 +418,19 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
return "\n".join(lines) return "\n".join(lines)
def project_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["🏗️ پیش‌نویس پروژه جدید", f"نام: {esc(payload.get('name'))}"]
if payload.get("description"):
lines.append(f"توضیحات: {esc(payload['description'])}")
lines.append("\nوضعیت فیلدها:")
lines.append(missing_block(missing))
return "\n".join(lines)
def project_committed_report(created: dict) -> str:
return f"✅ پروژه ثبت شد — {esc(created.get('name'))}"
def supplier_committed_report(created: dict) -> str: def supplier_committed_report(created: dict) -> str:
return ( return (
f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n" f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n"
@@ -430,3 +473,12 @@ def status_report(kind: str, quotes: list[dict], extra_note: str | None = None)
lines.append(extra_note) lines.append(extra_note)
lines += [f"• {quote_line(q)}" for q in quotes[:10]] lines += [f"• {quote_line(q)}" for q in quotes[:10]]
return "\n".join(lines) return "\n".join(lines)
def ambiguity_message_text(title: str, details: str) -> str:
return (
f"🤔 آیا نظر شما این بود؟\n"
f"موضوع: {title}\n\n"
f"{details}"
)
+1
View File
@@ -0,0 +1 @@
[]
+82
View File
@@ -0,0 +1,82 @@
import asyncio
import json
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.config import settings
from app.bale.client import BaleClient
from app.api.client import ErpClient
from app.runtime import BotContext, LinkedUser
from app.nlu import understand
from app.handlers import quote
from app.defaults import AUTHORIZED_USERS
from app import drafts as draft_store
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s — %(message)s")
logger = logging.getLogger("send_analysis")
CHAT_ID = 1461166742
TRANSCRIPTS = [
{
"name": "مدل 1 (Whisper)",
"text": "الان شبت بخیر می خواستیم که بیست و پنج تا کیسه عرض به خدمت شما که گچ و خاک از مصطفوی بخریم و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی که اونا تقریبا در می آد پامون دونه ای بیست و پنج میلیون سیمانی ها پامون در می آد حدودا چهل میلیون اینا دونه ای بهمون گفته که می تونین سه قسط بهم بدید کلی پولش ها در طول زمان و یا بیاید یه بار پولش رو امروز بهم بدین یه تخفیف ده درصدی تقریبا براتون لحاظ می کنم عددش رو کم می کنم خریدمون هم عمدتا برای منشیانه و دفتر پیش فاکتورم گفته تا حدودا بیست روزی نامه براتون اوکیه همین",
},
{
"name": "مدل 2 (Vosk)",
"text": "سلام شبت بخیر. میخواستیم که 25 تا کیسه عرض به خدمت شما که گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان 50 کیلویی که اونا تقریباً در میاد پامون دونهای 25 میلیون، سیمانیا پامون در میاد حدوداً 40 میلیون اینا دونهای. بهمون گفته که میتونین سه قسم بدین کلی پولشا در طول زمان و یا بیاین یه باره پولشو امروز بم بدین یه تخفیف 10 درصدی تقریباً بره دون، لحاظ میکنم عددشو کم میکنم. خریدم اونم اومدن برای منشیانه و دفتر. پیشفاکتورم گفته تا حدود یه 20 روز اینا برادون اوکیه. همین.",
},
]
async def run():
print(f"Connecting to Bale and ERP API for Chat ID: {CHAT_ID}...")
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)
user_info = AUTHORIZED_USERS.get(CHAT_ID, {})
user = LinkedUser(
chat_id=CHAT_ID,
user_id=user_info.get("user_id"),
display_name=user_info.get("display_name", "مدیر تدارکات"),
role=user_info.get("role", "procurement_manager"),
is_admin=user_info.get("is_admin", True),
)
# Cancel previous active draft to ensure fresh card
active = await draft_store.get_active(CHAT_ID)
if active:
await draft_store.set_status(active.id, "cancelled")
print("Sending transcripts to NLU (LLM) with upgraded Isfahan dialect prompt...")
result = await understand(settings, TRANSCRIPTS)
print("NLU Result:")
print(json.dumps(result, ensure_ascii=False, indent=2))
consensus_text = result.get("consensus_text") or ""
patch = result.get("patch") or {}
intro_msg = (
f"🤖 **تحلیل هوشمند فایل صوتی (تجمیع خروجی چند مدل با لحاظ لهجه اصفهانی):**\n\n"
f"📝 **متن پالایش‌شده و رسمی (Consensus):**\n«{consensus_text}»\n\n"
f"📋 پیش‌نویس استعلام خرید با مشخصات استخراج‌شده صادر گردید:"
)
await ctx.send(CHAT_ID, intro_msg)
# Create and render the new draft quote
await quote.handle_entities(
ctx,
CHAT_ID,
user,
patch,
source="voice",
)
print("Draft quote sent successfully to Bale chat!")
if __name__ == "__main__":
asyncio.run(run())
+128 -21
View File
@@ -24,7 +24,7 @@ try:
except ImportError: except ImportError:
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg")) sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
CHAT = 100200300 CHAT = 1461166742
_passed = _failed = 0 _passed = _failed = 0
@@ -59,6 +59,9 @@ class FakeBale:
"reply_markup": reply_markup}) "reply_markup": reply_markup})
return {"message_id": message_id} return {"message_id": message_id}
async def delete_message(self, chat_id, message_id):
return True
async def answer_callback_query(self, callback_query_id, text=None): async def answer_callback_query(self, callback_query_id, text=None):
self.answered.append(callback_query_id) self.answered.append(callback_query_id)
@@ -82,6 +85,7 @@ class FakeApi:
self.created_quotes: list[dict] = [] self.created_quotes: list[dict] = []
self.created_suppliers: list[dict] = [] self.created_suppliers: list[dict] = []
self.created_materials: list[dict] = [] self.created_materials: list[dict] = []
self.created_projects: list[dict] = []
self.accept_payloads: list[dict] = [] self.accept_payloads: list[dict] = []
self.arrive_payloads: list[dict] = [] self.arrive_payloads: list[dict] = []
self.login_calls: list[dict] = [] self.login_calls: list[dict] = []
@@ -119,6 +123,10 @@ class FakeApi:
async def list_projects(self, page_size=100): async def list_projects(self, page_size=100):
return [{"id": "prj-1", "name": "برج آسمان"}] return [{"id": "prj-1", "name": "برج آسمان"}]
async def create_project(self, payload, actor_id=None):
self.created_projects.append(payload)
return {"id": f"prj-{len(self.created_projects)}", "name": payload["name"]}
async def list_categories(self, page_size=100): async def list_categories(self, page_size=100):
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"}, return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
{"id": "cat-2", "name": "لوله و اتصالات"}] {"id": "cat-2", "name": "لوله و اتصالات"}]
@@ -180,10 +188,11 @@ def _install_fake_stores() -> None:
import app.drafts as drafts import app.drafts as drafts
import app.runtime as runtime import app.runtime as runtime
async def create(chat_id, kind, payload, source=None): async def create(chat_id, kind, payload, source=None, source_message_id=None):
draft_id = _uuid.uuid4() draft_id = _uuid.uuid4()
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload, draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
missing=None, status="active", source=source, missing=None, status="active", source=source,
source_message_id=source_message_id,
review_message_id=None) review_message_id=None)
MEM["drafts"][str(draft_id)] = draft MEM["drafts"][str(draft_id)] = draft
return draft return draft
@@ -193,16 +202,33 @@ def _install_fake_stores() -> None:
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)] 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 return max(found, key=lambda d: d.id) if found else None
async def get_by_message_id(chat_id, message_id):
for draft in MEM["drafts"].values():
if draft.chat_id == chat_id and draft.status == "active":
if draft.review_message_id == message_id or getattr(draft, "source_message_id", None) == message_id:
return draft
return None
async def get_by_ambiguity_message_id(chat_id, message_id):
for draft in MEM["drafts"].values():
if draft.chat_id == chat_id and draft.status == "active":
for amb in (draft.payload or {}).get("ambiguities") or []:
if amb.get("message_id") == message_id:
return draft, amb
return None, None
async def get_by_prefix(prefix, chat_id=None): async def get_by_prefix(prefix, chat_id=None):
for draft in MEM["drafts"].values(): for draft in MEM["drafts"].values():
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id): if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
return draft return draft
return None return None
async def save_payload(draft, missing=None, review_message_id=None): async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False):
draft.missing = missing draft.missing = missing
if review_message_id: if review_message_id:
draft.review_message_id = review_message_id draft.review_message_id = review_message_id
if source_message_id:
draft.source_message_id = source_message_id
async def set_status(draft_id, status): async def set_status(draft_id, status):
if draft_id in MEM["drafts"]: if draft_id in MEM["drafts"]:
@@ -212,6 +238,7 @@ def _install_fake_stores() -> None:
return 0 return 0
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
drafts.get_by_message_id, drafts.get_by_ambiguity_message_id = get_by_message_id, get_by_ambiguity_message_id
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
async def link_get(self, chat_id): async def link_get(self, chat_id):
@@ -245,6 +272,10 @@ async def fake_transcribe(settings, audio, mime_type=None, filename=None):
"پرداخت سه فاز") "پرداخت سه فاز")
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None):
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# driver # driver
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -262,11 +293,17 @@ def build_ctx():
ctx._log = types.MethodType(fake_log, ctx) ctx._log = types.MethodType(fake_log, ctx)
H.understand = fake_understand H.understand = fake_understand
H.transcribe = fake_transcribe H.transcribe = fake_transcribe
H.transcribe_multi = fake_transcribe_multi
return ctx, bale, api return ctx, bale, api
def text_update(update_id, text): def text_update(update_id, text, reply_to=None, chat_id=None, from_user=None):
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}} cid = chat_id or CHAT
fuser = from_user or {"id": cid, "first_name": "مدیر تدارکات", "username": "proc_manager"}
msg = {"message_id": update_id, "chat": {"id": cid}, "text": text, "from": fuser}
if reply_to:
msg["reply_to_message"] = {"message_id": reply_to}
return {"update_id": update_id, "message": msg}
def voice_update(update_id): def voice_update(update_id):
@@ -283,36 +320,72 @@ async def main() -> int:
_install_fake_stores() _install_fake_stores()
from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload
import app.handlers as H import app.handlers as H
from app.nlu import _extract_json, normalize_digits from app.nlu import _extract_json, normalize_digits, understand
from app.config import settings
# ---- pure logic ------------------------------------------------------- # ---- pure logic -------------------------------------------------------
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو") check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1}) check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
# ---- multi-STT understand pipeline support ---------------------------
multi_stt_samples = [
{"name": "whisper", "text": "25 کیسه گچ از مصطفوی"},
{"name": "vosk", "text": "بیست و پنج کیسه گچ از مصطفوی"},
]
stt_res = await understand(settings, multi_stt_samples)
check("multi-stt: pipeline accepted list input", isinstance(stt_res, dict) and "consensus_text" in stt_res)
check("multi-stt: consensus text generated", bool(stt_res.get("consensus_text")))
payload = new_quote_payload() payload = new_quote_payload()
merge_quote_patch(payload, { merge_quote_patch(payload, {
"supplier_name": "سازان نو",
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}], "items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
"payment_terms": [{"label": "۳ فاز"}], "payment_terms": [{"label": "۳ فاز"}],
}) })
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]}) missing = compute_quote_missing(payload)
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
merge_quote_patch(payload, {
"supplier_name": "سازان نو",
"items": [{"material_name": "میلگرد A3", "quantity": 600}],
})
check("merge: no dup item", len(payload["items"]) == 1) check("merge: no dup item", len(payload["items"]) == 1)
check("merge: qty updated", payload["items"][0]["quantity"] == 600) check("merge: qty updated", payload["items"][0]["quantity"] == 600)
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز") 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) body, problems = build_quote_request(payload)
check("build: blocked without supplier", body is None) check("build: blocked without supplier", body is None)
# ---- pipeline: linking gate ------------------------------------------- # ---- pipeline: whitelist gate & unregistered blocking -----------------
ctx, bale, api = build_ctx() ctx, bale, api = build_ctx()
await H.handle_update(ctx, text_update(1, "سلام")) UNAUTH_CHAT = 999999999
check("gate: unlinked asked username", "نام کاربری" in bale.last_text()) from app.handlers import UNREGISTERED_FILE
if UNREGISTERED_FILE.exists():
try:
UNREGISTERED_FILE.unlink()
except Exception:
pass
await H.handle_update(ctx, text_update(2, "mohaghegh")) # 1. Unregistered user is blocked and recorded
await H.handle_update(ctx, text_update(3, "secret")) await H.handle_update(
check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh") ctx,
check("link: linked greeting", "اتصال انجام شد" in bale.last_text()) text_update(
1,
"سلام من متقاضی جدید هستم",
chat_id=UNAUTH_CHAT,
from_user={"id": UNAUTH_CHAT, "first_name": "کاربر متفرقه", "username": "intruder"},
),
)
check("gate: unauthorized user blocked", "دسترسی شما هنوز تعریف نشده است" in bale.last_text())
import json
unreg_records = json.loads(UNREGISTERED_FILE.read_text(encoding="utf-8")) if UNREGISTERED_FILE.exists() else []
check(
"gate: unregistered logged to JSON file",
any(u.get("chat_id") == UNAUTH_CHAT and u.get("first_name") == "کاربر متفرقه" for u in unreg_records),
)
# 2. Whitelisted user (1461166742) bypasses Casdoor completely
await H.handle_update(ctx, text_update(2, "/whoami"))
check("gate: whitelisted user active without casdoor", "مدیر تدارکات" in bale.last_text())
check("gate: no casdoor login prompted", "نام کاربری Casdoor" not in bale.last_text())
# ---- voice → draft quote ---------------------------------------------- # ---- voice → draft quote ----------------------------------------------
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": { NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
@@ -343,7 +416,7 @@ async def main() -> int:
# refine price via follow-up text # refine price via follow-up text
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": { NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}}) "items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد")) await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
draft = await _active("quote") draft = await _active("quote")
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000) check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
@@ -417,14 +490,48 @@ async def main() -> int:
check("material: posted", api.created_materials check("material: posted", api.created_materials
and api.created_materials[0]["canonical_unit_id"] == 3) and api.created_materials[0]["canonical_unit_id"] == 3)
# ---- new projects in quote + auto-creation on commit --------------------
NLU_QUEUE.append({
"intent": "register_quote",
"confidence": 0.95,
"patch": {
"supplier_name": "سازان نو",
"project_names": ["منشانه", "دفتر"],
"items": [
{"material_name": "میلگرد A3 سایز ۱۴", "quantity": 100, "unit_price": 50000, "project_name": "منشانه"},
{"material_name": "شیر فلکه چهارطرفه", "quantity": 20, "unit_price": 100000, "project_name": "دفتر"},
],
},
})
await H.handle_update(ctx, text_update(18, "استعلام برای منشانه و دفتر"))
q_draft = await _active("quote")
check("multi-project: draft created", q_draft is not None)
check("multi-project: no bogus match to برج آسمان",
"برج آسمان" not in bale.last_text() and "prj" not in (q_draft.payload.get("options") or {}))
check("multi-project: new project badge shown", "✨ (پروژه جدید)" in bale.last_text())
await H.handle_update(ctx, callback_update(19, f"q:ok:{q_draft.id[:8]}"))
created_prj_names = [p["name"] for p in api.created_projects]
check("multi-project: auto-created in ERP", "منشانه" in created_prj_names and "دفتر" in created_prj_names)
check("multi-project: commit report mentions projects", "پروژه(های) جدید ثبت شد" in bale.last_text())
# ---- add project standalone --------------------------------------------
NLU_QUEUE.append({"intent": "add_project", "confidence": 0.9,
"patch": {"project": {"name": "پروژه امید", "description": "پروژه فاز سوم"}}})
await H.handle_update(ctx, text_update(20, "پروژه جدید امید"))
p_draft = await _active("project")
check("project: draft created", p_draft is not None)
await H.handle_update(ctx, callback_update(21, f"p:ok:{p_draft.id[:8]}"))
check("project: posted to ERP", any(p["name"] == "پروژه امید" for p in api.created_projects))
check("project: report sent", "پروژه ثبت شد" in bale.last_text())
# ---- cancel / unknown -------------------------------------------------- # ---- cancel / unknown --------------------------------------------------
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}}) 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(22, "استعلام از سازان نو"))
await H.handle_update(ctx, text_update(19, "/cancel")) await H.handle_update(ctx, text_update(23, "/cancel"))
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text()) check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}}) NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز")) await H.handle_update(ctx, text_update(24, "اسپاگتی بپز"))
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text()) check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed") print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
-76
View File
@@ -249,9 +249,6 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks: networks:
- traefik_network - traefik_network
- default - default
@@ -350,78 +347,6 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 512m mem_limit: 512m
# ---------------------------------------------------------------------------
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
# created additively at startup) and reads ERP users/user_panels for SSO.
# ---------------------------------------------------------------------------
pm-api:
image: v15-pm-api:latest
build:
context: ./pm/backend
dockerfile: Dockerfile
container_name: pm-api
volumes:
- pm_files:/data/files
- ./pm/backend/app:/srv/pm/app
environment:
# Same database as the ERP — PM tables are additive, created at startup.
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
PM_FILES_DIR: /data/files
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
# ERP integration — in-network service URLs.
PM_ERP_BASE_URL: http://api:8000
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
pm-frontend:
image: v15-pm-frontend:latest
build:
context: ./pm/frontend
dockerfile: Dockerfile
container_name: pm-frontend
depends_on:
- pm-api
# PM platform under /pm on the same domain and the same Traefik router.
labels:
- traefik.enable=true
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
- traefik.http.routers.pm.entrypoints=web
# Higher priority than the ERP frontend Host router — otherwise /pm/api
# would reach the ERP SPA (POST → 405).
- traefik.http.routers.pm.priority=100
- traefik.http.services.pm.loadbalancer.server.port=80
networks:
- traefik_network
- default
restart: unless-stopped
mem_limit: 128m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
@@ -438,4 +363,3 @@ volumes:
postgres_data: postgres_data:
minio_data: minio_data:
traefik_certs: traefik_certs:
pm_files:
-94
View File
@@ -1,94 +0,0 @@
# گزارش گپ‌های دفتر موجودی (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 یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title> <title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
-390
View File
@@ -28,7 +28,6 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
@@ -1367,32 +1366,6 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@@ -1741,18 +1714,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2042,69 +2003,6 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -2135,12 +2033,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -2235,133 +2127,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2390,24 +2155,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2454,25 +2201,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/immer": {
"version": "11.1.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
"integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2917,36 +2645,6 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-is": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -3054,57 +2752,6 @@
} }
} }
}, },
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@@ -3237,12 +2884,6 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3329,37 +2970,6 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
-1
View File
@@ -30,7 +30,6 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار", "name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "مدیریت پروژه و تأمین", "short_name": "دفترچه تأمین",
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری", "description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"dir": "rtl", "dir": "rtl",
"lang": "fa", "lang": "fa",
"start_url": "/", "start_url": "/",
+5 -290
View File
@@ -38,18 +38,11 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
); );
const OrganizationPage = lazy(() => const OrganizationPage = lazy(() =>
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
); );
const AdminPanelPage = lazy(() => const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
); );
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجت‌های Power-BI-style
const MvpDashboardPage = lazy(() =>
import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })),
);
function Loading() { function Loading() {
return ( return (
@@ -63,7 +56,6 @@ function Loading() {
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const location = useLocation(); const location = useLocation();
const pathname = location.pathname;
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است // در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
@@ -76,251 +68,16 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
const returnUrl = encodeURIComponent(location.pathname + location.search); const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />; return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
} }
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
شما به عنوان سرپرست پروژه در سیستم ثبت شدهاید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است.
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
</p>
<button
type="button"
onClick={() => {
useAuthStore.getState().logout();
window.location.href = "/login";
}}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
}
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const panels = user.panels ?? {};
const isAdmin = user.role === "admin";
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>; return <>{children}</>;
} }
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها): // صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
function HomeRoute() { function HomeRoute() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {}; if (user?.role === "pm") return <Navigate to="/inventory" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
return <DashboardPage />; return <DashboardPage />;
} }
// ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() {
return (
<iframe
src="/pm/"
title="مدیریت پروژه (PM)"
className="h-dvh w-full border-0"
/>
);
}
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin";
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>;
}
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed =
user?.role === "admin" ||
user?.role === "supervisor" ||
user?.panels?.inventory === "limited";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را می‌بیند)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@@ -340,13 +97,10 @@ export default function App() {
path="/wizard" path="/wizard"
element={ element={
<RequireAuth> <RequireAuth>
<RequireProcurementWrite>
<WizardPage /> <WizardPage />
</RequireProcurementWrite>
</RequireAuth> </RequireAuth>
} }
/> />
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route <Route
path="/suppliers" path="/suppliers"
element={ element={
@@ -390,17 +144,6 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route <Route
path="/units" path="/units"
element={ element={
@@ -434,9 +177,7 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<RequireInventoryChange>
<ChangeInventoryPage /> <ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
@@ -456,24 +197,18 @@ export default function App() {
element={ element={
<RequireAuth> <RequireAuth>
<RequirePmProjects> <RequirePmProjects>
<RequireWarehouseManage>
<WarehousesPage /> <WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
<Route <Route
path="/inventory/org" path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={ element={
<RequireAuth> <RequireAuth>
<RequireOrganization> <RequirePmProjects>
<OrganizationPage /> <OrganizationPage />
</RequireOrganization> </RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -485,26 +220,6 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
<Route
path="/requester"
element={
<RequireAuth>
<RequesterHome />
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
element={
<RequireAuth>
<RequirePmPanel>
<PmPanelPage />
</RequirePmPanel>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+29 -65
View File
@@ -1,84 +1,48 @@
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu"; import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu"; import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه: // منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// ۱. مدیریت انبار (/inventory) // دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// ۲. مدیریت تدارکات (/) // + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// ۳. مدیریت پروژه (/panel/pm) // مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// ۴. سازمان‌دهی (/organization) // پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
export function AppSwitcherMenu({ export function AppSwitcherMenu({
activeModule, activeModule,
canSwitchToProcurement = true, canSwitchToProcurement = true,
}: { }: {
activeModule: "warehouse" | "procurement" | "organization"; activeModule: "warehouse" | "procurement";
canSwitchToProcurement?: boolean; canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
}) { }) {
const logout = useLogout(); const logout = useLogout();
const { pathname } = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {}; const setActiveProject = useAuthStore((s) => s.setActiveProject);
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند
const canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = []; const items: MenuItem[] = [];
if (hasInventory) { const isPm = user?.role === "pm";
items.push({ const pmProjects = isPm ? user?.projects ?? [] : [];
label: "انبار", const projectChildren: MenuItem[] | undefined =
to: "/inventory", activeModule === "warehouse" && isPm
active: activeModule === "warehouse", ? pmProjects.map((p) => ({
}); label: p.name,
} to: pathname, // همان بخش فعلی؛ فقط پروژه عوض می‌شود
active: p.id === user?.project_id,
onClick: () => setActiveProject(p.id),
}))
: undefined;
if (hasProcurement && canSwitchToProcurement) { if (activeModule === "procurement") {
items.push({ items.push({ label: "مدیریت انبار", to: "/inventory" });
label: "تدارکات", items.push({ label: "مدیریت تدارکات", to: "/", active: true });
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/", } else {
active: activeModule === "procurement", items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
}); if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
} }
if (hasPm) {
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
} }
if (canManageOrg) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
active: activeModule === "organization",
});
}
items.push({ divider: true }); items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: logout }); items.push({ label: "خروج از حساب", onClick: logout });
return <PanelMenu title="پنل‌ها" items={items} />; return <PanelMenu title="پنل‌ها" items={items} />;
+11 -84
View File
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-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,11 +16,7 @@ export interface ComboboxProps {
value?: string; value?: string;
onChange?: (item: ComboboxItem) => void; onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name} items: ComboboxSource; // آرایه از string یا {id, name}
// شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر
// فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد)
pinnedIds?: string[];
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از + addSuffix?: string; // متن بعد از +
placeholder?: string; placeholder?: string;
className?: string; className?: string;
@@ -30,11 +25,6 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی
// (و فیلتر وابسته به آن) به‌تنهایی پاک شود.
onClear?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند. // فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean; openOnFocus?: boolean;
@@ -53,9 +43,7 @@ export function Combobox({
value, value,
onChange, onChange,
items, items,
pinnedIds,
allowAdd = true, allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید", addSuffix = "به عنوان گزینه جدید",
placeholder, placeholder,
className, className,
@@ -64,17 +52,11 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
onClear,
inputRef,
openOnFocus = true, openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1); const [highlight, setHighlight] = useState(-1);
// کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز
// می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک
// دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته.
const justOpenedByFocus = useRef(false);
useEffect(() => { useEffect(() => {
setText(value ?? ""); setText(value ?? "");
@@ -92,37 +74,19 @@ export function Combobox({
return all.filter((x) => normalize(x.label).includes(q)); return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]); }, [all, q]);
// آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود.
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
const pin = pinnedIds ?? [];
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
const pinned = pin
.map((id) => all.find((x) => x.id === id))
.filter((x): x is ComboboxItem => x !== undefined);
const pinnedSet = new Set(pinned.map((x) => x.id));
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
}, [all, filtered, pinnedIds]);
const showAdd = const showAdd =
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q); allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0; const listCount = filtered.length + (showAdd ? 1 : 0);
const listCount = visible.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false)); const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => { const pick = (it: ComboboxItem) => {
// آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به setText(it.label);
// placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند.
const nextText = it.id ? it.label : "";
setText(nextText);
setOpen(false); setOpen(false);
setHighlight(-1); setHighlight(-1);
onChange?.(it); onChange?.(it);
onInputChange?.(nextText); onInputChange?.(it.label);
}; };
const addCurrent = () => { const addCurrent = () => {
@@ -147,9 +111,9 @@ export function Combobox({
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1)); setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (highlight >= 0 && highlight < visible.length) { if (highlight >= 0 && highlight < filtered.length) {
pick(visible[highlight]); pick(filtered[highlight]);
} else if (highlight === visible.length && showAdd) { } else if (highlight === filtered.length && showAdd) {
addCurrent(); addCurrent();
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -161,10 +125,8 @@ 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",
showClear && "pe-8",
inputClassName, inputClassName,
)} )}
value={text} value={text}
@@ -178,53 +140,24 @@ export function Combobox({
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => { onFocus={() => {
if (openOnFocus && !open) { if (openOnFocus) setOpen(true);
setOpen(true);
justOpenedByFocus.current = true;
}
}}
onClick={() => {
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیک‌های بعدی تاگل می‌کنند
if (justOpenedByFocus.current) {
justOpenedByFocus.current = false;
return;
}
setOpen((o) => !o);
}} }}
onClick={() => setOpen(true)}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{showClear && (
<button
type="button"
aria-label="پاک کردن"
title="پاک کردن"
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setText("");
setHighlight(-1);
setOpen(true);
onClear?.();
}}
>
<X className="h-3.5 w-3.5" />
</button>
)}
{open && ( {open && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg"> <div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && ( {listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div> <div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
)} )}
{visible.map((it, i) => ( {filtered.map((it, i) => (
<button <button
type="button" type="button"
key={it.id + "|" + i} key={it.id + "|" + i}
className={cn( className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg", "block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg", i === highlight && "bg-slate-bg",
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
)} )}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input e.preventDefault(); // حفظ فوکوس input
@@ -249,15 +182,9 @@ export function Combobox({
onMouseEnter={() => setHighlight(filtered.length)} onMouseEnter={() => setHighlight(filtered.length)}
> >
<span className="font-bold">+</span> <span className="font-bold">+</span>
{addPrefix ? (
<span>
{addPrefix} «<b>{text.trim()}</b>»
</span>
) : (
<span> <span>
«<b>{text.trim()}</b>» {addSuffix} «<b>{text.trim()}</b>» {addSuffix}
</span> </span>
)}
</button> </button>
)} )}
</div> </div>
-17
View File
@@ -6,8 +6,6 @@ import { cn } from "../lib/utils";
export interface MenuItem { export interface MenuItem {
label?: string; label?: string;
to?: string; to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean; active?: boolean;
divider?: boolean; divider?: boolean;
onClick?: () => void; onClick?: () => void;
@@ -114,21 +112,6 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</div> </div>
)} )}
</div> </div>
) : it.href ? (
<a
key={i}
href={it.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
<span aria-hidden className="text-[10px] opacity-60">↗</span>
</a>
) : it.to ? ( ) : it.to ? (
<Link <Link
key={i} key={i}
@@ -16,13 +16,11 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects); const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout(); const logout = useLogout();
// پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود const isPm = user?.role === "pm";
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited"; const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => { useEffect(() => {
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited"; if (!user || user.role !== "pm") {
if (!user || !scoped) {
setState("ok"); setState("ok");
return; return;
} }
@@ -47,7 +45,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, user?.panels, isDev]); }, [user?.id, user?.role, isDev]);
if (state === "loading") { if (state === "loading") {
return ( return (
@@ -1,183 +0,0 @@
// ============================================================
// ویجت ۴ — کنترل روزانه (امروز)
// کارت‌های KPI: بلیت‌های خروج امروز، برگشت از سایت، ضایعات و پذیرش
// + فهرست دقیق نام تحویل‌دهنده‌ها/انجام‌دهنده‌ها + ریز عملیات امروز.
// هر ۶۰ ثانیه خودکار تازه‌سازی می‌شود (useDailyControl).
// ============================================================
import {
ArrowDownToLine,
ArrowUpFromLine,
PackageOpen,
TriangleAlert,
Users,
type LucideIcon,
} from "lucide-react";
import { useDailyControl } from "../../hooks/useDashboardData";
import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
const TYPE_FA: Record<string, string> = {
WITHDRAWAL: "برداشت از انبار",
RETURN: "برگشت از سایت",
DAMAGE: "آسیب‌دیده",
WASTE: "اتلاف",
LOSS: "مفقودی",
PURCHASE: "پذیرش خرید",
TRANSFER_IN: "انتقال ورودی",
TRANSFER_OUT: "انتقال خروجی",
FOUND: "پیدا شده",
STOCK_COUNT: "انبارگردانی",
};
const TYPE_STYLE: Record<string, string> = {
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
RETURN: "bg-amber-bg text-amber",
DAMAGE: "bg-red-bg text-red",
WASTE: "bg-red-bg text-red",
LOSS: "bg-red-bg text-red",
PURCHASE: "bg-green-bg text-green",
};
function KpiCard({
icon: Icon,
label,
tickets,
qty,
tone,
}: {
icon: LucideIcon;
label: string;
tickets: number;
qty: number;
tone: string;
}) {
return (
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
<Icon className="h-4.5 w-4.5" />
</span>
<div className="min-w-0">
<div className="text-[11px] text-muted">{label}</div>
<div className="flex items-baseline gap-1.5 tabular-nums">
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
<span className="text-[10.5px] text-muted">بلیت</span>
<span className="text-[11px] text-muted">·</span>
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
</div>
</div>
</div>
);
}
export function DailyControlPanel() {
const query = useDailyControl();
const d = query.data;
return (
<ChartCard
title="کنترل روزانه — عملیات امروز"
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه به‌روزرسانی می‌شود` : "هر دقیقه به‌روزرسانی می‌شود"}
>
{query.isLoading ? (
<ChartLoading />
) : !d ? (
<ChartEmpty message="داده‌ی امروز در دسترس نیست" />
) : (
<div className="flex flex-col gap-3">
{/* KPI کارت‌ها */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<KpiCard
icon={ArrowUpFromLine}
label="خروج به سایت (برداشت)"
tickets={d.outbound.tickets}
qty={d.outbound.qty}
tone="bg-blue-bg text-[#1d4ed8]"
/>
<KpiCard
icon={ArrowDownToLine}
label="برگشت از سایت"
tickets={d.returns.tickets}
qty={d.returns.qty}
tone="bg-amber-bg text-amber"
/>
<KpiCard
icon={TriangleAlert}
label="ضایعات / اتلاف / مفقودی"
tickets={d.waste.tickets}
qty={d.waste.qty}
tone="bg-red-bg text-red"
/>
<KpiCard
icon={PackageOpen}
label="پذیرش خرید (ورود)"
tickets={d.purchases.tickets}
qty={d.purchases.qty}
tone="bg-green-bg text-green"
/>
</div>
<div className="grid gap-3 lg:grid-cols-5">
{/* نام دقیق انجام‌دهنده‌ها */}
<div className="rounded-xl border border-line p-3 lg:col-span-2">
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
<Users className="h-4 w-4 text-muted" />
تحویل‌دهنده‌ها / انجام‌دهنده‌های امروز
</h3>
{d.performers.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
) : (
<ul className="flex flex-col divide-y divide-line">
{d.performers.map((p, i) => (
<li key={i} className="flex items-center gap-2 py-1.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
{faNum(i + 1)}
</span>
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
</span>
</li>
))}
</ul>
)}
</div>
{/* ریز عملیات امروز */}
<div className="rounded-xl border border-line p-3 lg:col-span-3">
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
{d.items.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">هنوز جابه‌جایی‌ای امروز ثبت نشده است</p>
) : (
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
{d.items.map((it) => (
<li key={it.id} className="flex items-center gap-2 py-1.5">
<Badge
className={cn(
"shrink-0 px-2 py-0 text-[10px]",
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
)}
>
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
</Badge>
<span className="truncate text-[12px] font-bold">{it.material}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted">
{fmtNum(it.quantity)} {it.unit_code}
</span>
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
{fmtDateTime(it.transaction_date)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</ChartCard>
);
}
@@ -1,165 +0,0 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی).
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter, X } from "lucide-react";
import { Combobox, type ComboboxItem } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند.
// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود.
const ALL_OPTION_ID = "";
// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
const LABELS = {
allMaterials: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
cascade: "متریال ← برند ← مشخصات فنی",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
}
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
// فهرست متریال‌ها مستقیماً از کاتالوگ سراسری می‌آید؛ فقط یکتاسازی شناسه‌ها
// انجام می‌شود تا هیچ گزینه‌ی تکراری در فهرست نباشد.
const materials = useMemo(() => {
const seen = new Set<string>();
return (filterQuery.data?.materials ?? []).filter((m) =>
seen.has(m.id) ? false : (seen.add(m.id), true),
);
}, [filterQuery.data]);
const selectedMaterial = useMemo(
() => materials.find((m) => m.id === state.materialId) ?? null,
[materials, state.materialId],
);
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const pick =
(set: (id: string | null, label: string | null) => void) =>
(item: ComboboxItem) =>
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
// فهرست هر فیلد با گزینه‌ی «همه» در ابتدا
const materialItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
[materials],
);
const brandItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allBrands),
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
],
[selectedMaterial],
);
const specItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allSpecs),
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
],
[selectedMaterial],
);
return (
<section
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
aria-label="فیلتر سراسری متریال"
>
<div className="mb-2.5 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
<Filter className="h-3.5 w-3.5" />
</span>
<div>
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
</div>
{activeCount > 0 && (
<button
type="button"
onClick={state.reset}
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
>
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
{LABELS.clearAll}
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
{faNum(activeCount)}
</span>
</button>
)}
</div>
{/* سه فیلتر با عرض یکسان، وسط‌چین و متقارن؛ روی موبایل تک‌ستونی می‌شود */}
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
{/* ۱ — متریال (سرآغاز آبشار) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder={LABELS.allMaterials}
allowAdd={false}
disabled={materials.length === 0}
items={materialItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)}
onChange={pick(state.setMaterial)}
/>
{/* ۲ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)}
onChange={pick(state.setBrand)}
/>
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpecs(null, null)}
onChange={pick(state.setSpecs)}
/>
</div>
</section>
);
}
@@ -1,117 +0,0 @@
// ============================================================
// ویجت ۲ — جدول موجودی راکد (سرمایه‌ی خوابیده)
// ردیف‌هایی با موجودی مثبت که هیچ تراکنشی در بازه‌ی رکود نداشته‌اند.
// آستانه‌ی ۶۰/۹۰ روز با چیپ انتخاب می‌شود؛ روزهای رکود رنگی است.
// ============================================================
import { useState } from "react";
import { Snowflake } from "lucide-react";
import { useDeadStock } from "../../hooks/useDashboardData";
import { fmtNum, faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { cn } from "../../lib/utils";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
function specLabel(specs: Record<string, string>): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
}
function idleSeverity(days: number | null): string {
if (days == null) return "text-muted";
if (days >= 90) return "text-red";
if (days >= 60) return "text-amber";
return "text-muted";
}
export function DeadStockTable() {
const [idleDays, setIdleDays] = useState<60 | 90>(60);
const query = useDeadStock(idleDays);
const items = query.data?.items ?? [];
return (
<ChartCard
title={
<span className="inline-flex items-center gap-1.5">
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
موجودی راکد — سرمایه‌ی خوابیده
</span>
}
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازه‌ی انتخابی نداشته‌اند"
actions={
<div className="flex items-center gap-1">
{([60, 90] as const).map((d) => (
<button
key={d}
type="button"
onClick={() => setIdleDays(d)}
className={cn(
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
)}
>
{faNum(d)} روز و بیشتر
</button>
))}
</div>
}
>
{query.isLoading ? (
<ChartLoading />
) : items.length === 0 ? (
<ChartEmpty message={`موجودی راکدی با آستانه‌ی ${faNum(idleDays)} روز وجود ندارد`} />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>متریال و مشخصات</TableHead>
<TableHead>برند</TableHead>
<TableHead>انبار / پروژه</TableHead>
<TableHead className="text-end">مقدار راکد</TableHead>
<TableHead className="text-end">روزهای رکود</TableHead>
<TableHead>آخرین فعالیت</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const specs = specLabel(it.specs);
return (
<TableRow key={it.inventory_id}>
<TableCell>
<div className="text-[13px] font-bold">{it.material}</div>
{specs && <div className="text-[11px] text-muted">{specs}</div>}
</TableCell>
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
<TableCell>
<div className="text-[12px]">{it.warehouse}</div>
<div className="text-[10.5px] text-muted">{it.project}</div>
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(it.quantity)}{" "}
<span className="text-[10.5px] font-normal text-muted">
{it.unit?.name_fa || it.unit?.code}
</span>
</TableCell>
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
{it.idle_days == null ? "—" : faNum(it.idle_days)}
</TableCell>
<TableCell className="text-[11px] text-muted">
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ChartCard>
);
}
@@ -1,983 +0,0 @@
// ============================================================
// ویجت ۱ — ماتریس Drill-Down سلسله‌مراتبی با نمودار ۱۰۰٪ استک‌شده
// • سطح ۰: محور X = متریال‌ها، استک‌ها = پروژه‌ها
// • سطح ۱: محور X = پروژه‌های متریال انتخاب‌شده، استک‌ها = انبارها
// • قفل هوشمند: با فیلتر فعال انبار، drill-down غیرفعال می‌شود (کلیک بی‌اثر
// و نشانگر پیش‌فرض) چون اسکوپ انبار از قبل بسته شده است.
// • «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار؛ دامنه‌های سراسری
// پروژه/انبار (کنترل‌های چندانتخابی) و متریال انتخاب‌شده به‌صورت تگ حذف‌پذیر.
// فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند و متریال انتخابی با
// هر تغییر آن در برابر payload تازه اعتبارسنجی می‌شود.
// ============================================================
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { XAxisTickContentProps } from "recharts";
import {
CheckSquare,
ChevronDown,
ChevronLeft,
MousePointerClick,
Package,
RotateCcw,
Square,
X,
} from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import type { DrilldownProject } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import {
ChartCard,
ChartEmpty,
ChartLoading,
} from "./chartShared";
import { cn } from "../../lib/utils";
type Level = 0 | 1; // ۰=متریال‌ها (استک: پروژه‌ها)، ۱=پروژه‌ها (استک: انبارها)
// متریال انتخاب‌شده برای drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب
// تگ breadcrumb حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند.
interface DrillNode {
id: string;
name: string;
}
// یک گروه استک (پروژه در نمای کلی، انبار در نمای drill-down) — نام و رنگ از
// payload مشتق می‌شوند
interface StackGroup {
key: string;
name: string;
color: string;
}
// یک میله روی محور X — سهم هر گروه (segments) از مجموع همان واحد.
// کمیت هر گروه علاوه بر segments، به‌صورت پراپرتیِ مسطح و عددی هم روی خودِ شیء
// می‌نشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود.
interface StackedBar {
id: string;
label: string;
unitLabel: string;
total: number;
segments: Record<string, number>;
[segmentKey: string]: number | string | Record<string, number>;
}
// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند)
const ROOT_LEVEL: Level = 0;
const DRILL_LEVEL: Level = 1;
const LABELS = {
root: "کل سازمان",
allProjects: "همه‌ی پروژه‌ها",
allWarehouses: "همه‌ی انبارها",
widgetTitle: "",
projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید",
warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید",
drillHint: "برای مشاهده‌ی سهم پروژه‌ها و انبارها روی میله‌ها کلیک کنید",
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
removeMaterial: "حذف متریال انتخاب‌شده و بازگشت به نمای متریال‌ها",
totalLabel: "مجموع نمایش داده شده",
itemsLabel: "اقلام نمایش داده شده",
tooltipTotal: "مجموع",
tooltipBreakdown: "تفکیک بر اساس",
emptyChart: "موجودی‌ای با این فیلترها یافت نشد",
} as const;
// فراداده‌ی سطوح — برچسب محور X هر سطح (نام‌ها از payload می‌آیند)
const LEVEL_META: Record<Level, { axisTitle: string }> = {
0: { axisTitle: "متریال‌ها" },
1: { axisTitle: "پروژه‌ها" },
};
// ——— تفکیک سگمنت‌ها (استک‌ها) ———
// نوع تفکیک زمینه‌آگاه است: نمای کلی بدون فیلتر پروژه → پروژه‌ها؛ نمای
// drill-down متریال یا نمای دارای فیلتر پروژه → انبارها (تفکیک ریزتر).
type SegmentKind = "project" | "warehouse";
const SEGMENT_META: Record<SegmentKind, { label: string; plural: string }> = {
project: { label: "پروژه", plural: "پروژه‌ها" },
warehouse: { label: "انبار", plural: "انبارها" },
};
function segmentKindFor(level: Level, projectFilterActive: boolean): SegmentKind {
return level === DRILL_LEVEL || projectFilterActive ? "warehouse" : "project";
}
// نام کامل سگمنت انبار — انبارهای هم‌نام در پروژه‌های مختلف با پیشوند پروژه از
// هم تفکیک می‌شوند («مجتمع مسکونی آرام - انبار شمالی»)
const SEGMENT_NAME_SEPARATOR = " - ";
const warehouseSegmentName = (projectName: string, warehouseName: string): string =>
`${projectName}${SEGMENT_NAME_SEPARATOR}${warehouseName}`;
// انبار هم‌نام در «همان پروژه» هم ممکن است (دیتابیس محدودیت یکتایی روی
// (project_id, name) ندارد)؛ فقط در همان حالتِ برخورد، یک شماره‌ی ترتیب پایدار
// به انتهای نام اضافه می‌شود تا راهنما هیچ‌وقت دو برچسب یکسان نداشته باشد.
function disambiguateNames(entries: { key: string; name: string }[]): Map<string, string> {
const keysByName = new Map<string, string[]>();
entries.forEach((entry) => {
const keys = keysByName.get(entry.name) ?? [];
keys.push(entry.key);
keysByName.set(entry.name, keys);
});
const names = new Map<string, string>();
entries.forEach((entry) => {
const twins = keysByName.get(entry.name) ?? [];
names.set(
entry.key,
twins.length > 1 ? `${entry.name} (${faNum(twins.indexOf(entry.key) + 1)})` : entry.name,
);
});
return names;
}
// شناسه‌ی استک در Recharts + سقف عرض میله‌ها به‌ازای هر سطح
const STACK_ID = "dashboard-stack";
const MAX_BAR_SIZE: Record<Level, number> = { 0: 110, 1: 64 };
const BAR_TOP_RADIUS: [number, number, number, number] = [6, 6, 0, 0];
// ——— رنگ گروه‌های استک ———
// رنگ هر گروه فقط از «ترتیب آن در payload» مشتق می‌شود (هیچ نگاشت دستی
// گروه→رنگ وجود ندارد)؛ چرخش طلایی تضمین می‌کند هر تعداد گروه، رنگ‌های
// به‌اندازه‌ی کافی متمایز بگیرند.
const GROUP_HUE_BASE = 232; // هم‌خانواده با رنگ accent سیستم
const GROUP_HUE_STEP = 137.5; // چرخش طلایی
const GROUP_SATURATION = 58;
const GROUP_LIGHTNESS = 52;
function groupColor(index: number): string {
const hue = (GROUP_HUE_BASE + index * GROUP_HUE_STEP) % 360;
return `hsl(${hue.toFixed(1)} ${GROUP_SATURATION}% ${GROUP_LIGHTNESS}%)`;
}
// پسوند شمارش گزینه‌های چند‌انتخابی («… و ۲ پروژه‌ی دیگر»)
const otherProjectsLabel = (count: number) => `${faNum(count)} پروژه‌ی دیگر`;
const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`;
function crumbHint(level: Level, label: string): string {
if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و متریال انتخابی`;
return `حذف «${label}» و بازگشت به نمای ${LEVEL_META[ROOT_LEVEL].axisTitle}`;
}
// برچسب یک دامنه‌ی چند‌انتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش»
function scopeLabel(
options: { id: string; name: string }[],
selectedIds: string[],
allLabel: string,
othersLabel: (count: number) => string,
): string {
if (selectedIds.length === 0) return allLabel;
const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name);
const first = names[0] ?? allLabel;
return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first;
}
// ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ———
// هر میله یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی
// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند.
const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px)
const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px)
const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px)
// شکستن نام بلند به خطوط کوتاه — پایه‌ی تیک محور X
function wrapAxisLabel(text: string): string[] {
const lines: string[] = [];
for (const word of text.split(/\s+/).filter(Boolean)) {
// واژه‌های بسیار بلند هم به تکه‌های قابل نمایش شکسته می‌شوند
const chunks: string[] = [];
for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) {
chunks.push(word.slice(i, i + LABEL_LINE_CHARS));
}
for (const chunk of chunks) {
const last = lines[lines.length - 1];
if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) {
lines[lines.length - 1] = `${last} ${chunk}`;
} else {
lines.push(chunk);
}
}
}
if (lines.length <= LABEL_MAX_LINES) return lines;
const head = lines.slice(0, LABEL_MAX_LINES - 1);
head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`);
return head;
}
// تیک سفارشی محور X — برچسب چندخطی با فاصله‌ی کافی (بدون هم‌پوشانی).
// فاصله‌ی خط اول از محور از tickMargin خودِ XAxis می‌آید و شروع متن با
// dominantBaseline=hanging تراز می‌شود (مثل تیک پیش‌فرض Recharts).
function AxisTick({ x, y, payload }: XAxisTickContentProps) {
const xPos = typeof x === "number" ? x : Number(x);
const yPos = typeof y === "number" ? y : Number(y);
const lines = wrapAxisLabel(String(payload?.value ?? ""));
return (
<text
x={xPos}
y={yPos}
textAnchor="middle"
dominantBaseline="hanging"
fill="#64748b"
fontSize={11}
fontFamily="Vazirmatn"
>
{lines.map((line, i) => (
<tspan key={i} x={xPos} dy={i === 0 ? 0 : LABEL_LINE_HEIGHT}>
{line}
</tspan>
))}
</text>
);
}
// تیک محور Y در نمای ۱۰۰٪ — کسر ۰..۱ به درصد فارسی
function percentTick(value: unknown): string {
const n = Number(value);
return Number.isFinite(n) ? `${faNum(Math.round(n * 100))}٪` : "";
}
// کمیت‌ها هرگز بین واحدها جمع نمی‌شوند: برای هر میله «واحد غالب» (بیشترین
// مجموع) از خود payload انتخاب می‌شود و فقط کمیت‌های همان واحد استک می‌شوند.
function pickDominantUnit(qtyByUnit: Record<string, number>): string | null {
let code: string | null = null;
let best = -Infinity;
for (const [unitCode, value] of Object.entries(qtyByUnit)) {
if (value > best) {
best = value;
code = unitCode;
}
}
return code;
}
// کمیت‌ها همیشه عدد خالص می‌مانند: هر مقدار غیرعددی/نامعتبر (رشته، NaN) از
// محاسبه حذف می‌شود تا میله‌ها هرگز به‌خاطر داده‌ی نامعتبر نامرئی نشوند.
function numericValues(qtyByUnit: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {};
for (const [unitCode, rawValue] of Object.entries(qtyByUnit)) {
const value = Number(rawValue);
if (Number.isFinite(value)) out[unitCode] = value;
}
return out;
}
// تولتیپ سفارشی — کارت شیشه‌ای مینیمال با سلسله‌مراتب روشن:
// سربرگ (موجودیت میله + نوع تفکیک جاری)، بدنه (به‌ازای هر سگمنت: نقطه‌ی رنگ
// داینامیک، نام کامل، کمیت و سهم درصدی) و پاصفحه (جمع میله).
// عرض کارت با محتوا رشد می‌کند (w-max) و فقط با سقف واکنش‌گرا محدود می‌شود؛
// متن‌های بلند (عنوان/نام سگمنت) در همان سقف می‌شکنند تا هرگز از کادر بیرون
// نزنند، و گروه‌های عددی با whitespace-nowrap یک‌تکه می‌مانند تا اعداد و متن
// در حالت RTL به‌هم نریزند.
function CustomTooltip({
active,
label,
payload,
stacks,
segmentKind,
}: {
active?: boolean;
label?: string | number;
payload?: readonly { payload?: StackedBar }[];
stacks: StackGroup[];
segmentKind: SegmentKind;
}) {
if (!active || !payload?.length) return null;
const bar = payload[0]?.payload;
if (!bar) return null;
const rows = stacks
.map((stack) => ({ stack, value: bar.segments[stack.key] ?? 0 }))
.filter((row) => row.value > 0);
if (rows.length === 0) return null;
const total = rows.reduce((sum, row) => sum + row.value, 0);
return (
<div
dir="rtl"
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[320px] sm:whitespace-nowrap"
>
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
<div className="flex flex-col gap-0.5">
<span className="whitespace-normal text-xs font-bold text-slate-800">
{String(label ?? bar.label)}
</span>
<span className="text-[10px] text-slate-500 sm:text-xs">
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
</span>
</div>
<div className="my-2 border-b border-slate-100" />
{/* ردیف هر سگمنت: نام سمت راست، کمیت و درصد چسبیده به هم سمت چپ */}
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div key={row.stack.key} className="flex w-full items-center justify-between gap-6">
<div className="flex items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: row.stack.color }}
/>
<span className="whitespace-normal text-[11px] font-medium text-slate-700 sm:text-xs">
{row.stack.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{fmtNum(row.value)} {bar.unitLabel}
</span>
<span className="whitespace-nowrap text-[11px] tabular-nums text-slate-700 sm:text-xs">
{faNum(Math.round((row.value / total) * 100))}٪
</span>
</div>
</div>
))}
</div>
<div className="my-2 border-b border-slate-100" />
{/* جمع میله — «مجموع: ۸۰۰ متر» کنار هم و از سمت راست */}
<div className="flex w-full items-center justify-start gap-1.5 text-xs font-semibold text-slate-600">
<span>{LABELS.tooltipTotal}:</span>
<span className="whitespace-nowrap tabular-nums">
{fmtNum(total)} {bar.unitLabel}
</span>
</div>
</div>
);
}
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component ---
// کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb
// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را
// آزاد می‌کند. کلیک‌های درون منو با stopPropagation جدا می‌شوند تا هرگز به
// handlerهای نمودار نرسند.
function MultiSelectDropdown({
label,
options,
selectedIds,
onChange,
disabled,
allLabel,
onClearAll,
title,
}: {
label: string;
options: { id: string; name: string }[];
selectedIds: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
// با ارائه‌ی این دو، ردیف «همه‌ی …» در ابتدای فهرست ظاهر می‌شود
allLabel?: string;
onClearAll?: () => void;
title?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const toggleItem = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((x) => x !== id));
} else {
onChange([...selectedIds, id]);
}
};
return (
<div className="relative inline-block" ref={ref}>
<button
type="button"
disabled={disabled}
title={title}
onClick={() => setOpen(!open)}
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
disabled ? "cursor-not-allowed opacity-50" : "hover:bg-slate-bg hover:text-accent cursor-pointer",
selectedIds.length > 0 ? "text-accent" : "text-ink"
)}
>
<span>{label}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 max-h-60 w-56 overflow-auto rounded-lg border border-line bg-panel p-1 shadow-xl">
{allLabel && onClearAll && (
<>
<button
type="button"
onClick={(event) => {
// رویداد منو هرگز نباید به کلیک‌های نمودار برسد
event.stopPropagation();
onClearAll();
setOpen(false);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs font-bold text-accent transition-colors hover:bg-slate-bg"
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="truncate">{allLabel}</span>
</button>
<div className="my-1 border-t border-line" />
</>
)}
{options.length === 0 ? (
<div className="p-2 text-center text-xs text-muted">گزینه‌ای یافت نشد</div>
) : (
options.map((opt) => {
const isSelected = selectedIds.includes(opt.id);
return (
<div
key={opt.id}
onClick={(event) => {
// رویداد انتخاب گزینه هم ایزوله می‌شود
event.stopPropagation();
toggleItem(opt.id);
}}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-slate-bg"
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-accent" />
) : (
<Square className="h-4 w-4 text-muted" />
)}
<span className="truncate">{opt.name}</span>
</div>
);
})
)}
</div>
)}
</div>
);
}
export function DrilldownMatrix() {
const query = useDrilldown();
// متریال انتخاب‌شده (سطح ۱) — تنها حالت محلی نمودار؛ سطح از آن مشتق می‌شود
const [selectedMaterial, setSelectedMaterial] = useState<DrillNode | null>(null);
const level: Level = selectedMaterial ? DRILL_LEVEL : ROOT_LEVEL;
const materialId = selectedMaterial?.id ?? null;
// فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
// تفکیک جاری نمودار: نمای کلی بدون فیلتر پروژه → پروژه‌ها؛ نمای drill-down یا
// نمای دارای فیلتر پروژه → انبارها
const segmentKind = segmentKindFor(level, selectedProjectIds.length > 0);
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
const plotRef = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
const data = query.data;
// گزینه‌های آبشاری از دل payload ساخته می‌شوند
const projectOptions = useMemo(() => {
if (!data) return [];
const map = new Map<string, string>();
data.materials.forEach((m) => {
m.projects.forEach((p) => {
map.set(p.id, p.name);
});
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data]);
const warehouseOptions = useMemo(() => {
if (!data) return [];
const map = new Map<string, string>();
data.materials.forEach((m) => {
m.projects.forEach((p) => {
if (selectedProjectIds.length === 0 || selectedProjectIds.includes(p.id)) {
p.warehouses.forEach((w) => {
map.set(w.id, w.name);
});
}
});
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data, selectedProjectIds]);
// ——— همگام‌سازی با فیلتر سراسری (Zustand) ———
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی انتخاب‌ها، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه
// در سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
// که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم.
useEffect(() => {
if (!data) return;
const validProjectIds = new Set(projectOptions.map((o) => o.id));
setSelectedProjectIds((ids) =>
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
);
const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id));
setSelectedWarehouseIds((ids) =>
ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)),
);
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
// ۲) اگر متریال انتخاب‌شده از payload حذف شده باشد، نمودار به نمای متریال‌ها
// برمی‌گردد؛ انتخاب معتبر دست‌نخورده می‌ماند.
useEffect(() => {
if (!data || !selectedMaterial) return;
if (!data.materials.some((m) => m.id === selectedMaterial.id)) {
setSelectedMaterial(null);
}
}, [globalFilterKey, data, selectedMaterial]);
// ——— اندازه‌گیری عرض کانتینر نمودار ———
// عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر
// اسکرول‌دار است، پس با هر تغییر اندازه‌ی پنجره/کارت دوباره اندازه‌گیری می‌شود.
useEffect(() => {
const el = plotRef.current;
if (!el) return;
const measure = () => setPlotWidth(el.clientWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
// ——— داده‌ی نمودار ۱۰۰٪ استک‌شده ———
// محورها، سگمنت‌ها (پروژه/انبار بر اساس segmentKind)، نام‌های کامل، واحدها
// و رنگ‌ها همه از payload مشتق می‌شوند؛ هیچ نگاشت دستی در کد وجود ندارد.
const { bars, stacks, unitLabels } = useMemo(() => {
const empty = {
bars: [] as StackedBar[],
stacks: [] as StackGroup[],
unitLabels: [] as string[],
};
if (!data) return empty;
const matchesProject = (pid: string) =>
selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
const matchesWarehouse = (wid: string) =>
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
interface GroupAcc {
name: string;
byUnit: Record<string, number>;
}
const barsAcc = new Map<
string,
{ id: string; label: string; ownUnits: Record<string, number>; groups: Map<string, GroupAcc> }
>();
// هر میله حتی بدون گروه هم ساخته می‌شود تا برچسبش روی محور بماند
// (متریال‌های کاتالوگی با موجودی صفر هم روی محور دیده می‌شوند).
const ensureBar = (id: string, label: string, ownUnits: Record<string, number>) => {
let bar = barsAcc.get(id);
if (!bar) {
bar = { id, label, ownUnits: numericValues(ownUnits), groups: new Map() };
barsAcc.set(id, bar);
}
return bar;
};
const addGroup = (
bar: { groups: Map<string, GroupAcc> },
groupId: string,
groupName: string,
qtyByUnit: Record<string, number>,
) => {
let group = bar.groups.get(groupId);
if (!group) {
group = { name: groupName, byUnit: {} };
bar.groups.set(groupId, group);
}
for (const [unitCode, value] of Object.entries(numericValues(qtyByUnit))) {
group.byUnit[unitCode] = (group.byUnit[unitCode] ?? 0) + value;
}
};
// میله‌ها و سگمنت‌ها با یک الگوی واحد ساخته می‌شوند: میله = متریال (نمای
// کلی) یا پروژه (نمای drill-down)، و سگمنت بر اساس تفکیک زمینه‌آگاه
// (segmentKind) پروژه یا انبار است.
const addBar = (
barId: string,
barLabel: string,
ownUnits: Record<string, number>,
projects: DrilldownProject[],
) => {
const bar = ensureBar(barId, barLabel, ownUnits);
projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return;
if (segmentKind === "project") {
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
} else {
// سگمنت انبار: نام کامل «پروژه - انبار» تا انبارهای هم‌نام در
// پروژه‌های مختلف در راهنما و تولتیپ قابل تفکیک باشند
addGroup(bar, wh.id, warehouseSegmentName(proj.name, wh.name), wh.qty_by_unit);
}
});
});
};
if (level === ROOT_LEVEL) {
// میله = متریال، سگمنت = پروژه (نمای کلی) یا انبار (وقتی فیلتر پروژه فعال است)
data.materials.forEach((mat) => addBar(mat.id, mat.name, mat.qty_by_unit, mat.projects));
} else {
// میله = پروژه‌ی متریال انتخاب‌شده، سگمنت = انبار
const mat = data.materials.find((m) => m.id === materialId);
mat?.projects.forEach((proj) => addBar(proj.id, proj.name, proj.qty_by_unit, [proj]));
}
const groupNames = new Map<string, string>();
const bars: StackedBar[] = [];
for (const bar of barsAcc.values()) {
// واحد غالب میله: از مجموع گروه‌ها و در نبود گروه، از واحد خودِ موجودیت
const unitTotals: Record<string, number> = { ...bar.ownUnits };
for (const group of bar.groups.values()) {
for (const [unitCode, value] of Object.entries(group.byUnit)) {
unitTotals[unitCode] = (unitTotals[unitCode] ?? 0) + value;
}
}
const unitCode = pickDominantUnit(unitTotals);
const segments: Record<string, number> = {};
let total = 0;
for (const [groupId, group] of bar.groups) {
const value = unitCode ? group.byUnit[unitCode] ?? 0 : 0;
segments[groupId] = value;
total += value;
groupNames.set(groupId, group.name);
}
bars.push({
id: bar.id,
label: bar.label,
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
total,
segments,
// مسطح‌سازی برای Recharts: هر <Bar dataKey={stack.key}> مقدار عددی را
// مستقیماً از سطح پراپرتی‌های همین شیء می‌خواند
...segments,
});
}
bars.sort((a, b) => b.total - a.total);
// فهرست استک‌ها = اجتماع گروه‌های حاضر؛ ترتیب (و بنابراین رنگ‌ها) از مجموع
// کمیت‌ها مشتق می‌شود و در حالت تساوی با نام، پایدار می‌ماند.
const stackTotals = new Map<string, number>();
for (const bar of bars) {
for (const [groupId, value] of Object.entries(bar.segments)) {
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
}
}
const orderedKeys = [...stackTotals.entries()]
.sort(
(a, b) =>
b[1] - a[1] ||
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
)
.map(([key]) => key);
// نام‌های تکراری (انبار هم‌نامِ همان پروژه) پیش از ساخت راهنما تفکیک می‌شوند
const uniqueNames = disambiguateNames(
orderedKeys.map((key) => ({ key, name: groupNames.get(key) ?? key })),
);
const stacks: StackGroup[] = orderedKeys.map((key, index) => ({
key,
name: uniqueNames.get(key) ?? groupNames.get(key) ?? key,
color: groupColor(index),
}));
return {
bars,
stacks,
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
};
}, [data, level, materialId, segmentKind, selectedProjectIds, selectedWarehouseIds]);
// عرض صریح نمودار: هر میله یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و
// برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
// کانتینر (و کف تعیین‌شده) نمی‌شود.
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
// ——— قفل هوشمند drill-down ———
// اگر کاربر انبار(های) مشخصی را در دامنه‌ی سراسری فیلتر کرده باشد، عمیق‌تر
// رفتن روی نمودار بی‌معناست؛ کلیک بی‌اثر و نشانگر پیش‌فرض می‌شود.
const drillLocked = selectedWarehouseIds.length > 0;
const handleBarClick = (entry: unknown) => {
// قفل فعال → هیچ پرشی انجام نمی‌شود
if (drillLocked) return;
// سطح پروژه‌ها، سطح پایانی است؛ فقط از نمای متریال‌ها drill می‌شویم
if (level !== ROOT_LEVEL) return;
const bar = (entry as { payload?: StackedBar })?.payload;
if (!bar) return;
setSelectedMaterial({ id: bar.id, name: bar.label });
};
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک می‌شوند
const resetToRoot = () => {
setSelectedMaterial(null);
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// حذف تگ متریال — فقط حالت محلی نمودار صفر می‌شود و دامنه‌های سراسری دست‌نخورده می‌مانند
const clearMaterial = () => setSelectedMaterial(null);
// دامنه‌ی پروژه فقط دامنه‌ی انبارِ زیرمجموعه‌اش را آزاد می‌کند
const handleProjectScopeChange = (ids: string[]) => {
setSelectedProjectIds(ids);
if (ids.length === 0) setSelectedWarehouseIds([]);
};
// ردیف «همه‌ی پروژه‌ها» در منوی گره: دامنه‌ی پروژه و دامنه‌ی عمیق‌ترش (انبار) آزاد می‌شود
const clearProjectScope = () => {
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و قفل drill-down برداشته می‌شود
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
const projectScopeLabel = () =>
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
const warehouseScopeLabel = () =>
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
// راهنمای تعامل با نمودار — هماهنگ با سطح جاری و قفل هوشمند
const drillHint = (): string => {
if (level !== ROOT_LEVEL) return "";
return drillLocked ? LABELS.lockedHint : LABELS.drillHint;
};
// جمع زدن کمیت‌های با واحدهای متفاوت بی‌معناست؛ در آن حالت تعداد اقلام می‌آید
const totalText =
unitLabels.length === 1
? `${LABELS.totalLabel}: ${fmtNum(bars.reduce((sum, bar) => sum + bar.total, 0))} ${unitLabels[0]}`
: `${LABELS.itemsLabel}: ${faNum(bars.length)}`;
// ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک)
const crumbClass = (isCurrent: boolean) =>
cn(
"max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]",
isCurrent
? "bg-accent-bg text-accent hover:bg-accent-bg/70"
: "text-muted hover:bg-slate-bg hover:text-ink",
);
return (
<ChartCard title={LABELS.widgetTitle}>
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و متریال انتخاب‌شده
به‌صورت تگ حذف‌پذیر می‌آید؛ ✕ و ریشه هر دو انتخاب متریال را صفر می‌کنند. */}
<nav
dir="rtl"
aria-label="مسیر پیمایش گزارش"
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
>
{/* ریشه — بازگشت کامل به کل سازمان */}
<button
type="button"
onClick={resetToRoot}
aria-current={level === ROOT_LEVEL ? "page" : undefined}
title={crumbHint(ROOT_LEVEL, LABELS.root)}
className={crumbClass(level === ROOT_LEVEL)}
>
{LABELS.root}
</button>
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چند‌انتخابی، برچسب بازتاب انتخاب‌ها */}
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={projectScopeLabel()}
options={projectOptions}
selectedIds={selectedProjectIds}
onChange={handleProjectScopeChange}
allLabel={LABELS.allProjects}
onClearAll={clearProjectScope}
title={LABELS.projectScopeHint}
/>
{/* گره‌ی دامنه‌ی انبار — فقط وقتی دامنه‌ی پروژه/انبار فعال است */}
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={warehouseScopeLabel()}
options={warehouseOptions}
selectedIds={selectedWarehouseIds}
onChange={setSelectedWarehouseIds}
allLabel={LABELS.allWarehouses}
onClearAll={clearWarehouseScope}
title={LABELS.warehouseScopeHint}
/>
</>
)}
{/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */}
{selectedMaterial && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
<button
type="button"
onClick={clearMaterial}
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
>
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
</button>
<button
type="button"
onClick={(event) => {
// ✕ نباید به handler بدنه‌ی تگ برسد
event.stopPropagation();
clearMaterial();
}}
aria-label={LABELS.removeMaterial}
title={LABELS.removeMaterial}
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
>
<X aria-hidden className="h-3 w-3" />
</button>
</span>
</>
)}
<span className="ms-auto text-[11px] tabular-nums text-muted">{totalText}</span>
</nav>
{/* میله‌های صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط
وقتی است که هیچ میله‌ای با فیلترها هم‌خوانی نداشته باشد */}
{query.isLoading ? (
<ChartLoading />
) : !bars.length ? (
<ChartEmpty message={LABELS.emptyChart} />
) : (
<>
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
میله‌ها پخش شوند و برچسب‌های فاصله‌ی کافی داشته باشند.
ریشه‌ی حلقه‌ی مشکی فوکوس: Recharts لایه‌های z-index را به‌صورت
<g tabindex="-1" class="recharts-zIndex-layer_*"> رندر می‌کند
(ZIndexPortal.js) و آن g کل ناحیه‌ی نمودار را می‌پوشاند؛ پس با
کلیک، فوکوس روی همین g می‌نشیند و مرورگر حلقه‌ی پیش‌فرض را دور کل
نمودار می‌کشد. چون outline ارث‌بری نمی‌شود، مهار آن فقط با
سلکتور فرزند (g/سایر عناصر داخل نمودار) ممکن است. */}
<div
ref={plotRef}
dir="ltr"
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
style={{ width: '100%', height: CHART_HEIGHT }}
>
{/* بدون لایه‌ی دسترس‌پذیری، Recharts روی خود SVG دیگر tabIndex
نمی‌گذارد؛ ولی لایه‌های z-index داخلی‌اش (g[tabindex="-1"]) هنوز
فوکوس‌پذیرند و همان‌ها منبع حلقه‌ی فوکوس‌اند — که با سلکتورهای
فرزند روی wrapper مهار شده‌اند. */}
<BarChart
width={chartWidth}
height={CHART_HEIGHT}
data={bars}
stackOffset="expand"
accessibilityLayer={false}
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="label"
tick={AxisTick}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={AXIS_HEIGHT}
tickMargin={AXIS_TICK_MARGIN}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={percentTick}
tickLine={false}
axisLine={false}
width={56}
domain={[0, 1]}
tickCount={5}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={(props) => (
<CustomTooltip {...props} stacks={stacks} segmentKind={segmentKind} />
)}
/>
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشه‌های گرد فقط
برای بالاترین گروه (آخرین Bar) اعمال می‌شود */}
{stacks.map((stack, index) => (
<Bar
key={stack.key}
dataKey={stack.key}
stackId={STACK_ID}
fill={stack.color}
radius={index === stacks.length - 1 ? BAR_TOP_RADIUS : 0}
maxBarSize={MAX_BAR_SIZE[level]}
isAnimationActive={false}
onClick={handleBarClick}
className={cn(
"transition-opacity",
drillLocked ? "cursor-default" : "cursor-pointer hover:opacity-90",
)}
/>
))}
</BarChart>
</div>
{/* راهنمای رنگ‌ها — از همان سگمنت‌های payload ساخته می‌شود و نام
انبارها به‌صورت کامل «پروژه - انبار» نمایش داده می‌شود */}
{stacks.length > 0 && (
<div
dir="rtl"
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
>
<span className="font-bold">
{LEVEL_META[level].axisTitle} × {SEGMENT_META[segmentKind].plural}:
</span>
{stacks.map((stack) => (
<span key={stack.key} className="inline-flex items-center gap-1">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: stack.color }} />
<span className="max-w-[16rem] truncate" title={stack.name}>
{stack.name}
</span>
</span>
))}
</div>
)}
{drillHint() && (
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
<MousePointerClick className="h-3.5 w-3.5" />
{drillHint()}
</p>
)}
</>
)}
</ChartCard>
);
}
@@ -1,352 +0,0 @@
// ============================================================
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
// ============================================================
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useDailyFlow } from "../../hooks/useDashboardData";
import { isoToJalaliStr } from "../../lib/jalali";
import { fmtNum } from "../../lib/formatters";
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
const FLOW_COLORS = {
inbound: "#059669", // ورود به انبار — زمردی
returns: "#0d9488", // برگشت از سایت — تیل
siteOut: "#f59e0b", // خروج به سایت — کهربایی
waste: "#dc2626", // ضایعات و اتلاف — قرمز
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
balance: "#0f172a", // خط مانده — سرمهای
} as const;
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
const LABELS = {
title: "جریان روزانه موجودی",
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
inbound: "ورود به انبار",
returns: "برگشت از سایت",
siteOut: "خروج به سایت",
waste: "ضایعات و اتلاف",
other: "سایر حرکات (انتقال/شمارش)",
balance: "مانده",
opening: "مانده ابتدای روز",
net: "تغییر خالص روز",
closing: "مانده پایان روز",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
} as const;
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> به‌عنوان مقدار محدوده می‌خواند
type Range = [number, number];
// یک ردیف زمانی — سگمنتها به‌صورت پراپرتی‌های مسطح می‌نشینند تا dataKey هر
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
interface FlowRow {
date: string;
balance: number; // خط پله‌ای مانده = مانده پایان روز
opening: number;
closing: number;
net: number;
inbound: number;
returns: number;
siteOut: number;
waste: number;
other: number;
hasMovement: boolean;
segInbound: Range;
segReturns: Range;
segSiteOut: Range;
segWaste: Range;
segOther: Range;
}
// گرد کردن تا ۴ رقم اعشار — مثل بقیه‌ی تجمیع‌های داشبورد
const round4 = (v: number) => Math.round(v * 10000) / 10000;
// حاشیه‌ی دامنه‌ی محور Y (نسبت به بازه‌ی داده) تا لبه‌ها به قاب نچسبند
const DOMAIN_PAD_RATIO = 0.04;
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور می‌آید
const BAR_CATEGORY_GAP = "18%";
// همه‌ی سگمنت‌ها یک ستون مشترک می‌سازند (نه چند ستون باریک کنار هم)؛ چون
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
// دقیقاً در همان ارتفاع شناور رسم می‌کند.
const FLOW_STACK_ID = "flow-waterfall";
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
function FlowTooltip({
active,
payload,
}: {
active?: boolean;
payload?: readonly { payload?: FlowRow }[];
}) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
];
if (row.other !== 0) {
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
}
const netTone =
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
return (
<div
dir="rtl"
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
>
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</div>
<div className="my-2 border-b border-slate-100" />
<div className="flex flex-col gap-1.5">
{lines.map((line) => (
<div key={line.label} className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
{line.label}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
>
{line.value === 0 ? fmtNum(0) : signed(line.value)}
</span>
</div>
))}
<div className="my-0.5 border-b border-slate-100" />
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.net}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
>
{signed(row.net)}
</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.closing}
</span>
<span
dir="ltr"
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
>
{fmtNum(row.closing)}
</span>
</div>
</div>
</div>
);
}
export function FlowChart() {
const query = useDailyFlow();
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار می‌شود ———
const rows: FlowRow[] = useMemo(() => {
const days = query.data?.days ?? [];
let running = query.data?.opening_balance ?? 0;
return days.map((d) => {
const opening = running;
const closing = d.balance;
const waste = Math.max(0, d.waste);
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته می‌شود
const siteOut = Math.max(0, d.outbound - waste);
const net = round4(closing - opening);
// «سایر حرکات» = باقی‌مانده‌ی تراز (انتقال/شمارش/یافت‌شده)؛ معمولاً صفر
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
let cursor = opening;
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
const segment = (delta: number): Range => {
const from = cursor;
cursor = round4(cursor + delta);
return [Math.min(from, cursor), Math.max(from, cursor)];
};
const segInbound = segment(d.inbound);
const segReturns = segment(d.returns);
const segSiteOut = segment(-siteOut);
const segWaste = segment(-waste);
const segOther = segment(other);
running = closing;
return {
date: d.date,
balance: closing,
opening,
closing,
net,
inbound: d.inbound,
returns: d.returns,
siteOut,
waste,
other,
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
segInbound,
segReturns,
segSiteOut,
segWaste,
segOther,
};
});
}, [query.data]);
// ——— دامنه‌ی محور Y از خود داده (نه از صفر) تا ستون‌ها و خط در کادر جا شوند ———
const [domainMin, domainMax] = useMemo(() => {
const values: number[] = [];
rows.forEach((r) => {
values.push(r.balance, r.opening, r.closing);
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
values.push(lo, hi);
});
});
if (values.length === 0) return [0, 1] as const;
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
}, [rows]);
const hasMovement = rows.some((r) => r.hasMovement);
const hasOther = rows.some((r) => r.other !== 0);
return (
<ChartCard title={LABELS.title} sub={LABELS.sub}>
{query.isLoading ? (
<ChartLoading />
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : (
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
minTickGap={18}
/>
<YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={<FlowTooltip />}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)}
/>
{/* سگمنت‌های شناور آبشاری — هر جزء دقیقاً روی مسیر مانده می‌نشیند */}
<Bar
dataKey="segInbound"
name={LABELS.inbound}
fill={FLOW_COLORS.inbound}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segReturns"
name={LABELS.returns}
fill={FLOW_COLORS.returns}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segSiteOut"
name={LABELS.siteOut}
fill={FLOW_COLORS.siteOut}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segWaste"
name={LABELS.waste}
fill={FLOW_COLORS.waste}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده می‌شود */}
{hasOther && (
<Bar
dataKey="segOther"
name={LABELS.other}
fill={FLOW_COLORS.other}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
)}
{/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
<Line
type="stepAfter"
dataKey="balance"
name={LABELS.balance}
stroke={FLOW_COLORS.balance}
strokeWidth={1.75}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
}
@@ -1,125 +0,0 @@
// ============================================================
// ویجت ۶ — شبکه‌ی وضعیت سفارش‌های در راه
// جدول کمی سفارش‌های باز: متریال و تأمین‌کننده | سفارش‌شده | رسیده
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
// ============================================================
import { Truck } from "lucide-react";
import { useInTransit } from "../../hooks/useDashboardData";
import type { InTransitRow } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Badge } from "../ui/badge";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
const STATE_META: Record<
InTransitRow["state"],
{ label: string; cls: string; bar: string }
> = {
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
};
function specLabel(specs: Record<string, string>): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
}
function num(v: string): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
export function InTransitGrid() {
const query = useInTransit();
const rows = query.data ?? [];
return (
<ChartCard
title={
<span className="inline-flex items-center gap-1.5">
<Truck className="h-4 w-4 text-muted" />
سفارش‌های در راه — وضعیت تحویل
</span>
}
sub="اقلام پذیرفته‌شده‌ی خرید تا رسیدن کامل به انبار"
>
{query.isLoading ? (
<ChartLoading />
) : rows.length === 0 ? (
<ChartEmpty message="سفارش بازی در جریان نیست" />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>متریال / تأمین‌کننده</TableHead>
<TableHead className="text-end">سفارش‌شده</TableHead>
<TableHead className="text-end">رسیده تاکنون</TableHead>
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
<TableHead>وضعیت</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => {
const ordered = num(r.ordered_qty);
const received = num(r.received_qty);
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
const specs = specLabel(r.specs);
return (
<TableRow key={r.item_decision_id}>
<TableCell>
<div className="text-[13px] font-bold">{r.material.name}</div>
<div className="text-[11px] text-muted">
{r.supplier.name}
{r.brand ? ` · ${r.brand.name}` : ""}
</div>
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
{r.project && (
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
)}
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(ordered)}
<div className="text-[10px] font-normal text-muted">
{r.unit?.name_fa || r.unit?.code}
</div>
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(received)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
<div
className={cn("h-full rounded-full", meta.bar)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
{faNum(pct)}٪
</span>
</div>
</TableCell>
<TableCell>
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ChartCard>
);
}
@@ -1,42 +0,0 @@
// ============================================================
// ویجت ۵ — KPI زمان تأمین (Lead Time)
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
// از /quotes/stats (میانگین arrived_date − accepted_date).
// ============================================================
import { Timer } from "lucide-react";
import { useQuoteStats } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters";
import { ChartCard, ChartLoading } from "./chartShared";
export function LeadTimeKpiCard() {
const query = useQuoteStats();
const days = query.data?.average_lead_time_days;
return (
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
{query.isLoading ? (
<ChartLoading />
) : (
<div className="flex items-center gap-4">
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
<Timer className="h-7 w-7" />
</span>
<div>
<div className="flex items-baseline gap-1.5">
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
{days == null ? "—" : faNum(days)}
</span>
<span className="text-[14px] font-bold text-muted">روز</span>
</div>
<p className="mt-1 text-[11px] text-muted">
{days == null
? "هنوز سفارشی به مرحله‌ی رسیدن نرسیده است"
: `بر پایه‌ی سفارش‌های رسیده‌شده به انبار`}
</p>
</div>
</div>
)}
</ChartCard>
);
}
@@ -1,124 +0,0 @@
// ============================================================
// ویجت ۷ — قیف تدارکات
// تبدیل تجمعی: کل درخواست‌ها (پیشنهادشده) → پذیرفته‌شده → رسیده به انبار
// از /quotes/stats.funnel (شمارش تجمعی بر پایه‌ی تاریخ‌های واقعی).
// ============================================================
import { useMemo } from "react";
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
import { useQuoteStats } from "../../hooks/useDashboardData";
import { faNum, fmtNum } from "../../lib/formatters";
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
interface Step {
name: string;
value: number;
fill: string;
conversion: number | null;
}
export function ProcurementFunnel() {
const query = useQuoteStats();
const steps: Step[] = useMemo(() => {
const f = query.data?.funnel;
if (!f) return [];
const offered = f.offered_total;
return [
{
name: "کل درخواست‌ها (پیشنهادشده)",
value: f.offered_total,
fill: CHART_COLORS.accent,
conversion: null,
},
{
name: "پذیرفته‌شده",
value: f.accepted_total,
fill: CHART_COLORS.blue,
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
},
{
name: "رسیده به انبار",
value: f.arrived_total,
fill: CHART_COLORS.green,
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
},
];
}, [query.data]);
const total = steps[0]?.value ?? 0;
return (
<ChartCard
title="قیف تدارکات"
sub="تبدیل درخواست‌ها به سفارش پذیرفته‌شده و رسیدن کالا به انبار"
>
{query.isLoading ? (
<ChartLoading />
) : total === 0 ? (
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
) : (
<div className="grid items-center gap-3 sm:grid-cols-5">
<div dir="ltr" className="h-56 sm:col-span-3">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip
content={
<ChartTooltip
labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${fmtNum(v)} درخواست`}
/>
}
/>
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
<LabelList
dataKey="value"
position="right"
fill="#0f172a"
stroke="none"
fontSize={12}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) ? fmtNum(n) : "";
}}
/>
{/* نام مرحله داخل نوار */}
<LabelList
dataKey="name"
position="left"
fill="#ffffff"
stroke="none"
fontSize={11}
formatter={(v: unknown) => (v == null ? "" : String(v))}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</div>
{/* مراحل قیف + درصد تبدیل */}
<ul className="flex flex-col gap-2 sm:col-span-2">
{steps.map((s) => (
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
<span className="text-[12px] font-bold">{s.name}</span>
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
{fmtNum(s.value)}
</span>
</div>
{s.conversion != null && (
<div className="mt-0.5 text-[10.5px] text-muted">
{faNum(s.conversion)}٪ از کل درخواست‌ها
</div>
)}
</li>
))}
</ul>
</div>
)}
</ChartCard>
);
}
@@ -1,123 +0,0 @@
// ============================================================
// ویجت ۸ — تراکم پروژه‌ها (Project Density)
// نمودار میله‌ای افقی: توزیع بار تدارکات روی پروژه‌های فعال —
// سفارش‌های باز (سته‌شده با پیشنهادهای باز).
// ============================================================
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useProcurementWorkload } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
export function ProjectDensityChart() {
const query = useProcurementWorkload();
const rows = useMemo(() => {
const projects = query.data?.projects ?? [];
return projects
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
.map((p) => ({
name: p.name,
"سفارش باز (در راه)": p.open_orders,
"پیشنهاد باز": p.offered_quotes,
_ordered: p.ordered_qty,
_received: p.received_qty,
}))
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
}, [query.data]);
return (
<ChartCard
title="تراکم بار تدارکات روی پروژه‌ها"
sub="تعداد سفارش‌های باز و پیشنهادهای در جریان به تفکیک پروژه"
>
{query.isLoading ? (
<ChartLoading />
) : rows.length === 0 ? (
<ChartEmpty message="بار تدارکاتی فعالی روی پروژه‌ها وجود ندارد" />
) : (
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={rows}
layout="vertical"
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
barCategoryGap="28%"
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
width={150}
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={
<ChartTooltip
labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${faNum(v)} مورد`}
/>
}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={26}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)}
/>
<Bar
dataKey="سفارش باز (در راه)"
stackId="load"
fill={CHART_COLORS.blue}
maxBarSize={22}
isAnimationActive={false}
/>
<Bar
dataKey="پیشنهاد باز"
stackId="load"
fill={CHART_COLORS.amber}
radius={[0, 4, 4, 0]}
maxBarSize={22}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
}
@@ -1,181 +0,0 @@
// ============================================================
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
// همه‌ی نمودارهای داشبورد از این قالب استفاده می‌کنند تا خروجی
// Recharts فارسی و هماهنگ با دیزاین‌سیستم (Tailwind @theme) باشد.
// ============================================================
import type { ReactNode } from "react";
import { groupCommas } from "../../lib/numutil";
import { isoToJalaliStr } from "../../lib/jalali";
import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// پالت معنایی نمودارها — هم‌راستا با توکن‌های @theme در globals.css
export const CHART_COLORS = {
inbound: "#16a34a", // ورود = سبز
outbound: "#2563eb", // خروج = آبی
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایه‌ی داخل ستون خروج)
returns: "#d97706", // برگشت از سایت = کهربایی
balance: "#0f172a", // مانده = خط سرمه‌ای نقطه‌چین
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
amber: "#d97706",
green: "#16a34a",
red: "#dc2626",
blue: "#2563eb",
} as const;
// عدد → رقم فارسی با جداکننده‌ی هزارگان (تیک محورها)
export function faAxisTick(value: unknown): string {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return "";
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
return groupCommas(n, true);
}
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
export function jaAxisDate(iso: unknown): string {
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
return j || String(iso ?? "");
}
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نام‌های فارسی سری‌ها ———
interface TooltipEntry {
name?: string | number;
value?: string | number;
color?: string;
dataKey?: string | number;
payload?: Record<string, unknown>;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipEntry[];
label?: string | number;
labelFormatter?: (label: string | number) => ReactNode;
valueFormatter?: (value: number, name: string) => string;
}
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
if (!active || !payload || payload.length === 0) return null;
// ردیف‌های صفر را از تولتیپ حذف می‌کنیم — شلوغیِ بی‌معنا (اصل Power BI)
const rows = payload.filter((p) => {
const v = typeof p.value === "number" ? p.value : Number(p.value);
return Number.isFinite(v) && v !== 0;
});
return (
<div
dir="rtl"
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
>
{label !== undefined && label !== "" && (
<div className="mb-1 text-[11px] font-bold text-muted">
{labelFormatter ? labelFormatter(label) : String(label)}
</div>
)}
<div className="flex flex-col gap-1">
{rows.map((p, i) => {
const v = typeof p.value === "number" ? p.value : Number(p.value);
const name = String(p.name ?? p.dataKey ?? "");
return (
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
<span className="text-muted">{name}</span>
<span className="ms-auto font-bold tabular-nums text-ink">
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
</span>
</div>
);
})}
</div>
</div>
);
}
// ——— کارت نگه‌دارنده‌ی ویجت — عنوان + توضیح + اکشن ———
export function ChartCard({
title,
sub,
actions,
children,
className,
}: {
title: ReactNode;
sub?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
<header className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-0">
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
{sub && <p className="text-[11px] text-muted">{sub}</p>}
</div>
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
</header>
{children}
</section>
);
}
// ——— حالت خالی/خطای ویجت ———
export function ChartEmpty({ message = "داده‌ای برای نمایش وجود ندارد" }: { message?: string }) {
return (
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
{message}
</div>
);
}
export function ChartLoading() {
return (
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
در حال بارگذاری...
</div>
);
}
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
export function UnitPicker({
units,
value,
onChange,
totals,
}: {
units: Record<string, string>;
value: string;
onChange: (code: string) => void;
totals?: Record<string, number>;
}) {
const codes = Object.keys(units);
if (codes.length <= 1) return null;
return (
<div className="flex flex-wrap items-center gap-1">
{codes.map((code) => {
const total = totals?.[code];
return (
<button
key={code}
type="button"
onClick={() => onChange(code)}
title={"نمایش موجودی به " + (units[code] || code)}
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
value === code
? "bg-accent text-white"
: "bg-slate-bg text-muted hover:text-ink",
)}
>
{units[code] || code}
{total != null && (
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
{fmtNum(total)}
</span>
)}
</button>
);
})}
</div>
);
}
+1 -22
View File
@@ -2,23 +2,6 @@ import type { ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { ChevronRight, ClipboardList } from "lucide-react"; import { ChevronRight, ClipboardList } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useAuthStore, type AuthUser } from "../../store/auth";
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
export function userSubtitle(user: AuthUser | null | undefined): string {
if (!user) return "";
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const username = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
}
interface HeaderProps { interface HeaderProps {
title: string; title: string;
@@ -35,10 +18,6 @@ interface HeaderProps {
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) { export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user);
const userSub = userSubtitle(user);
const displaySub = sub !== undefined ? sub : userSub;
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛ // اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛
// اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم. // اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم.
@@ -68,7 +47,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<div className="flex min-w-0 items-center gap-2.5"> <div className="flex min-w-0 items-center gap-2.5">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1> <h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
{displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>} {sub && <div className="text-[11px] text-muted">{sub}</div>}
</div> </div>
</div> </div>
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div> <div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
@@ -8,6 +8,7 @@ import {
PackageSearch, PackageSearch,
Scale, Scale,
Truck, Truck,
UsersRound,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@@ -36,12 +37,12 @@ const PM_INV_TABS: TabDef[] = [
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch }, { to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
]; ];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینه‌ی ناوبری بالای دسکتاپ) // تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [ const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true }, { to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays }, { to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch }, { to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
]; ];
function TabBar({ tabs }: { tabs: TabDef[] }) { function TabBar({ tabs }: { tabs: TabDef[] }) {
@@ -80,28 +81,17 @@ export function MobileTabBar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null; if (pathname === "/login" || pathname === "/admin") return null;
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد
if (pathname === "/requester" || pathname === "/panel/pm") return null;
// کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد
const panels = useAuthStore.getState().user?.panels ?? {};
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
const isPM = role === "pm"; const isPM = role === "pm";
const isSupervisor = role === "supervisor";
const isOffice = role === "admin" || role === "procurement_manager"; const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) { if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />; if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />; if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
return null; return null;
} }
// سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد
if (isSupervisor) return null;
if (pathname === "/wizard" || pathname.startsWith("/review")) return null; if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage = const onOfficePage =
pathname === "/" || pathname === "/" ||
pathname === "/dashboard" ||
pathname === "/projects" || pathname === "/projects" ||
pathname === "/units" || pathname === "/units" ||
pathname.startsWith("/suppliers"); pathname.startsWith("/suppliers");
+1 -1
View File
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes"; export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials"; export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject, useDeleteProject } from "./useProjects"; export { useProjects, useAddProject } from "./useProjects";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits"; export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
-90
View File
@@ -1,90 +0,0 @@
// ============================================================
// هوک‌های TanStack Query برای داشبورد MVP
// همه‌ی ویجت‌ها مستقیم به API واقعی وصل‌اند — بدون هیچ داده‌ی ساختگی.
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
// تغییر فیلتر، فقط ویجت‌های همان تب دوباره خوانده شوند.
// ============================================================
import { useQuery } from "@tanstack/react-query";
import { apiV26 } from "../lib/api";
import type { DashboardFilterParams } from "../lib/api";
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
function useFilterParams(): DashboardFilterParams {
const state = useDashboardFilter();
return dashboardFilterParams(state);
}
// گزینه‌های آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
export function useDashboardFilterOptions() {
return useQuery({
queryKey: ["dashboard", "filters"],
queryFn: () => apiV26.dashboard.filters(),
staleTime: 5 * 60 * 1000,
});
}
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
export function useDrilldown() {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "drilldown", params],
queryFn: () => apiV26.dashboard.drilldown(params),
});
}
// ویجت ۲ — موجودی راکد (dead stock)
export function useDeadStock(idleDays: number) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "dead-stock", idleDays, params],
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
});
}
// ویجت ۳ — سری زمانی جریان روزانه
export function useDailyFlow(dateFrom?: string, dateTo?: string) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
});
}
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازه‌سازی می‌شود
export function useDailyControl(date?: string) {
return useQuery({
queryKey: ["dashboard", "daily-control", date],
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
refetchInterval: 60 * 1000,
});
}
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
export function useQuoteStats() {
return useQuery({
queryKey: ["dashboard", "quote-stats"],
queryFn: () => apiV26.dashboard.quoteStats(),
staleTime: 60 * 1000,
});
}
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
export function useInTransit() {
const state = useDashboardFilter();
const params = dashboardFilterParams(state);
return useQuery({
queryKey: ["dashboard", "in-transit", params],
queryFn: () =>
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
});
}
// ویجت ۸ — بار تدارکات روی پروژه‌ها
export function useProcurementWorkload() {
return useQuery({
queryKey: ["dashboard", "workload"],
queryFn: () => apiV26.dashboard.workload(),
staleTime: 60 * 1000,
});
}
-12
View File
@@ -21,15 +21,3 @@ export function useAddProject() {
onSettled: () => invalidate(), onSettled: () => invalidate(),
}); });
} }
export function useDeleteProject() {
const deleteProject = useAppStore((s) => s.deleteProject);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.projects.delete(id),
onMutate: (id) => deleteProject(id),
onSettled: () => invalidate(),
});
}
+22 -230
View File
@@ -19,38 +19,19 @@ import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
ConfirmedReceipt, ConfirmedReceipt,
DashboardFilterOptions,
DailyControlData,
DeadStockItem,
DrilldownData,
FlowDay,
InTransitRow,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
Material, Material,
PendingArrival,
Project, Project,
Quote, Quote,
QuoteStats,
StandardConversion, StandardConversion,
Store, Store,
Supplier, Supplier,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferReceiveIn,
ProjectTransferUpdateIn,
TransactionType, TransactionType,
Warehouse, Warehouse,
WorkloadProject,
} from "./types"; } from "./types";
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = {
material_id?: string | null;
brand_id?: string | null;
specs_hash?: string | null;
};
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";
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
@@ -106,7 +87,7 @@ function handle401(path: string, res: Response): void {
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند. // ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null { function activeProjectId(): string | null {
const { user } = useAuthStore.getState(); const { user } = useAuthStore.getState();
return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null; return user?.role === "pm" ? user.project_id : null;
} }
function projectQuery(projectId?: string | null): string { function projectQuery(projectId?: string | null): string {
@@ -258,36 +239,20 @@ 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<any>(path); const res = await request<{ data: T }>(path);
if (res && typeof res === "object" && "data" in res) { return res.data;
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<any>(path, { method: "POST", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
if (res && typeof res === "object" && "data" in res) { return res.data;
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<any>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
if (res && typeof res === "object" && "data" in res) {
return res.data as T;
}
return res as T;
}
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data; return res.data;
} }
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<any>(path, { method: "DELETE" }); const res = await request<{ data: T }>(path, { method: "DELETE" });
if (res && typeof res === "object" && "data" in res) { return res.data;
return res.data as T;
}
return res as T;
} }
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر // آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
@@ -358,12 +323,6 @@ interface RawSupplier {
interface RawProject { interface RawProject {
id: string; id: string;
name: string; name: string;
location?: string | null;
project_manager?: string | null;
description?: string | null;
start_date?: string | null;
status?: string | null;
progress?: number | null;
} }
interface RawSpec { interface RawSpec {
attribute_key: string; attribute_key: string;
@@ -499,39 +458,6 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
} }
// تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers
// Raw shape mirrors the backend dict exactly: clean supplier objects produced
// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name
// always comes from the suppliers table, never from the material row.
interface RawMaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
/** Clean UI-facing type for one supplier linked to a material. */
export interface MaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
return {
id: raw.id,
// Bound straight from the supplier object in the API response — there is
// no fallback, so a supplier row can never render the material's name.
name: raw.name,
phone: raw.phone,
contact_person: raw.contact_person,
location: raw.location,
};
}
// فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند) // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment { interface RawAttachment {
id: string; id: string;
@@ -1001,30 +927,15 @@ 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 || []) {
if (li.allocations && li.allocations.length > 0) {
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
for (const a of li.allocations) {
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; const tp = li.target_project;
if (tp && tp.name) { if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name); const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid); if (pid) targetProjByLine.set(li.id, pid);
} }
} }
}
const payload = { const payload = {
accepted_termsheets: acceptedTs.map((t) => ({ accepted_termsheets: acceptedTs.map((t) => ({
@@ -1042,13 +953,11 @@ 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 } : {}),
}; };
}), }),
@@ -1284,51 +1193,16 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id }); await patchData(`/materials/${id}`, { canonical_unit_id });
}, },
// فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers)
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
// The backend returns { data: [...clean supplier objects] } from an
// atomic JOIN; getData<T> unwraps the envelope. No fallback data —
// an empty array means the material genuinely has no suppliers.
const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers`,
);
return raw.map(adaptMaterialSupplier);
},
}, },
projects: { projects: {
list: async (): Promise<Project[]> => { list: async (): Promise<Project[]> => {
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`); const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
return raw.map((p) => ({ return raw.map((p) => ({ id: p.id, name: p.name }));
id: p.id,
name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
start_date: p.start_date,
status: p.status || "ACTIVE",
progress: p.progress != null ? Number(p.progress) : 0,
}));
}, },
create: async (p: Project): Promise<Project> => { create: async (p: Project): Promise<Project> => {
const raw = await postData<RawProject>("/projects", { const raw = await postData<RawProject>("/projects", { name: p.name });
name: p.name, return { id: raw.id, name: raw.name };
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
});
return {
id: raw.id,
name: raw.name,
location: raw.location || "",
project_manager: raw.project_manager || "",
description: raw.description || "",
status: raw.status || "ACTIVE",
progress: raw.progress != null ? Number(raw.progress) : 0,
};
},
delete: async (id: string): Promise<void> => {
await deleteData(`/projects/${id}`);
}, },
}, },
@@ -1718,16 +1592,11 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
export interface UserRow { export interface UserRow {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string | null;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role: "pm" | "procurement_manager" | "admin" | null;
role_label: string | null; role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...}
panels?: Record<string, string>;
// عضویت M:N پروژه‌ای — محدوده داده سطح limited
member_project_ids?: string[];
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
// 1:N — every project this PM manages (oldest first). // 1:N — every project this PM manages (oldest first).
@@ -1751,11 +1620,10 @@ export const apiV26 = {
user: { user: {
id: string; id: string;
casdoor_id: string; casdoor_id: string;
username?: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role: "pm" | "procurement_manager" | "admin" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
@@ -1769,7 +1637,6 @@ export const apiV26 = {
expires_in: res.data.expires_in, expires_in: res.data.expires_in,
user: { user: {
...u, ...u,
username: u.username || u.casdoor_id,
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id, display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
}, },
}; };
@@ -1789,15 +1656,14 @@ export const apiV26 = {
users: { users: {
me: async (): Promise<import("../store/auth").AuthUser> => { me: async (): Promise<import("../store/auth").AuthUser> => {
const data = await getData<{ const data = await getData<{
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null; id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null; role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
project_id: string | null; project_name: string | null; project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[]; projects?: { id: string; name: string }[];
}>("/users/me"); }>("/users/me");
return { return {
id: data.id, id: data.id,
casdoor_id: data.casdoor_id, casdoor_id: data.casdoor_id,
username: data.username || data.casdoor_id,
first_name: data.first_name, first_name: data.first_name,
last_name: data.last_name, last_name: data.last_name,
phone: data.phone, phone: data.phone,
@@ -1821,13 +1687,8 @@ export const apiV26 = {
first_name: string; last_name?: string; phone?: string | null; first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null; casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): Promise<UserRow> => postData<UserRow>("/users", body), }): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> => updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }), patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
putData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> => assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }), postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
@@ -1875,23 +1736,16 @@ 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; page?: number } = {}): Promise<InventoryTransaction[]> => { transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): 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; delivered_to?: string | null; transaction_date?: string | null; note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => { }): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body); return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
}, },
@@ -1902,12 +1756,8 @@ export const apiV26 = {
pm: { pm: {
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => { onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
const qs = new URLSearchParams( getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
).toString();
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
},
confirmReceipt: async (body: { confirmReceipt: async (body: {
item_decision_id: string; received_qty: number; item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[]; allocations: { warehouse_id: string; quantity: number }[];
@@ -1918,27 +1768,6 @@ export const apiV26 = {
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"), getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
}, },
// ---------- داشبورد MVP (v2.8) — تجمیع‌های سمت سرور، فقط‌خواندنی ----------
// نقش/پروژه سمت بک‌اند از JWT (یا هدرهای dev) حل می‌شود؛ PM به پروژه‌های
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را می‌بیند.
dashboard: {
filters: async (): Promise<DashboardFilterOptions> =>
getData<DashboardFilterOptions>("/dashboard/filters"),
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> =>
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"),
// KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats)
quoteStats: async (): Promise<QuoteStats> =>
getData<QuoteStats>("/quotes/stats"),
},
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند. // متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند.
catalog: { catalog: {
@@ -1967,41 +1796,4 @@ 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),
},
}; };
-189
View File
@@ -31,12 +31,6 @@ export interface Material {
export interface Project { export interface Project {
id: string; id: string;
name: string; name: string;
location?: string;
project_manager?: string;
description?: string;
start_date?: string | null;
status?: string;
progress?: number;
} }
// مرحله‌ی پرداخت // مرحله‌ی پرداخت
@@ -79,7 +73,6 @@ 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)
@@ -170,7 +163,6 @@ 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;
@@ -195,12 +187,6 @@ export interface PendingArrival {
state: "on_the_way" | "partially_received" | "arrived"; state: "on_the_way" | "partially_received" | "arrived";
} }
// v2.6.1 — برند/مشخصه‌های فrozen قلم (اضافه‌شده به /pm/on-the-way برای داشبورد)
export interface InTransitRow extends PendingArrival {
brand: { id: string; name: string } | null;
specs: Record<string, string>;
}
export interface ConfirmedReceipt { export interface ConfirmedReceipt {
receipt_id: string; receipt_id: string;
quote_id: string; quote_id: string;
@@ -213,178 +199,3 @@ 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 }[];
} }
// ============================================================
// v2.8 — داشبورد MVP (انبار + بازرگانی)
// ============================================================
// — فیلتر سراسری «چه چیزی» —
export interface FilterSpecOption {
hash: string;
specs: Record<string, string>;
}
export interface FilterMaterialOption {
id: string;
name: string;
unit: { id: number; code: string; name_fa: string | null } | null;
brands: { id: string; name: string }[];
specs: FilterSpecOption[];
}
export interface DashboardFilterOptions {
materials: FilterMaterialOption[];
}
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
export type QtyByUnit = Record<string, number>;
export interface DrilldownWarehouse {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
}
export interface DrilldownProject {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
warehouses: DrilldownWarehouse[];
}
export interface DrilldownMaterial {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
projects: DrilldownProject[];
}
export interface DrilldownData {
units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number };
materials: DrilldownMaterial[];
}
// — جدول موجودی راکد —
export interface DeadStockItem {
inventory_id: string;
material: string;
brand: string | null;
specs: Record<string, string>;
warehouse: string;
project: string;
quantity: number;
unit: { code: string; name_fa: string | null };
last_activity: string | null;
idle_days: number | null;
}
// — سری زمانی جریان روزانه —
export interface FlowDay {
date: string; // ISO (YYYY-MM-DD)
inbound: number;
outbound: number;
waste: number;
returns: number;
balance: number;
}
// — کنترل روزانه —
export interface DailyControlData {
date: string;
outbound: { tickets: number; qty: number };
returns: { tickets: number; qty: number };
waste: { tickets: number; qty: number };
purchases: { tickets: number; qty: number };
performers: { name: string; tickets: number; qty: number }[];
items: {
id: string;
transaction_type: string;
quantity: number;
unit_code: string;
performed_by: string | null;
note: string | null;
warehouse: string;
material: string;
specs: Record<string, string>;
transaction_date: string;
}[];
}
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
export interface QuoteStats {
total: number;
offered: number;
accepted: number;
arrived: number;
rejected: number;
expired: number;
average_lead_time_days: number | null;
funnel: {
offered_total: number;
accepted_total: number;
arrived_total: number;
};
}
// — بار تدارکات روی پروژه‌ها (Project Density) —
export interface WorkloadProject {
id: string | null;
name: string;
offered_quotes: number;
chosen_items: number;
open_orders: number;
ordered_qty: number;
received_qty: number;
}
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 -36
View File
@@ -15,7 +15,6 @@ const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه", pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات", procurement_manager: "مدیر تدارکات",
admin: "ادمین", admin: "ادمین",
supervisor: "سرپرست پروژه",
}; };
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و // پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و
@@ -81,26 +80,6 @@ export function AdminPanel() {
} }
}; };
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
const toggleSupervisor = async (u: UserRow) => {
setBusyId(u.id);
const target = u.role === "supervisor" ? null : "supervisor";
try {
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
toast.success(
target === "supervisor"
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
);
await load();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
if (!isAdmin) { if (!isAdmin) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -150,13 +129,12 @@ export function AdminPanel() {
<th className="py-2 pe-3">نام کامل</th> <th className="py-2 pe-3">نام کامل</th>
<th className="py-2 pe-3">شناسه Casdoor</th> <th className="py-2 pe-3">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th> <th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت نقش‌ها</th> <th className="py-2">مدیریت مدیر تدارکات</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => { {users.map((u) => {
const isPMgr = u.role === "procurement_manager"; const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return ( return (
<tr key={u.id} className="border-b border-line/50"> <tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold"> <td className="py-2 pe-3 font-semibold">
@@ -169,7 +147,6 @@ export function AdminPanel() {
</span> </span>
</td> </td>
<td className="py-2"> <td className="py-2">
<div className="flex flex-wrap items-center gap-1.5">
<button <button
disabled={busyId === u.id} disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)} onClick={() => void toggleProcurementManager(u)}
@@ -181,18 +158,6 @@ export function AdminPanel() {
> >
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"} {busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button> </button>
<button
disabled={busyId === u.id}
onClick={() => void toggleSupervisor(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isSup
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
</button>
</div>
</td> </td>
</tr> </tr>
); );
+5 -38
View File
@@ -79,13 +79,6 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return; return;
} }
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (user.role === "supervisor" && !hasProjects) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
setToken(access_token); setToken(access_token);
setUser(user); setUser(user);
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*) // تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
@@ -95,23 +88,8 @@ export function LoginPage() {
if (returnUrl) { if (returnUrl) {
navigate(decodeURIComponent(returnUrl)); navigate(decodeURIComponent(returnUrl));
} else { } else {
// هدایت پس از ورود بر اساس پنل‌ها یا نقش: // مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
const panels = user.panels ?? {}; navigate(user.role === "pm" ? "/inventory" : "/");
if (panels.procurement === "full") {
navigate("/");
} else if (panels.procurement === "limited") {
navigate("/requester");
} else if (panels.inventory) {
navigate("/inventory");
} else if (panels.pm) {
navigate("/panel/pm");
} else if (user.role === "supervisor") {
navigate("/panel/pm");
} else if (user.role === "pm") {
navigate("/inventory");
} else {
navigate("/");
}
} }
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود"); toast.error(err instanceof Error ? err.message : "خطا در ورود");
@@ -302,21 +280,10 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} /> <ClipboardList className="h-6 w-6" strokeWidth={2} />
</span> </span>
<div> <div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1> <h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
<p className="mt-1.5 text-[12.5px] leading-6 text-muted"> <p className="mt-1 text-[12.5px] leading-6 text-muted">
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
</p> </p>
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
مدیریت پروژه
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
تدارکات
</span>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
انبارداری
</span>
</div>
</div> </div>
</div> </div>
+35 -407
View File
@@ -23,134 +23,12 @@ 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; label: string | null; // null = پیش‌فرض «گزینه N»
} }
export interface AcceptModalProps { export interface AcceptModalProps {
@@ -173,9 +51,6 @@ 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);
@@ -192,35 +67,17 @@ 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);
const defaultProj = li.target_project?.name projSel[li.id] = 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]);
@@ -280,44 +137,6 @@ 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;
@@ -343,7 +162,6 @@ 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("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
@@ -388,60 +206,13 @@ 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";
}); });
for (const li of active) { // پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
const allocs = itemAllocations[li.id] || []; // li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
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 allocs = itemAllocations[li.id];
if (allocs && allocs.length) {
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]; const name = itemProjectsSel[li.id];
if (name !== undefined) { if (name === undefined) return;
li.target_project = name ? { id: "", name } : null; li.target_project = name ? { id: "", name } : null;
}
}
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -461,7 +232,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">
@@ -572,196 +343,53 @@ 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">
<div className="flex items-center justify-between mb-1"> <h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<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">
{projectOptions.length === 0 ? (
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید.
</div>
) : (
<div className="flex flex-col gap-4">
{allItems {allItems
.filter((li) => !removedItems.has(li.id)) .filter((li) => !removedItems.has(li.id))
.map((li) => { .map((li) => {
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
const targetQty = Number(li.quantity ?? 0);
const allocs = itemAllocations[li.id] || [];
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 ( return (
<div <div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
key={li.id} <div className="mb-3 flex items-center justify-between">
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30" <span className="text-[13px] font-semibold text-slate-800 truncate">
>
{/* هدر کارت قلم */}
<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)} {formatMaterialTitle(m)}
</span> </span>
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit}
</span>
</div> </div>
{projectOptions.length ? (
<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 <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" 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"
value={alloc.project_name} value={itemProjectsSel[li.id] ?? ""}
onChange={(e) => onChange={(e) =>
updateAllocationRow(li.id, alloc.rowId, { setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
project_name: e.target.value,
})
} }
> >
<option value="">انتخاب پروژه...</option> {(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
{projectOptions.map((p) => { <option key={i} value="">
const isUsed = otherUsedProjects.has(p); انتخاب پروژه...
return (
<option key={p} value={p} disabled={isUsed}>
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
</option> </option>
); ))}
})} {projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select> </select>
</div> ) : (
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</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>
)} )}
</div>
);
})}
</div>
</section> </section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} {/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
+2 -20
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react"; import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import { import {
useMaterials, useMaterials,
useProjects, useProjects,
@@ -18,16 +18,12 @@ import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal"; import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal"; import { EditModal } from "./EditModal";
import { useAuthStore } from "../../store/auth";
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
const user = useAuthStore((s) => s.user);
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
const quotes = useQuotes(); const quotes = useQuotes();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
@@ -89,20 +85,11 @@ export function DashboardPage() {
return ( return (
<div className="min-h-screen pb-20 md:pb-6"> <div className="min-h-screen pb-20 md:pb-6">
<Header <Header
title="مدیریت تدارکات" title="دفتر پیشنهادهای خرید"
brand brand
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
{/* داشبورد تحلیلی — دکمه‌ی مستقل، هم‌سبک با «پروژه‌ها» و «ضرایب تبدیل» */}
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<ChartColumnBig className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>داشبورد</span>
</Link>
<Link to="/suppliers" className="btn-line-file no-underline"> <Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" /> <Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها تأمین‌کننده‌ها
@@ -127,7 +114,6 @@ export function DashboardPage() {
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
{canWrite && (
<Link <Link
to="/wizard" to="/wizard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline" className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
@@ -135,7 +121,6 @@ export function DashboardPage() {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span>افزودن پیشنهاد جدید</span> <span>افزودن پیشنهاد جدید</span>
</Link> </Link>
)}
</div> </div>
} }
/> />
@@ -163,12 +148,10 @@ export function DashboardPage() {
onToggleExpand={toggleExpand} onToggleExpand={toggleExpand}
onAccept={setAcceptId} onAccept={setAcceptId}
onEdit={setEditId} onEdit={setEditId}
canWrite={canWrite}
/> />
</PageLayout> </PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */} {/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
{canWrite && (
<Link <Link
to="/wizard" to="/wizard"
aria-label="افزودن پیشنهاد جدید" aria-label="افزودن پیشنهاد جدید"
@@ -176,7 +159,6 @@ export function DashboardPage() {
> >
<Plus className="h-7 w-7" /> <Plus className="h-7 w-7" />
</Link> </Link>
)}
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} /> <AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} /> <EditModal quoteId={editId} onClose={() => setEditId(null)} />
@@ -438,26 +438,7 @@ 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) => {
@@ -10,7 +10,6 @@ export interface LedgerTableProps {
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
onAccept: (id: string) => void; onAccept: (id: string) => void;
onEdit: (id: string) => void; onEdit: (id: string) => void;
canWrite?: boolean;
} }
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل) // دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
@@ -21,7 +20,6 @@ export function LedgerTable({
onToggleExpand, onToggleExpand,
onAccept, onAccept,
onEdit, onEdit,
canWrite = true,
}: LedgerTableProps) { }: LedgerTableProps) {
if (!quotes.length) { if (!quotes.length) {
return ( return (
@@ -42,7 +40,6 @@ export function LedgerTable({
onToggleExpand={() => onToggleExpand(q.id)} onToggleExpand={() => onToggleExpand(q.id)}
onAccept={() => onAccept(q.id)} onAccept={() => onAccept(q.id)}
onEdit={() => onEdit(q.id)} onEdit={() => onEdit(q.id)}
canWrite={canWrite}
/> />
))} ))}
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]"> <div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
@@ -1,99 +0,0 @@
// ============================================================
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابه‌جایی
// بین تب‌ها حفظ می‌شود). تمام ویجت‌ها مستقیم به API واقعی وصل‌اند.
// ============================================================
import { useState } from "react";
import { BarChart3, Warehouse } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
import { FlowChart } from "../../components/dashboard/FlowChart";
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
import { cn } from "../../lib/utils";
type Tab = "warehouse" | "procurement";
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
];
export function MvpDashboardPage() {
const [tab, setTab] = useState<Tab>("warehouse");
return (
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="داشبورد تحلیلی"
sub="انبار و تدارکات — فیلتر جنس روی همه‌ی ویجت‌ها اعمال می‌شود"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
/* تب‌های نما — سگمنت‌کنترل هماهنگ با دیزاین‌سیستم */
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
tab === key
? "bg-panel text-accent shadow-sm"
: "text-muted hover:text-ink",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
}
/>
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
<DashboardFilterBar />
{tab === "warehouse" ? (
/* ——— نمای A: انبار ——— */
<div className="flex flex-col gap-3">
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
<DrilldownMatrix />
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
<div className="grid gap-3 xl:grid-cols-2">
<FlowChart />
<DailyControlPanel />
</div>
{/* ردیف سوم: موجودی راکد */}
<DeadStockTable />
</div>
) : (
/* ——— نمای B: بازرگانی و لجستیک ——— */
<div className="flex flex-col gap-3">
{/* KPI + قیف */}
<div className="grid gap-3 xl:grid-cols-3">
<LeadTimeKpiCard />
<div className="xl:col-span-2">
<ProcurementFunnel />
</div>
</div>
{/* تراکم پروژه‌ها + سفارش‌های در راه */}
<div className="grid gap-3 xl:grid-cols-2">
<ProjectDensityChart />
<InTransitGrid />
</div>
</div>
)}
</PageLayout>
</div>
);
}

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