Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a | ||
|
|
7bb432e715 | ||
|
|
b82c039265 | ||
|
|
d84637769a | ||
|
|
b0776da061 | ||
|
|
d38cf38bd9 | ||
|
|
a1220db844 | ||
|
|
1190ec42eb | ||
|
|
2747f9d4bb | ||
|
|
934b74ebbd | ||
|
|
dcc4d693f5 | ||
|
|
64bd292335 | ||
|
|
98c4b28815 | ||
|
|
aba9ba6486 | ||
|
|
965054093e | ||
|
|
3931baadd5 | ||
|
|
1bf55ba8a6 | ||
|
|
5458a2f99f | ||
|
|
df3ff4ea14 | ||
|
|
4b84844b54 | ||
|
|
ea6a377023 | ||
|
|
5400588751 | ||
|
|
0ea0322967 | ||
|
|
a84b33dde3 | ||
|
|
fbd34277c0 | ||
|
|
8c5f4ab770 | ||
|
|
af092fe4c8 | ||
|
|
e9fef5a104 | ||
|
|
4a3c226518 | ||
|
|
d85ede7d11 | ||
|
|
c7d860f431 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
da883a28c0 | ||
|
|
b6a1ce2ae7 | ||
|
|
64d80e15f8 |
@@ -1,3 +1,4 @@
|
||||
|
||||
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
||||
|
||||
Self-contained ship package: everything needed to run the stack is in this
|
||||
@@ -12,7 +13,9 @@ folder — no external files or sibling folders required.
|
||||
| **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) |
|
||||
|
||||
|
||||
```
|
||||
|
||||
Browser ──► Traefik :80 (edge)
|
||||
│
|
||||
├── Host(localhost) ──► frontend (nginx SPA :80)
|
||||
@@ -25,6 +28,7 @@ Browser ──► Traefik :80 (edge)
|
||||
│
|
||||
├── postgres :5432 (procurement + casdoor DBs)
|
||||
└── minio :9000 (invoice/proforma files)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -74,6 +78,7 @@ v1.3/
|
||||
├── Projects/ (projects)
|
||||
├── Units/ (unit conversions)
|
||||
└── Inventory/ (warehouse management panel — React port)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -86,9 +91,9 @@ the form (username / password) is sent to
|
||||
(password grant), verifies the returned JWT, and starts the session —
|
||||
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 («کالاهای در راه»).
|
||||
- **مدیر تدارکات / ادمین** → 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,
|
||||
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
||||
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
||||
@@ -111,11 +116,11 @@ for production builds).
|
||||
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
||||
Casdoor organization, application and users (idempotent). Full details —
|
||||
addresses, credentials, role model, failure scenarios and the checklist —
|
||||
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
|
||||
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
|
||||
|
||||
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
||||
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
|
||||
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
|
||||
|
||||
---
|
||||
|
||||
@@ -131,7 +136,7 @@ at these public addresses (HTTPS):
|
||||
|
||||
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||
assigned inside the app (`users.role` — see «Login» above and
|
||||
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
|
||||
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
|
||||
|
||||
### MinIO console on the server (SSH tunnel)
|
||||
|
||||
@@ -141,20 +146,19 @@ is an SSH tunnel from your machine:
|
||||
|
||||
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||
use the panel):
|
||||
|
||||
```powershell
|
||||
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
|
||||
lives as long as the window stays open.
|
||||
|
||||
2. In your browser, open: **http://localhost:9001**
|
||||
|
||||
3. Log in with the production MinIO root credentials from the server's
|
||||
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||
**never** written in this repo or its history. See
|
||||
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
|
||||
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
|
||||
|
||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||
ERP stores, browsable and downloadable from there.
|
||||
@@ -168,11 +172,11 @@ tunnel.
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
- 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
|
||||
with a password prompt on top), that can be arranged on request — the
|
||||
tunnel remains the safer default.
|
||||
@@ -181,9 +185,9 @@ Notes from the server setup:
|
||||
|
||||
## Prerequisites (target machine)
|
||||
|
||||
- **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`)
|
||||
- Internet access on first build (image + `npm ci` downloads)
|
||||
* **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`)
|
||||
* Internet access on first build (image + `npm ci` downloads)
|
||||
|
||||
---
|
||||
|
||||
@@ -192,6 +196,7 @@ Notes from the server setup:
|
||||
```powershell
|
||||
cd <this-folder>
|
||||
docker compose up -d --build
|
||||
|
||||
```
|
||||
|
||||
First run downloads base images and builds the frontend (takes a few minutes).
|
||||
@@ -201,6 +206,7 @@ Wait until all services are up:
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
|
||||
```
|
||||
|
||||
All should show `Up` (postgres also `(healthy)`).
|
||||
@@ -225,11 +231,12 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
|
||||
```powershell
|
||||
backend\tools\smoke_test.py # read paths
|
||||
backend\tools\smoke_test_write.py / smoke_test_misc.py
|
||||
|
||||
```
|
||||
|
||||
> Every API route now requires a Casdoor JWT — a bare
|
||||
> `curl http://localhost/api/v1/quotes` returns **401** by design.
|
||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
|
||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -241,7 +248,7 @@ backend\tools\smoke_test_write.py / smoke_test_misc.py
|
||||
| Web console | http://localhost:9001 |
|
||||
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
||||
| 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.
|
||||
|
||||
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
|
||||
|
||||
```powershell
|
||||
.\mc.cmd ls --recursive
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -261,10 +269,12 @@ Port: 5433
|
||||
Database: procurement
|
||||
Username: postgres
|
||||
Password: postgres
|
||||
|
||||
```
|
||||
|
||||
```powershell
|
||||
docker exec -it procurement-postgres psql -U postgres -d procurement
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -277,22 +287,24 @@ 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
|
||||
`postgres_data` volume; then run the seeders:
|
||||
|
||||
```powershell
|
||||
# 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
|
||||
# Demo dataset (optional):
|
||||
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,
|
||||
e.g. `/srv/erp/backups/`), never in git:
|
||||
|
||||
```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 casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
||||
|
||||
```
|
||||
|
||||
|
||||
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||
first — this **replaces all data**). The scheduled backup sidecar
|
||||
(see `docs/OPERATIONS.md`) automates this.
|
||||
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
|
||||
docker compose down # stop (keep data)
|
||||
docker compose up -d --build # rebuild after code changes
|
||||
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
(401 without a token, 403 without a role). The former dev-header fallback
|
||||
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
||||
`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);
|
||||
the procurement manager can only assign projects to project managers.
|
||||
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
||||
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
|
||||
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
||||
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
|
||||
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,
|
||||
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
||||
(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`
|
||||
for the units-dashboard tables and `quote_attachments` at startup.
|
||||
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||
invoice/proforma download links from MinIO.
|
||||
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
||||
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||
* **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 the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||
reachable; check `docker compose ps` and `docker compose logs api`.
|
||||
|
||||
```
|
||||
|
||||
+76
-8
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.db.engine import AsyncSessionLocal
|
||||
from app.models.procurement import Project, User
|
||||
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||
from app.utils.errors import (
|
||||
ForbiddenError,
|
||||
ServiceUnavailableError,
|
||||
@@ -106,6 +106,10 @@ class UserContext:
|
||||
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)
|
||||
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:
|
||||
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
if not casdoor_id:
|
||||
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))
|
||||
if user is None:
|
||||
user = User(
|
||||
casdoor_id=casdoor_id,
|
||||
username=username or None,
|
||||
first_name=str(
|
||||
claims.get("firstName")
|
||||
or claims.get("firstname")
|
||||
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
elif username and not user.username:
|
||||
user.username = username
|
||||
await db.flush()
|
||||
|
||||
project_ids: list = []
|
||||
if user.role == "pm":
|
||||
projects = (
|
||||
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
||||
panels: dict = {}
|
||||
panel_rows = (
|
||||
await db.execute(
|
||||
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
for row in panel_rows:
|
||||
panels[row.panel] = row.level
|
||||
|
||||
member_project_ids: list = [
|
||||
row.project_id
|
||||
for row in (
|
||||
await db.execute(
|
||||
select(UserProjectMembership)
|
||||
.where(UserProjectMembership.user_id == user.id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
]
|
||||
|
||||
pm_projects = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
project_ids = [p.id for p in projects]
|
||||
pm_project_ids_list = [p.id for p in pm_projects]
|
||||
|
||||
# ترکیب پروژههای تحت مدیریت مستقیم و پروژههای عضویت با حفظ اولویت و بدون تکرار
|
||||
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
|
||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||
@@ -155,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
project_id=project_id,
|
||||
project_ids=project_ids,
|
||||
display_name=display_name,
|
||||
panels=panels,
|
||||
member_project_ids=member_project_ids,
|
||||
)
|
||||
|
||||
|
||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||
"""Every project id managed by the given PM user (oldest first)."""
|
||||
rows = (
|
||||
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||
pm_rows = (
|
||||
await db.execute(
|
||||
select(Project.id)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
member_rows = (
|
||||
await db.execute(
|
||||
select(UserProjectMembership.project_id)
|
||||
.where(UserProjectMembership.user_id == user.id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
|
||||
|
||||
|
||||
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||
@@ -217,3 +257,31 @@ def require_role(*roles: str):
|
||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||
return ctx
|
||||
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
|
||||
|
||||
+164
-4
@@ -174,7 +174,7 @@ _V26_DDL = [
|
||||
""",
|
||||
"""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
|
||||
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
""",
|
||||
"""
|
||||
@@ -247,6 +247,7 @@ _V26_DDL = [
|
||||
physical_qty NUMERIC(18,6),
|
||||
transfer_pair_id UUID,
|
||||
performed_by TEXT,
|
||||
delivered_to TEXT,
|
||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
@@ -258,6 +259,9 @@ _V26_DDL = [
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
||||
""",
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
||||
@@ -380,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||
c,
|
||||
)
|
||||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||
# v2.5/v2.6 category decoupling from materials: drop the old supplier-derived
|
||||
# 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 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("""
|
||||
@@ -410,6 +415,50 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||||
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:
|
||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||
ddl = """
|
||||
@@ -466,3 +515,114 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
|
||||
await conn.execute(text(stmt))
|
||||
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)")
|
||||
|
||||
@@ -28,10 +28,13 @@ from app.db.init_db import (
|
||||
ensure_attachment_table,
|
||||
ensure_audit_table,
|
||||
ensure_bot_drafts_table,
|
||||
ensure_dashboard_schema,
|
||||
ensure_drop_category_tables,
|
||||
ensure_dashboard_tables,
|
||||
ensure_material_topic_column,
|
||||
ensure_quote_projects,
|
||||
ensure_rbac_schema,
|
||||
ensure_project_transfers_table,
|
||||
ensure_v26,
|
||||
)
|
||||
from app.observability import RequestContextMiddleware, setup_logging
|
||||
@@ -106,9 +109,12 @@ async def lifespan(_app: FastAPI):
|
||||
await ensure_material_topic_column(engine)
|
||||
await ensure_quote_projects(engine)
|
||||
await ensure_v26(engine)
|
||||
await ensure_rbac_schema(engine)
|
||||
await ensure_dashboard_schema(engine)
|
||||
await ensure_audit_table(engine)
|
||||
await ensure_drop_category_tables(engine)
|
||||
await ensure_bot_drafts_table(engine)
|
||||
await ensure_project_transfers_table(engine)
|
||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||
try:
|
||||
yield
|
||||
|
||||
@@ -32,6 +32,8 @@ from app.models.procurement import (
|
||||
TermsheetScheduleStep,
|
||||
)
|
||||
|
||||
from app.models.project_transfer import ProjectTransfer
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Unit",
|
||||
@@ -60,4 +62,5 @@ __all__ = [
|
||||
"ItemDecision",
|
||||
"ItemReceipt",
|
||||
"QuoteAttachment",
|
||||
"ProjectTransfer",
|
||||
]
|
||||
|
||||
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||
# v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the
|
||||
# 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"),)
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
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(
|
||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
@@ -147,6 +152,7 @@ class Material(Base):
|
||||
)
|
||||
|
||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||
back_populates="material", lazy="raise", passive_deletes=True
|
||||
)
|
||||
|
||||
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
|
||||
# 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))
|
||||
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(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.db.base import Base
|
||||
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
||||
|
||||
user_role_enum = Enum(
|
||||
"pm", "procurement_manager", "admin",
|
||||
"pm", "procurement_manager", "admin", "supervisor",
|
||||
name="user_role", native_enum=True,
|
||||
)
|
||||
quote_status_enum = Enum(
|
||||
@@ -58,6 +58,7 @@ class User(Base):
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
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)
|
||||
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -65,6 +66,47 @@ class User(Base):
|
||||
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):
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
@@ -148,12 +190,23 @@ class Project(Base):
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
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)
|
||||
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).
|
||||
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL")
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Numeric,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.catalog import _timestamptz, _uuid_pk
|
||||
|
||||
|
||||
class ProjectTransfer(Base):
|
||||
"""project_transfers — Inter-project (inter-site) material transfer ledger.
|
||||
|
||||
Records the movement of materials from a source project warehouse to a
|
||||
destination project. Stock is deducted from the source warehouse immediately
|
||||
upon creation (status = 'in_transit') via out_transaction_id.
|
||||
When the destination project PM confirms receipt in 'Arrivals', stock is
|
||||
added to the destination warehouse (status = 'received').
|
||||
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
|
||||
Once 'received', the transfer is permanently locked from edit or cancellation.
|
||||
"""
|
||||
|
||||
__tablename__ = "project_transfers"
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
from_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
to_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
material_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
brand_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("brands.id", ondelete="SET NULL")
|
||||
)
|
||||
unit_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
|
||||
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
|
||||
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
|
||||
|
||||
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
|
||||
)
|
||||
transfer_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
transferred_by: Mapped[str | None] = mapped_column(Text)
|
||||
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
received_by: Mapped[str | None] = mapped_column(Text)
|
||||
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("warehouses.id", ondelete="SET NULL")
|
||||
)
|
||||
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
|
||||
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
|
||||
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
|
||||
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
|
||||
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
|
||||
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
|
||||
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
|
||||
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
|
||||
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from app.routers import (
|
||||
auth,
|
||||
brand_materials,
|
||||
brands,
|
||||
dashboard,
|
||||
drafts,
|
||||
files,
|
||||
inventory,
|
||||
@@ -15,6 +16,7 @@ from app.routers import (
|
||||
material_categories,
|
||||
materials,
|
||||
pm,
|
||||
project_transfers,
|
||||
projects,
|
||||
quote_events,
|
||||
quotes,
|
||||
@@ -51,6 +53,8 @@ ROUTERS = [
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
project_transfers.router,
|
||||
dashboard.router,
|
||||
users.router,
|
||||
drafts.router,
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||
from app.models.procurement import LineItemSpec
|
||||
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
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:
|
||||
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_attribute_definition(
|
||||
payload: AttributeDefinitionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
||||
|
||||
@router.patch("/{definition_id}")
|
||||
async def update_attribute_definition(
|
||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
||||
definition_id,
|
||||
payload: AttributeDefinitionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
||||
|
||||
|
||||
@router.delete("/{definition_id}")
|
||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_attribute_definition(
|
||||
definition_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||
|
||||
@@ -22,7 +22,7 @@ from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.routers.users import _user_projects, user_to_dict
|
||||
from app.services import analytics
|
||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
||||
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
|
||||
from app.utils.ratelimit import limiter
|
||||
|
||||
logger = logging.getLogger("procurement.auth")
|
||||
@@ -78,6 +78,9 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
||||
claims = await _verify_token(access_token)
|
||||
ctx = await context_from_claims(claims, db)
|
||||
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(
|
||||
"login_success",
|
||||
{"username": payload.username, "role": ctx.role},
|
||||
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": body.get("expires_in"),
|
||||
"user": user_to_dict(ctx.user, projects),
|
||||
"user": await user_to_dict(ctx.user, projects, db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial, Material
|
||||
from app.models.procurement import LineItem
|
||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||
@@ -70,7 +72,11 @@ async def list_brand_materials(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_brand_material(
|
||||
payload: BrandMaterialCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
if await db.get(Brand, payload.brand_id) is None:
|
||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||
if await db.get(Material, payload.material_id) is None:
|
||||
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
|
||||
|
||||
|
||||
@router.patch("/{bm_id}")
|
||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_brand_material(
|
||||
bm_id,
|
||||
payload: BrandMaterialUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
||||
|
||||
|
||||
@router.delete("/{bm_id}")
|
||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_brand_material(
|
||||
bm_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial
|
||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def brand_to_dict(brand: Brand) -> dict:
|
||||
@@ -42,7 +44,11 @@ async def list_brands(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_brand(
|
||||
payload: BrandCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||
if any(b.country == payload.country for b in rows):
|
||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{brand_id}")
|
||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_brand(
|
||||
brand_id,
|
||||
payload: BrandUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
||||
|
||||
|
||||
@router.delete("/{brand_id}")
|
||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_brand(
|
||||
brand_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||
|
||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
||||
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||
# برای هر کاربر واردشده آزاد است.
|
||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||
_attachment_writer = require_role("procurement_manager", "admin")
|
||||
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||
@@ -125,7 +126,7 @@ async def _attachment_or_404(
|
||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
||||
# Proforma — no status restriction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
|
||||
@@ -12,6 +12,7 @@ import base64
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
@@ -58,6 +59,7 @@ class Scope:
|
||||
role: str
|
||||
project_id: uuid.UUID | None
|
||||
actor: str | None
|
||||
project_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
async def inventory_scope(
|
||||
@@ -75,25 +77,41 @@ async def inventory_scope(
|
||||
if request.headers.get("authorization"):
|
||||
user: UserContext = await get_optional_user(request, db)
|
||||
if user is not None:
|
||||
if user.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
||||
if user.role == "procurement_manager":
|
||||
panels = user.panels or {}
|
||||
if user.role is None and not panels:
|
||||
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
|
||||
# ادمین: دستنخورده — همهچیز از جمله تغییر موجودی
|
||||
if user.role == "admin":
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
if user.role not in ("pm", "admin"):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
||||
if user.role == "pm":
|
||||
allowed = list(user.project_ids or [])
|
||||
# لایه پنلها (جایگزین نقشها): پنل انبار اولویت دارد
|
||||
if "inventory" in panels:
|
||||
if panels["inventory"] == "full":
|
||||
# کل سیستم و تمام پروژهها — بهجز تغییر موجودی
|
||||
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="pm", project_id=project_id, actor=user.display_name)
|
||||
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||
if user.role in ("procurement_manager", "pm"):
|
||||
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
|
||||
if user.role == "supervisor":
|
||||
allowed = list(user.project_ids or [])
|
||||
if not allowed:
|
||||
raise ValidationError(
|
||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
||||
)
|
||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
||||
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 == "admin":
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
raise ValidationError("نقش نامعتبر است", field="role")
|
||||
|
||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||
@@ -128,9 +146,9 @@ async def inventory_scope(
|
||||
return Scope(role=role, project_id=project_id, actor=actor)
|
||||
|
||||
|
||||
def _require_pm(scope: Scope) -> None:
|
||||
if scope.role != ROLE_PM:
|
||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
||||
def _require_inventory_modifier(scope: Scope) -> None:
|
||||
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -197,6 +215,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
|
||||
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||
"performed_by": tx.performed_by,
|
||||
"delivered_to": tx.delivered_to,
|
||||
"transaction_date": to_iso(tx.transaction_date),
|
||||
"note": tx.note,
|
||||
}
|
||||
@@ -215,8 +234,16 @@ async def list_warehouses(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Warehouse)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
elif allowed:
|
||||
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||
else:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
stmt = stmt.order_by(Warehouse.name)
|
||||
@@ -248,9 +275,11 @@ async def create_warehouse(
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
||||
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if payload.project_id not in allowed:
|
||||
raise ValidationError("شما فقط میتوانید برای پروژه تخصیصیافته به خود انبار بسازید", field="project_id")
|
||||
if await db.get(Project, payload.project_id) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||
@@ -267,8 +296,9 @@ async def update_warehouse(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.project_id != wh.project_id:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.name is not None:
|
||||
wh.name = payload.name
|
||||
@@ -292,7 +322,9 @@ async def delete_warehouse(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||
# inventory cascade — block deletion if any stock history exists.
|
||||
@@ -323,7 +355,19 @@ async def list_inventory(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(InventoryItem)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
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(
|
||||
Warehouse.project_id == scope.project_id
|
||||
)
|
||||
@@ -388,7 +432,15 @@ async def list_transactions(
|
||||
stmt = select(InventoryTransaction).join(
|
||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
elif allowed:
|
||||
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||
else:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
@@ -397,9 +449,18 @@ async def list_transactions(
|
||||
if transaction_type is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
||||
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||
stmt = stmt.where(
|
||||
InventoryTransaction.transaction_date
|
||||
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||
)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
||||
stmt = stmt.where(
|
||||
InventoryTransaction.transaction_date
|
||||
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||
)
|
||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
@@ -454,7 +515,9 @@ async def inventory_history(
|
||||
if item is None:
|
||||
raise NotFoundError("موجودی یافت نشد")
|
||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||
txs = (await db.execute(
|
||||
select(InventoryTransaction)
|
||||
@@ -474,17 +537,19 @@ async def create_transaction(
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_require_pm(scope)
|
||||
_require_inventory_modifier(scope)
|
||||
# No explicit db.begin(): the session may already be inside an implicit
|
||||
# transaction (inventory_scope read from it in the JWT path); get_db
|
||||
# commits on success / rolls back on failure.
|
||||
with nullcontext():
|
||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||
if wh.project_id != scope.project_id:
|
||||
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||
if scope.role in (ROLE_PM, "supervisor"):
|
||||
if wh.project_id not in allowed:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.dest_warehouse_id is not None:
|
||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||
if dest.project_id != scope.project_id:
|
||||
if dest.project_id not in allowed:
|
||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||
await audit_record(
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||
tags=["item-receipts"],
|
||||
)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_receipt(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: ReceiptCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await _decision_or_404(db, line_item_id)
|
||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
||||
|
||||
|
||||
@router.patch("")
|
||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_receipt(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: ReceiptUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||
tags=["item-termsheets"],
|
||||
)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_item_termsheet(
|
||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: ItemTermsheetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
@@ -93,7 +99,12 @@ async def create_item_termsheet(
|
||||
|
||||
@router.patch("/{item_termsheet_id}")
|
||||
async def update_item_termsheet(
|
||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
item_termsheet_id,
|
||||
payload: ItemTermsheetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await db.get(Quote, quote_id)
|
||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
||||
|
||||
@router.delete("/{item_termsheet_id}")
|
||||
async def delete_item_termsheet(
|
||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
item_termsheet_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
it = await db.scalar(
|
||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Unit
|
||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
|
||||
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:
|
||||
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
||||
async def put_line_item_specs(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: SpecsPutIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, Unit
|
||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
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:
|
||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_line_item(
|
||||
quote_id,
|
||||
payload: LineItemCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
||||
|
||||
@router.patch("/{line_item_id}")
|
||||
async def update_line_item(
|
||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
line_item_id,
|
||||
payload: LineItemUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
@@ -131,7 +142,12 @@ async def update_line_item(
|
||||
|
||||
|
||||
@router.delete("/{line_item_id}")
|
||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_line_item(
|
||||
quote_id,
|
||||
line_item_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Supplier
|
||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def category_to_dict(category: MaterialCategory) -> dict:
|
||||
@@ -46,7 +48,11 @@ async def list_categories(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_category(
|
||||
payload: MaterialCategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{category_id}")
|
||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_category(
|
||||
category_id,
|
||||
payload: MaterialCategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_category(
|
||||
category_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import (
|
||||
AttributeDefinition,
|
||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def _coerce_filter_value(data_type: str, raw: str):
|
||||
@@ -125,7 +127,10 @@ async def list_materials(
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_material(
|
||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
payload: MaterialCreate,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
material = await material_service.create_material(db, payload)
|
||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
||||
|
||||
|
||||
@router.patch("/{material_id}")
|
||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_material(
|
||||
material_id,
|
||||
payload: MaterialUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
||||
|
||||
|
||||
@router.delete("/{material_id}")
|
||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_material(
|
||||
material_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
@@ -248,7 +262,10 @@ async def list_material_units(
|
||||
|
||||
@router.post("/{material_id}/units", status_code=201)
|
||||
async def create_material_unit(
|
||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
||||
material_id,
|
||||
payload: MaterialUnitCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
await _material_or_404(db, material_id)
|
||||
@@ -287,7 +304,11 @@ async def create_material_unit(
|
||||
|
||||
@router.patch("/{material_id}/units/{mu_id}")
|
||||
async def update_material_unit(
|
||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
||||
material_id,
|
||||
mu_id,
|
||||
payload: MaterialUnitUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
mu = await db.scalar(
|
||||
@@ -319,7 +340,12 @@ async def update_material_unit(
|
||||
|
||||
|
||||
@router.delete("/{material_id}/units/{mu_id}")
|
||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_material_unit(
|
||||
material_id,
|
||||
mu_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
mu = await db.scalar(
|
||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||
)
|
||||
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
|
||||
|
||||
@router.put("/{material_id}/attribute-values")
|
||||
async def put_material_attribute_values(
|
||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
||||
material_id,
|
||||
payload: MaterialAttributeValuesPut,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||
async with db.begin():
|
||||
|
||||
+68
-15
@@ -38,13 +38,14 @@ 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.models.catalog import BrandMaterial, Material, Unit
|
||||
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemReceipt,
|
||||
ItemReceiptAllocation,
|
||||
LineItem,
|
||||
LineItemSpec,
|
||||
Project,
|
||||
Quote,
|
||||
Supplier,
|
||||
@@ -71,16 +72,17 @@ class ArrivalScope:
|
||||
user_id: uuid.UUID | None
|
||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||
display_name: str
|
||||
project_ids: list[uuid.UUID] | None = None
|
||||
|
||||
|
||||
async def arrivals_scope(
|
||||
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),
|
||||
) -> ArrivalScope:
|
||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||
|
||||
PM → scoped to the requested project, which must be one they manage
|
||||
PM / supervisor → scoped to the requested project, which must be one they manage
|
||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||
Admin / procurement manager → all projects (param = optional filter).
|
||||
"""
|
||||
@@ -89,19 +91,19 @@ async def arrivals_scope(
|
||||
if ctx is not None:
|
||||
if ctx.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
if ctx.role == "pm":
|
||||
if ctx.role in ("pm", "supervisor"):
|
||||
allowed = list(ctx.project_ids or [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
|
||||
if not allowed:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
|
||||
if ctx.role in ("admin", "procurement_manager"):
|
||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
||||
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
|
||||
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
|
||||
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||
# Disabled by default: an ungated header identity in production would
|
||||
# let anyone confirm receipts as PM.
|
||||
@@ -110,8 +112,10 @@ async def arrivals_scope(
|
||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role == "procurement_manager":
|
||||
role = "admin"
|
||||
elif role == "supervisor":
|
||||
role = "pm"
|
||||
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.
|
||||
if project_id is None and role == "pm":
|
||||
raw_project = request.headers.get("x-project-id")
|
||||
@@ -179,20 +183,35 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
||||
async def pm_on_the_way(
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
|
||||
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
|
||||
):
|
||||
stmt = (
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.join(Supplier, Supplier.id == Quote.supplier_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")
|
||||
)
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
if material_id is not 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)")
|
||||
if ctx.project_id is not None:
|
||||
# PM: active project; admin/manager: optional project filter
|
||||
if ctx.project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
else:
|
||||
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||
elif ctx.project_id is not None:
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||
|
||||
@@ -201,8 +220,29 @@ async def pm_on_the_way(
|
||||
# unit / category lookups for display
|
||||
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()}
|
||||
# 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 = []
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
for d, li, q, m, sup, proj, bm, br in decisions:
|
||||
received = received_map.get(d.id)
|
||||
out.append({
|
||||
"item_decision_id": str(d.id),
|
||||
@@ -214,6 +254,8 @@ async def pm_on_the_way(
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"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,
|
||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||
"ordered_qty": str(li.quantity),
|
||||
@@ -243,6 +285,10 @@ async def pm_confirm_receipt(
|
||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||
if decision is None or decision.status != "chosen":
|
||||
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:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||
|
||||
@@ -451,10 +497,17 @@ async def pm_receipts(
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Project, Project.id == ItemDecision.project_id)
|
||||
)
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
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)")
|
||||
if ctx.project_id is not None:
|
||||
# PM: active project; admin/manager: optional project filter
|
||||
if ctx.project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
else:
|
||||
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||
elif ctx.project_id is not None:
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||
receipts = [r.ItemReceipt for r in rows]
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.routers.pm import ArrivalScope, arrivals_scope
|
||||
from app.schemas.project_transfers import (
|
||||
ProjectTransferCreate,
|
||||
ProjectTransferOut,
|
||||
ProjectTransferReceive,
|
||||
ProjectTransferUpdate,
|
||||
)
|
||||
from app.services import project_transfer_service
|
||||
from app.utils.errors import ForbiddenError
|
||||
|
||||
router = APIRouter(tags=["project-transfers"])
|
||||
|
||||
|
||||
def _to_transfer_out(t) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"from_project": (
|
||||
{"id": t.from_project.id, "name": t.from_project.name}
|
||||
if t.from_project
|
||||
else {"id": t.from_project_id, "name": ""}
|
||||
),
|
||||
"from_warehouse": (
|
||||
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
|
||||
if t.from_warehouse
|
||||
else {"id": t.from_warehouse_id, "name": ""}
|
||||
),
|
||||
"to_project": (
|
||||
{"id": t.to_project.id, "name": t.to_project.name}
|
||||
if t.to_project
|
||||
else {"id": t.to_project_id, "name": ""}
|
||||
),
|
||||
"to_warehouse": (
|
||||
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
|
||||
if t.to_warehouse
|
||||
else None
|
||||
),
|
||||
"material": (
|
||||
{"id": t.material.id, "name": t.material.name}
|
||||
if t.material
|
||||
else {"id": t.material_id, "name": ""}
|
||||
),
|
||||
"brand": (
|
||||
{"id": t.brand.id, "name": t.brand.name}
|
||||
if t.brand
|
||||
else None
|
||||
),
|
||||
"unit": (
|
||||
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
|
||||
if t.unit
|
||||
else {"id": t.unit_id, "code": "", "name_fa": None}
|
||||
),
|
||||
"quantity": t.quantity,
|
||||
"specs": t.specs or {},
|
||||
"specs_hash": t.specs_hash,
|
||||
"note": t.note,
|
||||
"status": t.status,
|
||||
"transfer_date": t.transfer_date,
|
||||
"transferred_by": t.transferred_by,
|
||||
"received_at": t.received_at,
|
||||
"received_by": t.received_by,
|
||||
"receipt_allocations": t.receipt_allocations,
|
||||
"created_at": t.created_at,
|
||||
"updated_at": t.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inventory/project-transfers — Create inter-project transfer
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/inventory/project-transfers", status_code=201)
|
||||
async def create_transfer(
|
||||
payload: ProjectTransferCreate,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.create_project_transfer(
|
||||
db=db,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
transferred_by=ctx.display_name,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /inventory/project-transfers — List transfers with direction and filters
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/inventory/project-transfers")
|
||||
async def list_transfers(
|
||||
project_id: uuid.UUID | None = Query(default=None),
|
||||
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
|
||||
status: str | None = Query(default=None),
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target_pid = project_id or ctx.project_id
|
||||
transfers = await project_transfer_service.list_project_transfers(
|
||||
db=db,
|
||||
project_id=target_pid,
|
||||
direction=direction,
|
||||
status=status,
|
||||
)
|
||||
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /inventory/project-transfers/{id} — Single transfer details
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/inventory/project-transfers/{transfer_id}")
|
||||
async def get_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
|
||||
if ctx.role == "pm" and ctx.project_id is not None:
|
||||
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.patch("/inventory/project-transfers/{transfer_id}")
|
||||
async def update_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferUpdate,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.update_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.delete("/inventory/project-transfers/{transfer_id}")
|
||||
async def delete_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
res = await project_transfer_service.delete_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
)
|
||||
return {"data": res}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/pm/transfers/on-the-way")
|
||||
async def pm_incoming_transfers(
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید")
|
||||
|
||||
transfers = await project_transfer_service.list_project_transfers(
|
||||
db=db,
|
||||
project_id=ctx.project_id,
|
||||
direction="incoming",
|
||||
status="in_transit",
|
||||
)
|
||||
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/inventory/project-transfers/{transfer_id}/receive")
|
||||
async def receive_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferReceive,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.receive_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
received_by=ctx.display_name,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, Quote, User
|
||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_date, to_iso
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
||||
d = {
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"location": project.location or "",
|
||||
"project_manager": project.project_manager or "",
|
||||
"description": project.description or "",
|
||||
"start_date": to_date(project.start_date),
|
||||
"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,
|
||||
"created_at": to_iso(project.created_at),
|
||||
}
|
||||
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
||||
pm = await db.get(User, project.pm_user_id)
|
||||
if pm:
|
||||
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
|
||||
|
||||
|
||||
@@ -55,7 +62,14 @@ async def list_projects(
|
||||
):
|
||||
stmt = select(Project)
|
||||
if q:
|
||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Project.name.ilike(f"%{q}%"),
|
||||
Project.description.ilike(f"%{q}%"),
|
||||
Project.location.ilike(f"%{q}%"),
|
||||
Project.project_manager.ilike(f"%{q}%"),
|
||||
)
|
||||
)
|
||||
stmt = sort_query(stmt, Project, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
pm_map = await _pm_map(db, items)
|
||||
@@ -64,24 +78,38 @@ async def list_projects(
|
||||
d = {
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"location": p.location or "",
|
||||
"project_manager": p.project_manager or "",
|
||||
"description": p.description or "",
|
||||
"start_date": to_date(p.start_date),
|
||||
"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,
|
||||
"created_at": to_iso(p.created_at),
|
||||
}
|
||||
pm = pm_map.get(p.pm_user_id)
|
||||
if pm:
|
||||
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)
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_project(
|
||||
payload: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = Project(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
name=payload.name.strip(),
|
||||
location=payload.location.strip() if payload.location else "",
|
||||
project_manager=payload.project_manager.strip() if payload.project_manager else "",
|
||||
description=payload.description.strip() if payload.description else "",
|
||||
start_date=payload.start_date,
|
||||
planned_start=payload.start_date,
|
||||
status="ACTIVE",
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
@@ -103,37 +131,51 @@ async def update_project(
|
||||
request: Request,
|
||||
ctx: UserContext | None = Depends(_optional_ctx),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
if payload.name is not None:
|
||||
project.name = payload.name
|
||||
project.name = payload.name.strip()
|
||||
if payload.location is not None:
|
||||
project.location = payload.location.strip()
|
||||
if payload.project_manager is not None:
|
||||
project.project_manager = payload.project_manager.strip()
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
project.description = payload.description.strip()
|
||||
if payload.start_date is not None:
|
||||
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.
|
||||
# Admin / procurement manager only (same rule as the Organization panel).
|
||||
if "pm_user_id" in payload.model_fields_set:
|
||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role not in ("admin", "procurement_manager"):
|
||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
||||
if role not in ("admin", "procurement_manager", "pm"):
|
||||
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||
if payload.pm_user_id is None:
|
||||
project.pm_user_id = None
|
||||
else:
|
||||
pm_user = await db.get(User, payload.pm_user_id)
|
||||
if pm_user is None:
|
||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||
if pm_user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
||||
if pm_user.role not in ("supervisor", "pm"):
|
||||
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="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()
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_project(
|
||||
project_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote, QuoteEvent
|
||||
from app.schemas.procurement import NoteEventIn
|
||||
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -34,7 +36,12 @@ async def list_events(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
||||
async def create_event(
|
||||
quote_id,
|
||||
payload: NoteEventIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
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.models.catalog import Material
|
||||
from app.models.procurement import (
|
||||
@@ -36,12 +37,15 @@ from app.services.expand_service import (
|
||||
serialize_quote_with_children,
|
||||
serialize_quotes,
|
||||
)
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import sort_query
|
||||
from app.utils.security import get_actor
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||
|
||||
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
# --- list filter helpers ---------------------------------------------------
|
||||
|
||||
@@ -156,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
async def create_quote(
|
||||
payload: QuoteCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
ctx: UserContext = Depends(allow_quote_create()),
|
||||
_auth=None,
|
||||
):
|
||||
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||
if ctx.project_id:
|
||||
allowed.add(ctx.project_id)
|
||||
allowed_str = {str(pid) for pid in allowed if pid}
|
||||
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||
if not wanted:
|
||||
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||
if not wanted <= allowed_str:
|
||||
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||
quote = await quote_service.create_quote(db, payload, actor)
|
||||
return {"data": await serialize_quote_with_children(db, quote)}
|
||||
|
||||
@@ -167,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
||||
|
||||
@router.patch("/{quote_id}")
|
||||
async def update_quote(
|
||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: QuoteUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||
return {"data": await serialize_quote(db, quote, set())}
|
||||
@@ -175,7 +200,11 @@ async def update_quote(
|
||||
|
||||
@router.patch("/{quote_id}/children")
|
||||
async def replace_quote_children(
|
||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: QuoteChildrenReplaceIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||
@@ -188,7 +217,11 @@ async def replace_quote_children(
|
||||
|
||||
@router.patch("/{quote_id}/accept")
|
||||
async def accept_quote(
|
||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: AcceptQuoteIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||
return {"data": await _accept_response(db, quote)}
|
||||
@@ -224,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/arrive")
|
||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
async def arrive_quote(
|
||||
quote_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||
return {
|
||||
"data": {
|
||||
@@ -237,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
||||
|
||||
@router.patch("/{quote_id}/arrive-items")
|
||||
async def arrive_items(
|
||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: ArriveItemsIn,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||
return {
|
||||
@@ -259,14 +301,23 @@ async def arrive_items(
|
||||
|
||||
@router.patch("/{quote_id}/reject")
|
||||
async def reject_quote(
|
||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
quote_id,
|
||||
payload: RejectQuoteIn | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/expire")
|
||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
async def expire_quote(
|
||||
quote_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: str = Depends(get_actor),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
@@ -276,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/{quote_id}")
|
||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_quote(
|
||||
quote_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
result = await quote_service.delete_quote(db, quote_id)
|
||||
return {"data": result}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||
tags=["schedule-steps"],
|
||||
)
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||
@@ -57,7 +59,11 @@ async def list_schedule_steps(
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_schedule_step(
|
||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
payload: ScheduleStepCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
step_no = payload.step_no
|
||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
||||
|
||||
@router.patch("/{step_id}")
|
||||
async def update_schedule_step(
|
||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
step_id,
|
||||
payload: ScheduleStepUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
step = await _step_or_404(db, termsheet_id, step_id)
|
||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||
@@ -109,7 +120,13 @@ async def update_schedule_step(
|
||||
|
||||
|
||||
@router.delete("/{step_id}")
|
||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_schedule_step(
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
step_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
step = await _step_or_404(db, termsheet_id, step_id)
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import StandardConversion
|
||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_standard_conversion(
|
||||
payload: StandardConversionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(
|
||||
select(StandardConversion.id).where(
|
||||
StandardConversion.from_unit == payload.from_unit,
|
||||
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{sc_id}")
|
||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_standard_conversion(
|
||||
sc_id,
|
||||
payload: StandardConversionUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
||||
|
||||
|
||||
@router.delete("/{sc_id}")
|
||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_standard_conversion(
|
||||
sc_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
||||
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
||||
GET /materials/{id}/suppliers — price-comparison data source.
|
||||
GET /materials/{id}/suppliers — suppliers linked to a material.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.models.procurement import Supplier, SupplierMaterial
|
||||
@@ -18,31 +21,50 @@ from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(tags=["supplier-materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
||||
def link_to_dict(
|
||||
link: SupplierMaterial,
|
||||
material: Material | None = None,
|
||||
category: MaterialCategory | None = None,
|
||||
supplier: Supplier | None = None) -> dict:
|
||||
supplier: Supplier | None = None,
|
||||
) -> dict:
|
||||
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
|
||||
return {
|
||||
"id": str(link.id),
|
||||
"supplier_id": str(link.supplier_id),
|
||||
"material_id": str(link.material_id),
|
||||
"is_active": link.is_active,
|
||||
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
|
||||
"last_quoted_price": (
|
||||
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,
|
||||
"created_at": to_iso(link.created_at),
|
||||
"material": {
|
||||
"material": (
|
||||
{
|
||||
"id": str(material.id),
|
||||
"name": material.name,
|
||||
"category": {"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,
|
||||
"category": (
|
||||
{"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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /suppliers/{supplier_id}/materials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/suppliers/{supplier_id}/materials")
|
||||
async def list_supplier_materials(
|
||||
supplier_id,
|
||||
supplier_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -56,44 +78,110 @@ async def list_supplier_materials(
|
||||
.order_by(SupplierMaterial.created_at)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
mat_ids = {l.material_id for l in items}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
||||
mat_ids = [lnk.material_id for lnk in items]
|
||||
materials: dict = {}
|
||||
if mat_ids:
|
||||
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)
|
||||
async def create_supplier_material(
|
||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
||||
supplier_id: UUID,
|
||||
payload: SupplierMaterialCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||
material = await db.get(Material, payload.material_id)
|
||||
return {"data": link_to_dict(link, material, None)}
|
||||
return {"data": link_to_dict(link, material)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /suppliers/{supplier_id}/materials/{material_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_supplier_material(
|
||||
supplier_id: UUID,
|
||||
material_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
await unlink_supplier_material(db, supplier_id, material_id)
|
||||
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(
|
||||
material_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
material_id: UUID,
|
||||
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)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
|
||||
stmt = (
|
||||
select(SupplierMaterial)
|
||||
select(Supplier)
|
||||
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
||||
.order_by(Supplier.name)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
sup_ids = {l.supplier_id for l in items}
|
||||
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
||||
suppliers = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": str(supplier.id),
|
||||
"name": supplier.name,
|
||||
"phone": supplier.phone,
|
||||
"contact_person": supplier.contact_person,
|
||||
"location": supplier.location,
|
||||
}
|
||||
for supplier in suppliers
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Quote, Supplier
|
||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_supplier(
|
||||
payload: SupplierCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
category = await _category_or_404(db, payload.category_id)
|
||||
supplier = Supplier(
|
||||
name=payload.name,
|
||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{supplier_id}")
|
||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_supplier(
|
||||
supplier_id,
|
||||
payload: SupplierUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_supplier(
|
||||
supplier_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_termsheet(
|
||||
quote_id,
|
||||
payload: TermsheetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
||||
|
||||
@router.patch("/{termsheet_id}")
|
||||
async def update_termsheet(
|
||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
payload: TermsheetUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
if ts.status != "offered":
|
||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
||||
|
||||
@router.patch("/{termsheet_id}/status")
|
||||
async def update_termsheet_status(
|
||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
payload: TermsheetStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
async with db.begin():
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
||||
|
||||
|
||||
@router.delete("/{termsheet_id}")
|
||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_termsheet(
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
|
||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import require_procurement_writer, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||
from app.models.procurement import LineItem, LineItemSpec
|
||||
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/units", tags=["units"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def unit_full_to_dict(unit: Unit) -> dict:
|
||||
@@ -45,7 +47,11 @@ async def list_units(
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
||||
async def create_unit(
|
||||
payload: UnitCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{unit_id}")
|
||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_unit(
|
||||
unit_id: int,
|
||||
payload: UnitUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
||||
|
||||
|
||||
@router.delete("/{unit_id}")
|
||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
async def delete_unit(
|
||||
unit_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
|
||||
+186
-31
@@ -17,15 +17,21 @@ import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import delete, select, update, text, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_current_user, require_role
|
||||
from app.utils.audit import record as audit_record
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, User
|
||||
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
||||
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||
from app.schemas.procurement import (
|
||||
UserCreate,
|
||||
UserPanelsUpdate,
|
||||
UserPanelProjectsUpdate,
|
||||
UserProjectUpdate,
|
||||
UserRoleUpdate,
|
||||
)
|
||||
from app.utils.errors import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
|
||||
"pm": "مدیر پروژه",
|
||||
"procurement_manager": "مدیر تدارکات",
|
||||
"admin": "ادمین",
|
||||
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||
"supervisor": "سرپرست پروژه",
|
||||
}
|
||||
|
||||
|
||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
PANEL_LEVELS = ("full", "limited")
|
||||
PANELS = ("procurement", "inventory", "pm")
|
||||
|
||||
|
||||
async def _panels_of(db: AsyncSession, user_id) -> dict:
|
||||
"""panel -> level map for the user (لایه پنلها — جایگزین نقشها)."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return {row.panel: row.level for row in rows}
|
||||
|
||||
|
||||
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
|
||||
"""عضویت M:N کاربر در پروژهها (جدا از مسئولیت) — رشته."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserProjectMembership.project_id)
|
||||
.where(UserProjectMembership.user_id == user_id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return [str(pid) for pid in rows]
|
||||
|
||||
|
||||
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
|
||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||
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 []
|
||||
first = projects[0] if projects else None
|
||||
return {
|
||||
out = {
|
||||
"id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
@@ -62,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||
"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:
|
||||
"""Admin OR procurement manager (the same person) — organization management."""
|
||||
if ctx.role not in ("admin", "procurement_manager"):
|
||||
"""Admin, procurement manager, or project manager — organization management."""
|
||||
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||
return ctx
|
||||
|
||||
|
||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
||||
if user.role != "pm":
|
||||
"""Every project assigned to the user (supervisor or PM) (oldest first).
|
||||
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
|
||||
"""
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
return []
|
||||
member_subq = select(UserProjectMembership.project_id).where(
|
||||
UserProjectMembership.user_id == user.id
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.where(
|
||||
or_(
|
||||
Project.pm_user_id == user.id,
|
||||
Project.id.in_(member_subq),
|
||||
)
|
||||
)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
|
||||
@router.get("/me")
|
||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {"data": user_to_dict(ctx.user, projects)}
|
||||
return {"data": await user_to_dict(ctx.user, projects, db)}
|
||||
|
||||
|
||||
@router.get("/me/projects")
|
||||
@@ -138,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
|
||||
active project against this list and falls back gracefully when a
|
||||
project is revoked."""
|
||||
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]}
|
||||
|
||||
|
||||
@@ -148,20 +212,20 @@ async def list_users(
|
||||
):
|
||||
"""Every user (with role + assigned projects) — Organization panel."""
|
||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
||||
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||
by_user: dict = {}
|
||||
if pm_ids:
|
||||
if supervisor_ids:
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||
"meta": {"total": len(users)},
|
||||
}
|
||||
|
||||
@@ -187,30 +251,30 @@ async def create_user(
|
||||
db.add(user)
|
||||
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})
|
||||
return {"data": user_to_dict(user)}
|
||||
return {"data": await user_to_dict(user)}
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def update_user_role(
|
||||
user_id,
|
||||
payload: UserRoleUpdate,
|
||||
ctx: UserContext = Depends(require_role("admin")),
|
||||
ctx: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
||||
unassigns the user from their project."""
|
||||
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||
if payload.role == "admin" and ctx.role != "admin":
|
||||
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
previous_role = user.role
|
||||
if user.role == "pm" and payload.role != "pm":
|
||||
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
|
||||
await _unassign_projects(db, user.id)
|
||||
user.role = payload.role
|
||||
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})
|
||||
projects = await _user_projects(db, user)
|
||||
return {"data": user_to_dict(user, projects)}
|
||||
return {"data": await user_to_dict(user, projects)}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
|
||||
continue
|
||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||
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(
|
||||
casdoor_id=cid,
|
||||
username=uname,
|
||||
first_name=str(
|
||||
cu.get("firstName")
|
||||
or cu.get("displayName")
|
||||
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
|
||||
phone=str(cu.get("phone") or "") or None,
|
||||
))
|
||||
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()
|
||||
|
||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
||||
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||
by_user: dict = {}
|
||||
if pm_ids:
|
||||
if supervisor_ids:
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||
"meta": {"total": len(users), "created": created},
|
||||
}
|
||||
|
||||
@@ -282,17 +357,97 @@ async def assign_user_project(
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
if user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||
|
||||
if payload.project_id is None:
|
||||
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()
|
||||
return {"data": user_to_dict(user)}
|
||||
return {"data": await user_to_dict(user)}
|
||||
|
||||
project = await db.get(Project, payload.project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_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()
|
||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
||||
return {"data": await user_to_dict(user, await _user_projects(db, user))}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# پنلها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.put("/{user_id}/panels")
|
||||
@router.patch("/{user_id}/panels")
|
||||
async def set_user_panels(
|
||||
user_id,
|
||||
payload: UserPanelsUpdate,
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
for panel, level in payload.panels.items():
|
||||
if panel not in PANELS:
|
||||
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
|
||||
if level not in PANEL_LEVELS:
|
||||
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
|
||||
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
|
||||
for panel, level in payload.panels.items():
|
||||
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
|
||||
await db.flush()
|
||||
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||
|
||||
|
||||
@router.put("/{user_id}/panel-projects")
|
||||
@router.patch("/{user_id}/panel-projects")
|
||||
async def set_user_panel_projects(
|
||||
user_id,
|
||||
payload: UserPanelProjectsUpdate,
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
wanted = list(dict.fromkeys(payload.project_ids))
|
||||
if wanted:
|
||||
found = (
|
||||
await db.execute(select(Project).where(Project.id.in_(wanted)))
|
||||
).scalars().all()
|
||||
found_ids = {p.id for p in found}
|
||||
unknown = [str(pid) for pid in wanted if pid not in found_ids]
|
||||
if unknown:
|
||||
raise ValidationError(
|
||||
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||
)
|
||||
await db.execute(
|
||||
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
|
||||
)
|
||||
for pid in wanted:
|
||||
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
|
||||
await db.flush()
|
||||
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||
|
||||
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
|
||||
unit_id: int | None = None
|
||||
specs: dict | None = None
|
||||
note: str | None = None
|
||||
delivered_to: str | None = None
|
||||
transaction_date: datetime | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
|
||||
|
||||
class ProjectCreate(APIModel):
|
||||
name: str = Field(min_length=1)
|
||||
location: str | None = None
|
||||
project_manager: str | None = None
|
||||
description: str | None = None
|
||||
start_date: date | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(APIModel):
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
location: str | None = None
|
||||
project_manager: str | None = None
|
||||
description: str | 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).
|
||||
# Admin / procurement manager only; None unassigns this project's PM.
|
||||
pm_user_id: UUID | None = None
|
||||
@@ -135,6 +140,11 @@ class AcceptTermsheetIn(APIModel):
|
||||
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):
|
||||
line_item_id: UUID
|
||||
status: Literal["chosen", "rejected"]
|
||||
@@ -142,6 +152,7 @@ class ItemDecisionIn(APIModel):
|
||||
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
||||
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)")
|
||||
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
|
||||
|
||||
|
||||
class ItemProjectsIn(APIModel):
|
||||
@@ -203,13 +214,13 @@ class UserCreate(APIModel):
|
||||
last_name: str = ""
|
||||
phone: str | None = None
|
||||
casdoor_id: str | None = None
|
||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
||||
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||
|
||||
|
||||
class UserRoleUpdate(APIModel):
|
||||
"""role=None revokes the role (and any project assignment)."""
|
||||
|
||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
||||
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||
|
||||
|
||||
class UserProjectUpdate(APIModel):
|
||||
@@ -341,3 +352,16 @@ class ReceiptUpdate(APIModel):
|
||||
received_qty: Decimal | None = Field(default=None, gt=0)
|
||||
on_time: bool | 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)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import APIModel
|
||||
|
||||
|
||||
class WarehouseAllocationIn(APIModel):
|
||||
warehouse_id: UUID
|
||||
quantity: Decimal = Field(gt=0)
|
||||
|
||||
|
||||
class ProjectTransferCreate(APIModel):
|
||||
from_warehouse_id: UUID
|
||||
to_project_id: UUID
|
||||
material_id: UUID
|
||||
brand_id: UUID | None = None
|
||||
unit_id: int
|
||||
quantity: Decimal = Field(gt=0)
|
||||
specs: dict = Field(default_factory=dict)
|
||||
note: str | None = None
|
||||
transfer_date: datetime | None = None
|
||||
|
||||
|
||||
class ProjectTransferUpdate(APIModel):
|
||||
to_project_id: UUID | None = None
|
||||
quantity: Decimal | None = Field(default=None, gt=0)
|
||||
note: str | None = None
|
||||
transfer_date: datetime | None = None
|
||||
|
||||
|
||||
class ProjectTransferReceive(APIModel):
|
||||
allocations: list[WarehouseAllocationIn] = Field(
|
||||
min_length=1,
|
||||
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
|
||||
)
|
||||
received_at: datetime | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class IdNameOut(APIModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class UnitBriefOut(APIModel):
|
||||
id: int
|
||||
code: str
|
||||
name_fa: str | None = None
|
||||
|
||||
|
||||
class ProjectTransferOut(APIModel):
|
||||
id: UUID
|
||||
from_project: IdNameOut
|
||||
from_warehouse: IdNameOut
|
||||
to_project: IdNameOut
|
||||
to_warehouse: IdNameOut | None = None
|
||||
material: IdNameOut
|
||||
brand: IdNameOut | None = None
|
||||
unit: UnitBriefOut
|
||||
quantity: Decimal
|
||||
specs: dict
|
||||
specs_hash: str
|
||||
note: str | None = None
|
||||
status: str
|
||||
transfer_date: datetime
|
||||
transferred_by: str | None = None
|
||||
received_at: datetime | None = None
|
||||
received_by: str | None = None
|
||||
receipt_allocations: list[dict] | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -0,0 +1,732 @@
|
||||
"""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,6 +290,7 @@ async def apply_transaction(
|
||||
transaction_type=t,
|
||||
quantity=quantity,
|
||||
performed_by=performed_by,
|
||||
delivered_to=payload.delivered_to,
|
||||
transaction_date=payload.transaction_date,
|
||||
note=payload.note,
|
||||
)
|
||||
@@ -319,6 +320,7 @@ async def apply_transaction(
|
||||
transaction_type=t,
|
||||
quantity=-quantity,
|
||||
performed_by=performed_by,
|
||||
delivered_to=payload.delivered_to,
|
||||
transaction_date=payload.transaction_date,
|
||||
note=payload.note,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.catalog import Brand, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import Project
|
||||
from app.models.project_transfer import ProjectTransfer
|
||||
from app.schemas.project_transfers import (
|
||||
ProjectTransferCreate,
|
||||
ProjectTransferReceive,
|
||||
ProjectTransferUpdate,
|
||||
)
|
||||
from app.services.inventory_service import (
|
||||
_find_inventory,
|
||||
_get_or_create_inventory,
|
||||
canonical_specs_hash,
|
||||
)
|
||||
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
|
||||
|
||||
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
|
||||
stmt = (
|
||||
select(ProjectTransfer)
|
||||
.where(ProjectTransfer.id == transfer_id)
|
||||
.options(
|
||||
selectinload(ProjectTransfer.from_project),
|
||||
selectinload(ProjectTransfer.to_project),
|
||||
selectinload(ProjectTransfer.from_warehouse),
|
||||
selectinload(ProjectTransfer.to_warehouse),
|
||||
selectinload(ProjectTransfer.material),
|
||||
selectinload(ProjectTransfer.brand),
|
||||
selectinload(ProjectTransfer.unit),
|
||||
)
|
||||
)
|
||||
transfer = await db.scalar(stmt)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
|
||||
return transfer
|
||||
|
||||
|
||||
async def create_project_transfer(
|
||||
db: AsyncSession,
|
||||
payload: ProjectTransferCreate,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
transferred_by: str | None = None,
|
||||
) -> ProjectTransfer:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
# Validate source warehouse
|
||||
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
|
||||
if from_wh is None:
|
||||
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
|
||||
|
||||
# RBAC: PM can only send from their own project
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if from_wh.project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
|
||||
|
||||
from_proj = await db.get(Project, from_wh.project_id)
|
||||
to_proj = await db.get(Project, payload.to_project_id)
|
||||
if to_proj is None:
|
||||
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||
|
||||
if from_wh.project_id == payload.to_project_id:
|
||||
raise ValidationError(
|
||||
"پروژه مقصد نمیتواند همان پروژه مبدأ باشد (از انتقال بینانباری استفاده کنید)",
|
||||
field="to_project_id",
|
||||
)
|
||||
|
||||
# Validate material, brand, unit
|
||||
mat = await db.get(Material, payload.material_id)
|
||||
if mat is None:
|
||||
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
|
||||
|
||||
if payload.brand_id is not None:
|
||||
brand = await db.get(Brand, payload.brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||
|
||||
unit = await db.get(Unit, payload.unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||
|
||||
# Requirement 2: Canonical specs hash from inventory_service
|
||||
specs = payload.specs or {}
|
||||
specs_hash = canonical_specs_hash(specs)
|
||||
|
||||
# Check stock in source warehouse with SELECT ... FOR UPDATE
|
||||
source_item = await _find_inventory(
|
||||
db,
|
||||
payload.from_warehouse_id,
|
||||
payload.material_id,
|
||||
payload.brand_id,
|
||||
specs_hash,
|
||||
for_update=True,
|
||||
)
|
||||
|
||||
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
|
||||
if source_item is None:
|
||||
candidates_res = await db.execute(
|
||||
select(InventoryItem)
|
||||
.where(
|
||||
InventoryItem.warehouse_id == payload.from_warehouse_id,
|
||||
InventoryItem.material_id == payload.material_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
candidates = candidates_res.scalars().all()
|
||||
|
||||
if len(candidates) == 1:
|
||||
source_item = candidates[0]
|
||||
elif len(candidates) > 1:
|
||||
best_cand = None
|
||||
best_score = -1
|
||||
p_keys = set(specs.keys())
|
||||
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
|
||||
|
||||
for cand in candidates:
|
||||
score = 0
|
||||
if cand.brand_id == payload.brand_id:
|
||||
score += 10
|
||||
elif cand.brand_id is None and payload.brand_id is None:
|
||||
score += 10
|
||||
elif cand.brand_id is None:
|
||||
score += 4
|
||||
|
||||
c_specs = cand.specs or {}
|
||||
c_keys = set(c_specs.keys())
|
||||
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
|
||||
|
||||
for pk in p_keys:
|
||||
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
|
||||
score += 5
|
||||
for pv in p_vals:
|
||||
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
|
||||
score += 5
|
||||
|
||||
if cand.quantity >= payload.quantity:
|
||||
score += 2
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_cand = cand
|
||||
|
||||
source_item = best_cand
|
||||
|
||||
if source_item is None or source_item.quantity < payload.quantity:
|
||||
avail = source_item.quantity if source_item else Decimal("0")
|
||||
raise ValidationError(
|
||||
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
|
||||
field="quantity",
|
||||
)
|
||||
|
||||
# Deduct from source warehouse immediately
|
||||
source_item.quantity -= payload.quantity
|
||||
|
||||
transfer_pair_id = uuid.uuid4()
|
||||
now_dt = payload.transfer_date or datetime.now(timezone.utc)
|
||||
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
|
||||
if payload.note:
|
||||
note_text += f" — {payload.note}"
|
||||
|
||||
out_tx = InventoryTransaction(
|
||||
inventory_id=source_item.id,
|
||||
warehouse_id=from_wh.id,
|
||||
transaction_type="TRANSFER_OUT",
|
||||
quantity=-payload.quantity,
|
||||
transfer_pair_id=transfer_pair_id,
|
||||
performed_by=transferred_by,
|
||||
transaction_date=now_dt,
|
||||
note=note_text,
|
||||
)
|
||||
db.add(out_tx)
|
||||
await db.flush()
|
||||
|
||||
actual_brand_id = source_item.brand_id
|
||||
actual_specs = source_item.specs if source_item.specs is not None else specs
|
||||
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
|
||||
|
||||
transfer = ProjectTransfer(
|
||||
from_project_id=from_wh.project_id,
|
||||
from_warehouse_id=from_wh.id,
|
||||
to_project_id=payload.to_project_id,
|
||||
material_id=payload.material_id,
|
||||
brand_id=actual_brand_id,
|
||||
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
|
||||
quantity=payload.quantity,
|
||||
specs=actual_specs,
|
||||
specs_hash=actual_specs_hash,
|
||||
note=payload.note,
|
||||
status="in_transit",
|
||||
out_transaction_id=out_tx.id,
|
||||
transfer_date=now_dt,
|
||||
transferred_by=transferred_by,
|
||||
)
|
||||
db.add(transfer)
|
||||
await db.flush()
|
||||
|
||||
return await get_project_transfer_or_404(db, transfer.id)
|
||||
|
||||
|
||||
async def update_project_transfer(
|
||||
db: AsyncSession,
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferUpdate,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
) -> ProjectTransfer:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
transfer = await db.scalar(
|
||||
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||
)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||
|
||||
# RBAC (Requirement 4)
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if transfer.from_project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به ویرایش انتقالهای پروژه مبدأ خود هستید")
|
||||
|
||||
# Status Lock Guard (Requirement 4)
|
||||
if transfer.status == "received":
|
||||
raise ConflictError(
|
||||
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
|
||||
)
|
||||
if transfer.status != "in_transit":
|
||||
raise ConflictError("فقط انتقالهای در راه (in_transit) قابل ویرایش هستند")
|
||||
|
||||
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
|
||||
new_to_proj = await db.get(Project, payload.to_project_id)
|
||||
if new_to_proj is None:
|
||||
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||
if payload.to_project_id == transfer.from_project_id:
|
||||
raise ValidationError("پروژه مقصد نمیتواند همان پروژه مبدأ باشد", field="to_project_id")
|
||||
transfer.to_project_id = payload.to_project_id
|
||||
|
||||
# Quantity adjustment (reconciles source stock difference)
|
||||
if payload.quantity is not None and payload.quantity != transfer.quantity:
|
||||
delta = payload.quantity - transfer.quantity
|
||||
source_item = await _find_inventory(
|
||||
db,
|
||||
transfer.from_warehouse_id,
|
||||
transfer.material_id,
|
||||
transfer.brand_id,
|
||||
transfer.specs_hash,
|
||||
for_update=True,
|
||||
)
|
||||
if delta > 0:
|
||||
if source_item is None or source_item.quantity < delta:
|
||||
avail = source_item.quantity if source_item else Decimal("0")
|
||||
raise ValidationError(
|
||||
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
|
||||
field="quantity",
|
||||
)
|
||||
source_item.quantity -= delta
|
||||
else:
|
||||
if source_item is not None:
|
||||
source_item.quantity += abs(delta)
|
||||
|
||||
transfer.quantity = payload.quantity
|
||||
|
||||
# Update out_transaction
|
||||
if transfer.out_transaction_id is not None:
|
||||
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if out_tx is not None:
|
||||
out_tx.quantity = -payload.quantity
|
||||
|
||||
if payload.note is not None:
|
||||
transfer.note = payload.note
|
||||
|
||||
if payload.transfer_date is not None:
|
||||
transfer.transfer_date = payload.transfer_date
|
||||
if transfer.out_transaction_id is not None:
|
||||
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if out_tx is not None:
|
||||
out_tx.transaction_date = payload.transfer_date
|
||||
|
||||
return await get_project_transfer_or_404(db, transfer_id)
|
||||
|
||||
|
||||
async def delete_project_transfer(
|
||||
db: AsyncSession,
|
||||
transfer_id: uuid.UUID,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
) -> dict:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
transfer = await db.scalar(
|
||||
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||
)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||
|
||||
# RBAC (Requirement 4)
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if transfer.from_project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به لغو انتقالهای پروژه مبدأ خود هستید")
|
||||
|
||||
# Status Lock Guard (Requirement 4)
|
||||
if transfer.status == "received":
|
||||
raise ConflictError(
|
||||
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
|
||||
)
|
||||
|
||||
# Reversibility (Requirement 4): Restore stock to source warehouse
|
||||
source_item = await _find_inventory(
|
||||
db,
|
||||
transfer.from_warehouse_id,
|
||||
transfer.material_id,
|
||||
transfer.brand_id,
|
||||
transfer.specs_hash,
|
||||
for_update=True,
|
||||
)
|
||||
if source_item is not None:
|
||||
source_item.quantity += transfer.quantity
|
||||
|
||||
# Delete out_transaction
|
||||
if transfer.out_transaction_id is not None:
|
||||
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if out_tx is not None:
|
||||
await db.delete(out_tx)
|
||||
|
||||
await db.delete(transfer)
|
||||
|
||||
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
|
||||
|
||||
|
||||
async def receive_project_transfer(
|
||||
db: AsyncSession,
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferReceive,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
received_by: str | None = None,
|
||||
) -> ProjectTransfer:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
transfer = await db.scalar(
|
||||
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||
)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||
|
||||
# RBAC (Requirement 4)
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if transfer.to_project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
|
||||
|
||||
# Lock Guard
|
||||
if transfer.status == "received":
|
||||
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
|
||||
if transfer.status != "in_transit":
|
||||
raise ConflictError("این انتقال در وضعیت در راه نیست")
|
||||
|
||||
# Requirement 1: 100% full delivery logic (no partial receipt)
|
||||
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
|
||||
if alloc_sum != transfer.quantity:
|
||||
raise ValidationError(
|
||||
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
|
||||
field="allocations",
|
||||
)
|
||||
|
||||
# Validate destination warehouses belong to destination project
|
||||
wh_ids = [a.warehouse_id for a in payload.allocations]
|
||||
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
|
||||
wh_map = {w.id: w for w in wh_rows}
|
||||
|
||||
for a in payload.allocations:
|
||||
wh = wh_map.get(a.warehouse_id)
|
||||
if wh is None or wh.project_id != transfer.to_project_id:
|
||||
raise ValidationError(
|
||||
"انبار انتخابشده معتبر نیست یا متعلق به پروژه مقصد نمیباشد",
|
||||
field="allocations.warehouse_id",
|
||||
)
|
||||
|
||||
from_proj = await db.get(Project, transfer.from_project_id)
|
||||
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
|
||||
|
||||
out_tx = (
|
||||
await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if transfer.out_transaction_id
|
||||
else None
|
||||
)
|
||||
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
|
||||
now_dt = payload.received_at or datetime.now(timezone.utc)
|
||||
|
||||
# Add stock to destination warehouse(s) via TRANSFER_IN
|
||||
for a in payload.allocations:
|
||||
dest_item = await _get_or_create_inventory(
|
||||
db,
|
||||
a.warehouse_id,
|
||||
transfer.material_id,
|
||||
transfer.brand_id,
|
||||
transfer.unit_id,
|
||||
transfer.specs,
|
||||
transfer.specs_hash,
|
||||
)
|
||||
dest_item.quantity += a.quantity
|
||||
|
||||
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
|
||||
if payload.note:
|
||||
note_text += f" — {payload.note}"
|
||||
|
||||
in_tx = InventoryTransaction(
|
||||
inventory_id=dest_item.id,
|
||||
warehouse_id=a.warehouse_id,
|
||||
transaction_type="TRANSFER_IN",
|
||||
quantity=a.quantity,
|
||||
transfer_pair_id=pair_id,
|
||||
performed_by=received_by,
|
||||
transaction_date=now_dt,
|
||||
note=note_text,
|
||||
)
|
||||
db.add(in_tx)
|
||||
|
||||
transfer.status = "received"
|
||||
transfer.received_at = now_dt
|
||||
transfer.received_by = received_by
|
||||
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
|
||||
transfer.receipt_allocations = [
|
||||
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
|
||||
for a in payload.allocations
|
||||
]
|
||||
|
||||
return await get_project_transfer_or_404(db, transfer_id)
|
||||
|
||||
|
||||
async def list_project_transfers(
|
||||
db: AsyncSession,
|
||||
project_id: uuid.UUID | None = None,
|
||||
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
|
||||
status: str | None = None,
|
||||
) -> list[ProjectTransfer]:
|
||||
stmt = (
|
||||
select(ProjectTransfer)
|
||||
.options(
|
||||
selectinload(ProjectTransfer.from_project),
|
||||
selectinload(ProjectTransfer.to_project),
|
||||
selectinload(ProjectTransfer.from_warehouse),
|
||||
selectinload(ProjectTransfer.to_warehouse),
|
||||
selectinload(ProjectTransfer.material),
|
||||
selectinload(ProjectTransfer.brand),
|
||||
selectinload(ProjectTransfer.unit),
|
||||
)
|
||||
)
|
||||
|
||||
if project_id is not None:
|
||||
if direction == "outgoing":
|
||||
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
|
||||
elif direction == "incoming":
|
||||
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
(ProjectTransfer.from_project_id == project_id)
|
||||
| (ProjectTransfer.to_project_id == project_id)
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(ProjectTransfer.status == status)
|
||||
|
||||
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
|
||||
res = await db.execute(stmt)
|
||||
return list(res.scalars().all())
|
||||
@@ -7,6 +7,7 @@ same transaction.
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
@@ -18,6 +19,7 @@ from app.models.procurement import (
|
||||
ItemTermsheet,
|
||||
LineItem,
|
||||
LineItemProjects,
|
||||
LineItemSpec,
|
||||
Project,
|
||||
Quote,
|
||||
QuoteEvent,
|
||||
@@ -418,6 +420,78 @@ async def replace_quote_children(
|
||||
|
||||
|
||||
|
||||
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
|
||||
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
|
||||
exists = await db.scalar(
|
||||
select(QuoteProjects).where(
|
||||
QuoteProjects.quote_id == quote_id,
|
||||
QuoteProjects.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not exists:
|
||||
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
|
||||
|
||||
|
||||
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
|
||||
"""Consolidate split line items when reverting a quote back to offered.
|
||||
|
||||
Line items sharing the exact same material, brand_material, unit, notes,
|
||||
and identical specs are merged into a single consolidated row whose quantity
|
||||
is the sum of all split rows. Secondary split rows are safely deleted.
|
||||
"""
|
||||
line_items = (
|
||||
await db.execute(
|
||||
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
if len(line_items) <= 1:
|
||||
return
|
||||
|
||||
# Load all specs for these line items to compare canonical signatures
|
||||
specs_rows = (
|
||||
await db.execute(
|
||||
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
|
||||
)
|
||||
).scalars().all()
|
||||
specs_by_li: dict[UUID, list[tuple]] = {}
|
||||
for sp in specs_rows:
|
||||
sig = (
|
||||
str(sp.attribute_definition_id),
|
||||
str(sp.value_number) if sp.value_number is not None else "",
|
||||
(sp.value_string or "").strip(),
|
||||
str(sp.value_boolean) if sp.value_boolean is not None else "",
|
||||
str(sp.value_date) if sp.value_date is not None else "",
|
||||
)
|
||||
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
|
||||
|
||||
# Group line items by identity
|
||||
groups: dict[tuple, list[LineItem]] = {}
|
||||
for li in line_items:
|
||||
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
|
||||
key = (
|
||||
li.material_id,
|
||||
li.brand_material_id,
|
||||
li.unit_id,
|
||||
(li.notes or "").strip(),
|
||||
specs_sig,
|
||||
)
|
||||
groups.setdefault(key, []).append(li)
|
||||
|
||||
for key, items in groups.items():
|
||||
if len(items) > 1:
|
||||
primary = items[0]
|
||||
total_qty = sum(Decimal(str(it.quantity)) for it in items)
|
||||
primary.quantity = total_qty
|
||||
|
||||
secondary_ids = [it.id for it in items[1:]]
|
||||
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||
current = quote.status
|
||||
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
||||
)
|
||||
.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!
|
||||
elif new_status == "accepted": # arrived -> accepted
|
||||
quote.arrived_date = None
|
||||
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
||||
)
|
||||
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(
|
||||
select(ItemTermsheet).where(
|
||||
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
||||
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
if decision.project_id is not None:
|
||||
if await db.get(Project, decision.project_id) is None:
|
||||
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)
|
||||
else: # rejected
|
||||
if decision is None:
|
||||
@@ -903,6 +1100,17 @@ async def quote_stats(
|
||||
*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 {
|
||||
"total": sum(counts.values()),
|
||||
"offered": counts["offered"],
|
||||
@@ -911,6 +1119,11 @@ async def quote_stats(
|
||||
"rejected": counts["rejected"],
|
||||
"expired": counts["expired"],
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
|
||||
|
||||
USER_PLAN = [
|
||||
# (casdoor username, first, last, role)
|
||||
("ali.mohaghegh", "علی", "محقق", "admin"),
|
||||
("razaei", "علی", "رضایی", "admin"),
|
||||
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
||||
("mohaghegh", "محمد", "محقق", "pm"),
|
||||
@@ -261,8 +262,11 @@ async def run() -> None:
|
||||
material_by_name: dict[str, Material] = {}
|
||||
brand_material_by_material: dict[str, object] = {}
|
||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||
cat = await get_category(db, cat_slug)
|
||||
material = Material(
|
||||
name=name,
|
||||
topic=cat.name,
|
||||
category_id=cat.id, # v2.8: dashboard Category→Material cascade
|
||||
canonical_unit_id=unit_ids[unit_code],
|
||||
status="active",
|
||||
)
|
||||
@@ -440,6 +444,86 @@ async def run() -> None:
|
||||
))
|
||||
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()
|
||||
|
||||
# summary
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
# پلیبوک اجرایی: افزودن موتور 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.
|
||||
@@ -1,256 +0,0 @@
|
||||
# گزارش جامع: اعتبارسنجی گزارشها و راهکار کرال موتور 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
|
||||
@@ -1,159 +0,0 @@
|
||||
# گزارش فنی و امکانسنجی: ادغام قابلیت صوت به متن (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`
|
||||
|
||||
---
|
||||
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
|
||||
@@ -1,137 +0,0 @@
|
||||
# گزارش فنی و امکانسنجی: ادغام قابلیت صوت به متن (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` سریعاً انجام میپذیرد.
|
||||
|
||||
---
|
||||
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
|
||||
@@ -1,499 +0,0 @@
|
||||
# سند جامع، تحلیلی و عملیاتی کرالر تبدیل صوتبهمتن (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
@@ -1,432 +0,0 @@
|
||||
# گزارش جامع: ارزیابی پروژههای متنباز و سرویسهای شخص ثالث، و ترکیب بهینه برای کرالر 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
|
||||
@@ -2,9 +2,6 @@
|
||||
.env
|
||||
*.local
|
||||
|
||||
# ---- Runtime data (voices etc.) — never commit, never delete ----
|
||||
data/
|
||||
|
||||
# ---- Python ----
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+4
-10
@@ -67,8 +67,8 @@ class ErpClient:
|
||||
|
||||
# ---------------------------------------------------------------- lookup
|
||||
|
||||
async def list_suppliers(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]:
|
||||
params: dict = {"page_size": page_size, "page": page}
|
||||
async def list_suppliers(self, q: str | None = None, page_size: int = 20) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
if q:
|
||||
params["q"] = q
|
||||
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
|
||||
@@ -76,8 +76,8 @@ class ErpClient:
|
||||
async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||
return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {}
|
||||
|
||||
async def list_materials(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]:
|
||||
params: dict = {"page_size": page_size, "page": page}
|
||||
async def list_materials(self, q: str | None = None, page_size: int = 20) -> list[dict]:
|
||||
params: dict = {"page_size": page_size}
|
||||
if q:
|
||||
params["q"] = q
|
||||
return (await self._request("GET", "/materials", params=params)).get("data") or []
|
||||
@@ -94,15 +94,9 @@ class ErpClient:
|
||||
async def list_projects(self, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
|
||||
|
||||
async def 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]:
|
||||
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
|
||||
|
||||
async def list_brands(self, page: int = 1, page_size: int = 100) -> list[dict]:
|
||||
return (await self._request("GET", "/brands", params={"page": page, "page_size": page_size})).get("data") or []
|
||||
|
||||
# ---------------------------------------------------------------- quotes
|
||||
|
||||
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
|
||||
|
||||
@@ -133,25 +133,6 @@ class Settings(BaseSettings):
|
||||
# Drafts older than this are marked expired by the sweeper loop.
|
||||
DRAFT_TTL_HOURS: int = 24
|
||||
|
||||
# Observability (phase 1) — silent pipeline recorder (DB only; no external
|
||||
# tools, no extra LLM tokens). Data is NEVER deleted.
|
||||
OBS_ENABLED: bool = True
|
||||
OBS_VOICE_DIR: str = "data/voices" # persistent volume (./bot/data) — keep forever
|
||||
OBS_PROMPT_VERSION: str = "" # manual label for the NLU prompt in use
|
||||
|
||||
# Jev (TypeSafe) entity matching — phases 2-5. Model version is PINNED
|
||||
# (phase 0 resolved jev-1.13.0) so upstream updates cannot silently change
|
||||
# decisions; every response's actual model is recorded for drift detection.
|
||||
TYPESAFE_API_KEY: str = ""
|
||||
JEV_MODEL: str = "jev-1.13.0"
|
||||
JEV_ENABLED: bool = True
|
||||
JEV_TIMEOUT: float = 8.0
|
||||
JEV_CONF_EXACT: float = 0.85
|
||||
JEV_CONF_FLOOR: float = 0.65 # phase-6 calibration (2026-10-12): 0.60 let one
|
||||
# wrong candidate (جعبه→بسته @0.61) through; the
|
||||
# correct picks it drops are covered by difflib.
|
||||
JEV_MAX_QUESTIONS_PER_FIELD: int = 15
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
@property
|
||||
|
||||
@@ -64,40 +64,6 @@ DDL = [
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
# ---- Observability (phase 1) — silent pipeline recorder, DB-only -------
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_traces (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id BIGINT NOT NULL,
|
||||
message_id BIGINT,
|
||||
kind VARCHAR(8) NOT NULL,
|
||||
source VARCHAR(16),
|
||||
draft_id UUID,
|
||||
draft_kind VARCHAR(16),
|
||||
committed_quote_id UUID,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_traces_chat ON bot_traces (chat_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_traces_draft ON bot_traces (draft_id)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_trace_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
trace_id UUID NOT NULL REFERENCES bot_traces(id) ON DELETE CASCADE,
|
||||
stage VARCHAR(32) NOT NULL,
|
||||
ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
latency_ms INTEGER,
|
||||
model VARCHAR(64),
|
||||
field VARCHAR(32),
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_trace ON bot_trace_events (trace_id, created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_stage ON bot_trace_events (stage)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_payload ON bot_trace_events USING GIN (payload jsonb_path_ops)",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""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,
|
||||
},
|
||||
}
|
||||
|
||||
+8
-154
@@ -16,7 +16,6 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import cast, func, select, String, update
|
||||
|
||||
from app import defaults
|
||||
from app.db import SessionLocal
|
||||
from app.models import BotDraft
|
||||
from app.nlu import normalize_text
|
||||
@@ -24,7 +23,6 @@ from app.nlu import normalize_text
|
||||
KIND_QUOTE = "quote"
|
||||
KIND_SUPPLIER = "supplier"
|
||||
KIND_MATERIAL = "material"
|
||||
KIND_PROJECT = "project"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -72,22 +70,6 @@ async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | 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:
|
||||
"""Resolve the short id used in callback_data (first 8 hex of the UUID)."""
|
||||
async with SessionLocal() as session:
|
||||
@@ -172,10 +154,9 @@ def new_quote_payload() -> dict:
|
||||
return {
|
||||
"supplier_name": None, "supplier_id": None,
|
||||
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
|
||||
"is_official": defaults.DEFAULT_IS_OFFICIAL, "notes": None, "expiry_date": None,
|
||||
"is_official": True, "notes": None, "expiry_date": None,
|
||||
"items": [], "termsheets": [],
|
||||
"options": {}, "proforma_file": None,
|
||||
"ambiguities": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -225,15 +206,11 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
||||
if not payload.get("project_names"):
|
||||
payload["project_names"] = [payload["project_name"]]
|
||||
changed = True
|
||||
set_field("is_official", patch.get("is_official", defaults.DEFAULT_IS_OFFICIAL))
|
||||
set_field("is_official", patch.get("is_official"))
|
||||
if patch.get("notes"):
|
||||
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
||||
changed = True
|
||||
if patch.get("expiry_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"):
|
||||
if patch.get("expiry_date"):
|
||||
set_field("expiry_date", patch["expiry_date"])
|
||||
|
||||
for raw in patch.get("items") or []:
|
||||
@@ -242,8 +219,10 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
||||
if name:
|
||||
best, best_ratio = None, 0.0
|
||||
for item in payload["items"]:
|
||||
# If both items have distinct specs, they are different line items
|
||||
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
|
||||
continue
|
||||
# If both items have distinct brands, they are different line items
|
||||
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
|
||||
continue
|
||||
ratio = _similarity(name, item.get("material_name"))
|
||||
@@ -270,78 +249,15 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
||||
if spec_str and not target.get("notes"):
|
||||
target["notes"] = spec_str
|
||||
changed = True
|
||||
for key in ("unit_name", "brand_name", "project_name", "notes"):
|
||||
for key in ("unit_name", "brand_name", "notes"):
|
||||
if raw.get(key):
|
||||
val = str(raw[key]).strip()
|
||||
if target.get(key) != val:
|
||||
target[key] = val
|
||||
changed = True
|
||||
|
||||
# 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 []:
|
||||
p_type = raw.get("type") or ""
|
||||
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 = []
|
||||
for m in raw.get("milestones") or []:
|
||||
if m.get("due_date") or m.get("amount"):
|
||||
@@ -357,43 +273,10 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
||||
"milestones": milestones,
|
||||
"description": (raw.get("description") or "").strip() or None,
|
||||
}
|
||||
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:
|
||||
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
|
||||
payload["termsheets"].append(entry)
|
||||
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
|
||||
|
||||
|
||||
@@ -409,13 +292,7 @@ def compute_quote_missing(payload: dict) -> list[dict]:
|
||||
add("supplier", "تأمینکننده", sup_ok, True)
|
||||
|
||||
# In DB: project_id is nullable (optional)
|
||||
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)
|
||||
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
|
||||
|
||||
# In DB: expiry_date is nullable (optional)
|
||||
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
|
||||
@@ -601,28 +478,5 @@ 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:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
+27
-177
@@ -11,7 +11,6 @@ Pipeline per update:
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.config import settings
|
||||
from app.nlu import understand
|
||||
from app.runtime import BotContext, LinkedUser
|
||||
@@ -19,7 +18,7 @@ from app.storage import save_proforma_file
|
||||
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 . import commands, callbacks, quote, supplier, material, project, queries, decisions
|
||||
from . import commands, callbacks, quote, supplier, material, queries, decisions
|
||||
|
||||
logger = logging.getLogger("bot.handlers")
|
||||
|
||||
@@ -40,25 +39,11 @@ async def handle_update(ctx: BotContext, update: 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()
|
||||
source = "text"
|
||||
voice_transcripts: list[dict] | None = None
|
||||
message_id = message.get("message_id")
|
||||
|
||||
# Observability (phase 1): open a trace for every incoming message.
|
||||
_doc = message.get("document") or {}
|
||||
_audio_like = bool(
|
||||
message.get("voice") or message.get("audio")
|
||||
or (_doc.get("mime_type") or "").lower().startswith("audio/")
|
||||
)
|
||||
trace_id = await obs.trace_start(chat_id, message_id, "voice" if _audio_like else "text")
|
||||
if text:
|
||||
await obs.event(trace_id, "input.text", payload={"text": text[:500]})
|
||||
|
||||
if text.startswith("/"):
|
||||
await commands.handle_command(ctx, chat_id, text)
|
||||
return
|
||||
@@ -71,6 +56,10 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
return
|
||||
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through
|
||||
|
||||
user = await _gate(ctx, chat_id, message)
|
||||
if user is None:
|
||||
return # gate either started linking or is mid-linking
|
||||
|
||||
reply_to = message.get("reply_to_message")
|
||||
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
|
||||
|
||||
@@ -90,20 +79,11 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
file_id = audio_media["file_id"]
|
||||
try:
|
||||
audio_bytes = await ctx.bale.download_file(file_id)
|
||||
voice_path = obs.save_voice(trace_id, message_id or 0, audio_bytes)
|
||||
await obs.event(trace_id, "input.voice", payload={
|
||||
"path": voice_path,
|
||||
"size": len(audio_bytes),
|
||||
"mime": audio_media.get("mime_type"),
|
||||
"file_id": file_id,
|
||||
"duration": audio_media.get("duration"),
|
||||
})
|
||||
transcripts = await transcribe_multi(
|
||||
settings,
|
||||
audio_bytes,
|
||||
mime_type=audio_media.get("mime_type"),
|
||||
filename=audio_media.get("file_name"),
|
||||
trace_id=trace_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
|
||||
@@ -118,12 +98,6 @@ 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
|
||||
# =========================================================================
|
||||
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)
|
||||
if target_draft is None:
|
||||
await ctx.send(
|
||||
@@ -221,23 +195,9 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
|
||||
draft_summary = _draft_summary(target_draft) if target_draft else None
|
||||
input_payload = voice_transcripts if voice_transcripts else text
|
||||
result = await understand(settings, input_payload, draft_summary, trace_id=trace_id)
|
||||
result = await understand(settings, input_payload, draft_summary)
|
||||
intent, patch = result.get("intent"), result.get("patch") or {}
|
||||
consensus_text = result.get("consensus_text") or text
|
||||
mentions = result.get("entity_mentions") or []
|
||||
await obs.event(trace_id, "entities.extracted",
|
||||
payload={"patch": patch, "intent": intent, "entity_mentions": mentions})
|
||||
|
||||
# Phase 3: Jev per-field matching — only when it can be applied (flag-gated;
|
||||
# a failure leaves jev_map empty and the difflib path runs unchanged).
|
||||
jev_map: dict = {}
|
||||
if settings.JEV_ENABLED and mentions and intent in ("register_quote", "update_draft", "add_material"):
|
||||
try:
|
||||
from app import matching
|
||||
jev_map = await matching.resolve_mentions(ctx, patch, mentions, trace_id)
|
||||
except Exception:
|
||||
logger.exception("jev matching failed")
|
||||
jev_map = {}
|
||||
|
||||
if source == "voice":
|
||||
await ctx._log(chat_id, "in", "voice", consensus_text)
|
||||
@@ -250,28 +210,21 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
ctx, chat_id, user, patch, source,
|
||||
incoming_msg_id=message_id,
|
||||
target_draft=target_draft,
|
||||
trace_id=trace_id,
|
||||
jev_map=jev_map,
|
||||
)
|
||||
elif intent == "commit_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active is None:
|
||||
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
|
||||
elif active.kind == draft_store.KIND_QUOTE:
|
||||
await quote.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
await quote.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_SUPPLIER:
|
||||
await supplier.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
await supplier.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
await material.commit(ctx, chat_id, user, active)
|
||||
elif intent == "discard_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active:
|
||||
await draft_store.set_status(active.id, "cancelled")
|
||||
await obs.event(trace_id, "draft.cancel",
|
||||
payload={"draft_id": str(active.id), "kind": active.kind})
|
||||
await obs.set_trace_status(trace_id, "cancelled")
|
||||
if active.review_message_id:
|
||||
try:
|
||||
await ctx.delete(chat_id, active.review_message_id)
|
||||
@@ -289,14 +242,10 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
await supplier.render(ctx, chat_id, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
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":
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "add_material":
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id, jev_map=jev_map)
|
||||
elif intent == "add_project":
|
||||
await project.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "status_query":
|
||||
await queries.handle_query(ctx, chat_id, user, patch)
|
||||
elif intent == "accept_quote":
|
||||
@@ -311,58 +260,25 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
# 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:
|
||||
"""Whitelist gate (Casdoor bypassed). Unregistered users are blocked and logged."""
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
"""Link or admin gate. Returns None when the message was consumed by linking."""
|
||||
from . import links
|
||||
|
||||
# 1. Whitelist check (only registered whitelist IDs)
|
||||
if chat_id in AUTHORIZED_USERS:
|
||||
u = AUTHORIZED_USERS[chat_id]
|
||||
return LinkedUser(
|
||||
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),
|
||||
)
|
||||
link = await ctx.links.get(chat_id)
|
||||
if link and link.user_id:
|
||||
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
||||
|
||||
# 2. Block unregistered user and record log
|
||||
_record_unregistered_user(chat_id, message)
|
||||
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
||||
flow = ctx.pending.get(chat_id)
|
||||
if flow and flow.kind == "link_password":
|
||||
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
|
||||
return None
|
||||
if flow and flow.kind == "link_username":
|
||||
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
|
||||
return None
|
||||
await ctx.send(chat_id, LINK_START)
|
||||
ctx.pending.set(chat_id, "link_username", {})
|
||||
return None
|
||||
|
||||
|
||||
@@ -407,69 +323,3 @@ def _draft_summary(draft) -> str | None:
|
||||
else:
|
||||
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
||||
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)."""
|
||||
trace_id = await obs.latest_trace_for_draft(draft.id)
|
||||
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"])
|
||||
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes)
|
||||
text = await transcribe(settings, audio_bytes, trace_id=trace_id)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if text:
|
||||
await obs.event(trace_id, "ambiguity.user_choice", payload={
|
||||
"amb_id": amb.get("id"), "action": "reply", "kind": amb.get("kind"),
|
||||
"reply_text": text[:500],
|
||||
})
|
||||
draft_summary = _draft_summary(draft)
|
||||
result = await understand(settings, text, draft_summary, trace_id=trace_id)
|
||||
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, trace_id)
|
||||
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, trace_id=trace_id)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
+11
-109
@@ -6,11 +6,10 @@ Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.runtime import LinkedUser
|
||||
from app.texts import CANCELLED, UNLINKED, esc
|
||||
|
||||
from . import quote, supplier, material, project, decisions
|
||||
from . import quote, supplier, material, decisions
|
||||
|
||||
logger = logging.getLogger("bot.callbacks")
|
||||
|
||||
@@ -30,18 +29,12 @@ async def handle_callback(ctx, cq: dict) -> None:
|
||||
|
||||
|
||||
async def _gate(ctx, chat_id: int) -> LinkedUser | None:
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
if chat_id in AUTHORIZED_USERS:
|
||||
u = AUTHORIZED_USERS[chat_id]
|
||||
return LinkedUser(
|
||||
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, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
||||
link = await ctx.links.get(chat_id)
|
||||
if link and link.user_id:
|
||||
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
||||
await ctx.send(chat_id, UNLINKED)
|
||||
return None
|
||||
|
||||
|
||||
@@ -64,85 +57,6 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
|
||||
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
|
||||
|
||||
tid = await obs.latest_trace_for_draft(draft.id)
|
||||
await obs.event(tid, "ambiguity.user_choice", payload={
|
||||
"amb_id": amb_id, "action": action, "kind": target_amb.get("kind"),
|
||||
"title": target_amb.get("title"),
|
||||
"arg": parts[4] if len(parts) > 4 else None,
|
||||
"candidates": target_amb.get("candidates"),
|
||||
})
|
||||
|
||||
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
|
||||
elif action == "pick" and len(parts) >= 5:
|
||||
# Phase 4: user picked one of the Jev candidates.
|
||||
try:
|
||||
idx = int(parts[4])
|
||||
except ValueError:
|
||||
return
|
||||
cands = target_amb.get("candidates") or []
|
||||
if not (0 <= idx < len(cands)):
|
||||
return
|
||||
from app import matching
|
||||
matching.apply_choice(payload, target_amb.get("field"), cands[idx],
|
||||
target_amb.get("variants") or [])
|
||||
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 == "new":
|
||||
# «هیچکدام» — keep the raw name; the auto-create path handles it at commit.
|
||||
target_amb["status"] = "rejected"
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
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 -------------------------------
|
||||
if parts[0] == "pk" and len(parts) == 4:
|
||||
draft = await draft_store.get_by_prefix(parts[1], chat_id)
|
||||
@@ -154,22 +68,14 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
|
||||
# ---- draft commit / cancel --------------------------------------------
|
||||
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
|
||||
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,
|
||||
"p": draft_store.KIND_PROJECT,
|
||||
}[op]
|
||||
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
|
||||
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
|
||||
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
||||
if draft is None or draft.kind != kind:
|
||||
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
||||
return
|
||||
if arg == "no":
|
||||
await draft_store.set_status(draft.id, "cancelled")
|
||||
tid = await obs.latest_trace_for_draft(draft.id)
|
||||
await obs.event(tid, "draft.cancel", payload={"draft_id": str(draft.id), "kind": draft.kind})
|
||||
await obs.set_trace_status(tid, "cancelled")
|
||||
ctx.pending.pop(chat_id, None)
|
||||
if draft.review_message_id:
|
||||
try:
|
||||
@@ -183,10 +89,8 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
|
||||
elif kind == draft_store.KIND_SUPPLIER:
|
||||
await supplier.commit(ctx, chat_id, user, draft)
|
||||
elif kind == draft_store.KIND_MATERIAL:
|
||||
else:
|
||||
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:
|
||||
@@ -195,10 +99,8 @@ async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, id
|
||||
await ctx.send(chat_id, "گزینه نامعتبر است.")
|
||||
return
|
||||
choice = options[idx]
|
||||
tid = await obs.latest_trace_for_draft(draft.id)
|
||||
await obs.event(tid, "user_choice.pick", payload={"field": field, "idx": idx, "choice": choice})
|
||||
if draft.kind == draft_store.KIND_QUOTE:
|
||||
await quote.apply_pick(ctx, chat_id, user, draft, field, choice, trace_id=tid)
|
||||
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
|
||||
elif draft.kind == draft_store.KIND_SUPPLIER:
|
||||
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
|
||||
elif draft.kind == draft_store.KIND_MATERIAL:
|
||||
|
||||
@@ -31,13 +31,10 @@ async def handle_command(ctx, chat_id: int, text: str) -> None:
|
||||
|
||||
|
||||
async def _start(ctx, chat_id: int) -> None:
|
||||
from app.defaults import AUTHORIZED_USERS
|
||||
|
||||
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)
|
||||
if not link or not link.user_id:
|
||||
if chat_id not in ctx.admin_chat_ids:
|
||||
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
|
||||
ctx.pending.set(chat_id, "link_username", {})
|
||||
|
||||
@@ -74,13 +71,6 @@ async def _cancel(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)
|
||||
if chat_id in ctx.admin_chat_ids:
|
||||
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import matching
|
||||
from app import observability as obs
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
|
||||
@@ -15,9 +13,7 @@ from app.texts import ERR_GENERIC, esc, material_committed_report, material_revi
|
||||
logger = logging.getLogger("bot.material")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
|
||||
trace_id: str | None = None,
|
||||
jev_map: dict | None = None) -> None:
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
@@ -26,29 +22,15 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
|
||||
payload = new_material_payload()
|
||||
merge_material_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
|
||||
await obs.link_draft(trace_id, draft.id, draft.kind)
|
||||
await resolve_payload(ctx, payload, trace_id, jev_map)
|
||||
await resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
|
||||
|
||||
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
jev_map: dict | None = None) -> None:
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
|
||||
hit = matching.lookup(jev_map, payload["unit_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
payload["canonical_unit_id"] = hit["id"]
|
||||
payload["unit_name"] = hit["name"]
|
||||
options.pop("unit", None)
|
||||
elif hit["status"] == "ambiguity" and hit.get("candidates"):
|
||||
options["unit"] = [{"id": c["id"], "name": c["name"]}
|
||||
for c in hit["candidates"][:6]]
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
|
||||
await obs.log_resolution(trace_id, "unit", payload["unit_name"], res.status,
|
||||
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
|
||||
if res.resolved:
|
||||
payload["canonical_unit_id"] = res.candidates[0].id
|
||||
payload["unit_name"] = res.candidates[0].name
|
||||
@@ -76,7 +58,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
payload = draft.payload
|
||||
missing = material_missing(payload)
|
||||
text = material_review_text(payload, missing)
|
||||
@@ -89,14 +71,11 @@ async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None =
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
|
||||
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
payload = draft.payload
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
await resolve_payload(ctx, payload)
|
||||
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
|
||||
problems = [label for key, label, ok in [
|
||||
("name", "نام کالا", missing.get("name")),
|
||||
@@ -115,8 +94,5 @@ async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
await obs.event(trace_id, "draft.commit", payload={"kind": "material",
|
||||
"material_id": (created or {}).get("id")})
|
||||
await obs.set_trace_status(trace_id, "committed")
|
||||
ctx.resolver.invalidate()
|
||||
ctx.resolver.invalidate("materials")
|
||||
await ctx.send(chat_id, material_committed_report(created))
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Add-project flow: name + optional description."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
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,
|
||||
trace_id: str | None = 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 obs.link_draft(trace_id, draft.id, draft.kind)
|
||||
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
|
||||
|
||||
|
||||
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, trace_id: str | None = None) -> 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})
|
||||
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
|
||||
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
|
||||
payload = draft.payload
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
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")
|
||||
await obs.event(trace_id, "draft.commit", payload={"kind": "project",
|
||||
"project_id": (created or {}).get("id")})
|
||||
await obs.set_trace_status(trace_id, "committed")
|
||||
ctx.resolver.invalidate()
|
||||
await ctx.send(chat_id, project_committed_report(created))
|
||||
+16
-195
@@ -2,8 +2,6 @@
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import matching
|
||||
from app import observability as obs
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
|
||||
@@ -22,25 +20,12 @@ async def handle_entities(
|
||||
ctx, chat_id, user, patch: dict, source: str | None,
|
||||
incoming_msg_id: int | None = None,
|
||||
target_draft = None,
|
||||
trace_id: str | None = None,
|
||||
jev_map: dict | None = None,
|
||||
) -> None:
|
||||
prev_review_msg_id = None
|
||||
if target_draft is not None:
|
||||
draft = target_draft
|
||||
prev_review_msg_id = draft.review_message_id
|
||||
draft.review_message_id = None
|
||||
|
||||
# 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
|
||||
merge_quote_patch(payload, patch)
|
||||
draft.source_message_id = incoming_msg_id
|
||||
@@ -51,11 +36,7 @@ async def handle_entities(
|
||||
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
|
||||
)
|
||||
|
||||
await obs.link_draft(trace_id, draft.id, draft.kind)
|
||||
await resolve_payload(ctx, payload, trace_id, jev_map)
|
||||
if jev_map:
|
||||
matching.normalize_brand_spec(payload, jev_map)
|
||||
matching.create_ambiguities(payload, jev_map)
|
||||
await resolve_payload(ctx, payload)
|
||||
|
||||
if prev_review_msg_id:
|
||||
try:
|
||||
@@ -63,32 +44,18 @@ async def handle_entities(
|
||||
except Exception as e:
|
||||
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
|
||||
|
||||
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id, trace_id=trace_id)
|
||||
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _log_res(trace_id, field: str, mention, res) -> None:
|
||||
"""Observability: record one difflib resolution attempt (baseline for phase 6)."""
|
||||
await obs.log_resolution(trace_id, field, mention, res.status,
|
||||
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
|
||||
|
||||
|
||||
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
jev_map: dict | None = None) -> None:
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
hit = matching.lookup(jev_map, payload["supplier_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
options.pop("sup", None)
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
|
||||
await _log_res(trace_id, "supplier", payload["supplier_name"], res)
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
payload["supplier_id"] = c.id
|
||||
@@ -103,24 +70,11 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
else:
|
||||
options.pop("sup", None)
|
||||
|
||||
resolved_projects = payload.setdefault("resolved_projects", {})
|
||||
|
||||
if not payload.get("project_id") and payload.get("project_name"):
|
||||
hit = matching.lookup(jev_map, payload["project_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
payload["project_name"] = hit["name"]
|
||||
options.pop("prj", None)
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||
await _log_res(trace_id, "project", payload["project_name"], res)
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
payload["project_id"] = c.id
|
||||
payload["project_name"] = c.name
|
||||
resolved_projects[c.name] = c.id
|
||||
resolved_projects[payload["project_name"]] = c.id
|
||||
payload["project_id"] = res.candidates[0].id
|
||||
payload["project_name"] = res.candidates[0].name
|
||||
if payload["project_id"] not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(payload["project_id"])
|
||||
options.pop("prj", None)
|
||||
@@ -130,18 +84,9 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
options.pop("prj", None)
|
||||
|
||||
for p_name in payload.get("project_names") or []:
|
||||
hit = matching.lookup(jev_map, p_name)
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
continue
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
await _log_res(trace_id, "project", p_name, res)
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
p_id = c.id
|
||||
resolved_projects[c.name] = p_id
|
||||
resolved_projects[p_name] = p_id
|
||||
p_id = res.candidates[0].id
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
if not payload.get("project_id"):
|
||||
@@ -149,14 +94,7 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name"):
|
||||
hit = matching.lookup(jev_map, item["material_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
options.pop(f"mat{i}", None)
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
|
||||
await _log_res(trace_id, "material", item["material_name"], res)
|
||||
if res.resolved:
|
||||
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
|
||||
options.pop(f"mat{i}", None)
|
||||
@@ -165,7 +103,7 @@ async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
else:
|
||||
options.pop(f"mat{i}", None)
|
||||
if not item.get("unit_id"):
|
||||
await _resolve_unit(ctx, item, trace_id, jev_map)
|
||||
await _resolve_unit(ctx, item)
|
||||
|
||||
|
||||
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
|
||||
@@ -173,21 +111,11 @@ def _set_material(item: dict, material_id: str, name: str, extra: dict | None =
|
||||
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
|
||||
|
||||
|
||||
async def _resolve_unit(ctx, item: dict, trace_id: str | None = None,
|
||||
jev_map: dict | None = None) -> None:
|
||||
async def _resolve_unit(ctx, item: dict) -> None:
|
||||
if item.get("unit_id"):
|
||||
return
|
||||
if item.get("unit_name"):
|
||||
hit = matching.lookup(jev_map, item["unit_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
item["unit_id"] = hit["id"]
|
||||
item["unit_explicit"] = True
|
||||
item["unit_name"] = hit["name"]
|
||||
return
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
|
||||
await _log_res(trace_id, "unit", item["unit_name"], res)
|
||||
if res.resolved:
|
||||
item["unit_id"] = res.candidates[0].id
|
||||
item["unit_explicit"] = True
|
||||
@@ -199,10 +127,8 @@ async def _resolve_unit(ctx, item: dict, trace_id: str | None = None,
|
||||
item["unit_explicit"] = False
|
||||
|
||||
|
||||
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict,
|
||||
trace_id: str | None = None) -> None:
|
||||
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||
payload = draft.payload
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
options = payload.setdefault("options", {})
|
||||
options.pop(field, None)
|
||||
|
||||
@@ -218,7 +144,7 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict,
|
||||
if 0 <= index < len(items):
|
||||
items[index]["material_name"] = choice["name"]
|
||||
_set_material(items[index], choice["id"], choice["name"])
|
||||
await _resolve_unit(ctx, items[index], trace_id)
|
||||
await _resolve_unit(ctx, items[index])
|
||||
elif field.startswith("unit"):
|
||||
index = int(field[4:]) - 1
|
||||
items = payload.get("items") or []
|
||||
@@ -227,9 +153,9 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict,
|
||||
items[index]["unit_explicit"] = True
|
||||
items[index]["unit_name"] = choice["name"]
|
||||
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
await resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=False, trace_id=trace_id)
|
||||
await render(ctx, chat_id, draft, fresh=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -269,7 +195,7 @@ def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> di
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
|
||||
reply_to: int | None = None, trace_id: str | None = None) -> None:
|
||||
reply_to: int | None = None) -> None:
|
||||
from app.config import settings
|
||||
|
||||
from app.security import create_draft_token
|
||||
@@ -305,9 +231,6 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
||||
source_message_id=reply_to or draft.source_message_id,
|
||||
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:
|
||||
if not draft.review_message_id:
|
||||
return
|
||||
@@ -318,56 +241,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
||||
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
|
||||
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
|
||||
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
|
||||
|
||||
|
||||
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
|
||||
trace_id = await obs.latest_trace_for_draft(draft.id)
|
||||
|
||||
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
|
||||
await obs.event(trace_id, "ambiguity.sent", payload={
|
||||
"amb_id": amb.get("id"), "title": amb.get("title"),
|
||||
"kind": amb.get("kind"), "message_id": sent["message_id"],
|
||||
})
|
||||
|
||||
if updated:
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
|
||||
trace_id: str | None = None) -> None:
|
||||
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
|
||||
payload = draft.payload
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
|
||||
newly_created: dict = {}
|
||||
await resolve_payload(ctx, payload)
|
||||
|
||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
@@ -386,13 +267,11 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
|
||||
sup_body["phone"] = payload["supplier_phone"]
|
||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||
payload["supplier_id"] = created_sup["id"]
|
||||
newly_created["supplier"] = payload["supplier_name"]
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create supplier: %s", e)
|
||||
|
||||
# 2. Sequential pre-creation for new materials
|
||||
created_mats = []
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||
try:
|
||||
@@ -401,60 +280,9 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
|
||||
"canonical_unit_id": int(item["unit_id"]),
|
||||
}, actor_id=user.actor_id)
|
||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||
created_mats.append(item["material_name"])
|
||||
ctx.resolver.invalidate("materials")
|
||||
except Exception as 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)
|
||||
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
||||
@@ -483,13 +311,6 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
|
||||
ctx.resolver.invalidate()
|
||||
|
||||
quote_id = (created or {}).get("id") or ""
|
||||
await obs.event(trace_id, "draft.commit", payload={
|
||||
"quote_id": quote_id,
|
||||
"items": len(body.get("line_items") or []),
|
||||
"newly_created": newly_created,
|
||||
"only_resolved": only_resolved,
|
||||
})
|
||||
await obs.set_trace_status(trace_id, "committed", quote_id)
|
||||
# If a proforma file was attached, persist it to the created ERP quote
|
||||
proforma_info = payload.get("proforma")
|
||||
if quote_id and proforma_info and proforma_info.get("saved_path"):
|
||||
@@ -525,4 +346,4 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
|
||||
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
||||
if dropped > 0:
|
||||
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining, newly_created=newly_created))
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
|
||||
@@ -14,8 +13,7 @@ from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_revi
|
||||
logger = logging.getLogger("bot.supplier")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
|
||||
trace_id: str | None = None) -> None:
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
@@ -24,18 +22,15 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
|
||||
payload = new_supplier_payload()
|
||||
merge_supplier_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
|
||||
await obs.link_draft(trace_id, draft.id, draft.kind)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
await resolve_payload(ctx, payload)
|
||||
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
|
||||
|
||||
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> None:
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
if not payload.get("category_id") and payload.get("category_name"):
|
||||
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
|
||||
await obs.log_resolution(trace_id, "category", payload["category_name"], res.status,
|
||||
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
|
||||
if res.resolved:
|
||||
payload["category_id"] = res.candidates[0].id
|
||||
payload["category_name"] = res.candidates[0].name
|
||||
@@ -63,7 +58,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
payload = draft.payload
|
||||
missing = supplier_missing(payload)
|
||||
text = supplier_review_text(payload, missing)
|
||||
@@ -76,14 +71,11 @@ async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None =
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
|
||||
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
payload = draft.payload
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
await resolve_payload(ctx, payload)
|
||||
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
|
||||
problems = [label for key, label, ok in [
|
||||
("name", "نام تأمینکننده", missing.get("name")),
|
||||
@@ -107,8 +99,5 @@ async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
await obs.event(trace_id, "draft.commit", payload={"kind": "supplier",
|
||||
"supplier_id": (created or {}).get("id")})
|
||||
await obs.set_trace_status(trace_id, "committed")
|
||||
ctx.resolver.invalidate()
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
await ctx.send(chat_id, supplier_committed_report(created))
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Jev (TypeSafe) client — structured `choice` matching over HTTP (phases 2-5).
|
||||
|
||||
Contract (docs.typesafe.ai): POST /v1/systemone with
|
||||
{"state": ..., "model": <pinned>, "questions": {key: {type, instructions, criteria}}}
|
||||
→ {"answers": {key: {choice, confidence, probabilities}}, "usage": {...}, "model": "..."}
|
||||
|
||||
Design rules (final plan v5):
|
||||
- The model version is PINNED (JEV_MODEL=jev-1.13.0 from phase 0); a response
|
||||
whose `model` differs logs a drift warning and is still returned (recorded).
|
||||
- Fail-soft: any error/timeout → None. Callers fall back per field to the
|
||||
existing difflib path; with JEV_ENABLED=false nothing here is called at all.
|
||||
- One retry only for 429/5xx (after a short backoff), then give up.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("bot.jev")
|
||||
|
||||
_URL = "https://api.typesafe.ai/v1/systemone"
|
||||
|
||||
|
||||
async def ask(state: dict, questions: dict, *, timeout: float | None = None) -> dict | None:
|
||||
"""One systemone request. Returns {"answers", "usage", "model", "latency_ms"} or None."""
|
||||
if not settings.TYPESAFE_API_KEY:
|
||||
logger.warning("jev: TYPESAFE_API_KEY is not configured")
|
||||
return None
|
||||
|
||||
payload = {"state": state, "model": settings.JEV_MODEL, "questions": questions}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.TYPESAFE_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
t0 = time.perf_counter()
|
||||
for attempt in (1, 2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout or settings.JEV_TIMEOUT) as client:
|
||||
resp = await client.post(_URL, json=payload, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
body = resp.json() or {}
|
||||
actual = str(body.get("model") or "")
|
||||
if actual and actual != settings.JEV_MODEL:
|
||||
logger.warning("jev: model drift — got %s, pinned %s", actual, settings.JEV_MODEL)
|
||||
return {
|
||||
"answers": body.get("answers") or {},
|
||||
"usage": body.get("usage") or {},
|
||||
"model": actual or settings.JEV_MODEL,
|
||||
"latency_ms": int((time.perf_counter() - t0) * 1000),
|
||||
}
|
||||
if resp.status_code in (429, 500, 502, 503, 504) and attempt == 1:
|
||||
logger.warning("jev: HTTP %s — retrying once", resp.status_code)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
logger.error("jev: HTTP %s: %s", resp.status_code, resp.text[:300])
|
||||
return None
|
||||
except Exception as exc:
|
||||
if attempt == 1:
|
||||
logger.warning("jev: request failed (%s) — retrying once", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
logger.error("jev: request failed: %s", exc)
|
||||
return None
|
||||
return None
|
||||
+1
-20
@@ -22,7 +22,7 @@ def _btn(text: str, data: str) -> dict:
|
||||
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
|
||||
|
||||
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
|
||||
op = {"quote": "q", "supplier": "s", "material": "m", "project": "p"}.get(kind, "q")
|
||||
op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q")
|
||||
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
|
||||
if has_partial_option:
|
||||
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
|
||||
@@ -72,22 +72,3 @@ def arrive_keyboard() -> dict:
|
||||
|
||||
def cancel_keyboard() -> dict:
|
||||
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}")],
|
||||
])
|
||||
|
||||
|
||||
def ambiguity_candidates_keyboard(prefix: str, amb_id: str, candidates: list[dict]) -> dict:
|
||||
"""Phase 4: one button per Jev candidate (+ 'new') for an entity-match ambiguity."""
|
||||
rows = [
|
||||
[_btn(f"✅ {str(c.get('name') or '')[:40]}", f"amb:pick:{prefix}:{amb_id}:{i}")]
|
||||
for i, c in enumerate(candidates[:5])
|
||||
]
|
||||
rows.append([_btn("🆕 هیچکدام — ایجاد مورد جدید", f"amb:new:{prefix}:{amb_id}")])
|
||||
return _kb(rows)
|
||||
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"""Entity matching with Jev (TypeSafe) — phases 3-5 of the final plan.
|
||||
|
||||
Mechanics (final plan v5 §2):
|
||||
- برونگروهی: ONE Jev request per FIELD that has mentions (requests run in
|
||||
parallel; latency ≈ one round-trip regardless of field count).
|
||||
- درونگروهی: inside a field request, ONE `choice` question per mention; every
|
||||
question rides with the SAME full candidate list (+ a `_none` option).
|
||||
- درونواریانسی: all STT spellings of one entity travel in ONE question, and
|
||||
the instructions ask the model to treat them as one name.
|
||||
|
||||
Routing (thresholds from env; calibrated in phase 6):
|
||||
conf >= JEV_CONF_EXACT → exact (bind id, no ambiguity sub-message)
|
||||
JEV_CONF_FLOOR..JEV_CONF_EXACT → ambiguity (candidate buttons sub-message)
|
||||
below floor / _none → new (existing auto-create path)
|
||||
|
||||
Fail-soft: a failed field simply yields no results for its mentions; callers
|
||||
fall back to the difflib path (`safe_resolve`). With JEV_ENABLED=false nothing
|
||||
in this module runs (zero regression).
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import settings
|
||||
from app.jev import ask as jev_ask
|
||||
from app.nlu import normalize_text
|
||||
|
||||
logger = logging.getLogger("bot.matching")
|
||||
|
||||
FIELD_ORDER = ["project", "supplier", "material", "brand", "spec", "unit"]
|
||||
|
||||
FIELD_FA = {
|
||||
"project": "پروژه",
|
||||
"supplier": "تأمینکننده",
|
||||
"material": "متریال",
|
||||
"brand": "برند",
|
||||
"spec": "مشخصه",
|
||||
"unit": "واحد",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# candidate lists (full lists — no prefilter, per owner decision)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _candidates(resolver, field: str) -> list:
|
||||
if field == "project":
|
||||
return await resolver.projects()
|
||||
if field == "supplier":
|
||||
return await resolver.all_suppliers()
|
||||
if field == "material":
|
||||
return await resolver.all_materials()
|
||||
if field == "brand":
|
||||
return await resolver.brands()
|
||||
if field == "spec":
|
||||
return await resolver.specs()
|
||||
if field == "unit":
|
||||
return await resolver.units()
|
||||
return []
|
||||
|
||||
|
||||
def _hint(field: str, cand) -> str:
|
||||
"""Short, token-cheap description shown next to a candidate name."""
|
||||
if field == "supplier":
|
||||
extra = cand.extra or {}
|
||||
bits = [b for b in (extra.get("category_name"), extra.get("location")) if b]
|
||||
return "، ".join(str(b) for b in bits) if bits else "تأمینکننده"
|
||||
return ""
|
||||
|
||||
|
||||
def _instructions(field: str, variants: list[str]) -> str:
|
||||
fa = FIELD_FA.get(field, field)
|
||||
vlist = "»، «".join(v for v in variants if v)
|
||||
if len(variants) > 1:
|
||||
return (
|
||||
f"کاربر در پیام (صوتی) خود این {fa} را ذکر کرده و مدلهای مختلف STT آن را به شکلهای "
|
||||
f"«{vlist}» پیاده کردهاند؛ اینها چند نگارش از یک نام واحدند. با توجه به بافت جمله و "
|
||||
f"تشابه آوایی میان این واریانتها، مشخص کن به کدام گزینه از فهرست اشاره دارد. "
|
||||
f"اگر هیچکدام منطبق نیست یا تردید جدی داری «_none» را انتخاب کن."
|
||||
)
|
||||
return (
|
||||
f"کاربر «{vlist}» را بهعنوان {fa} ذکر کرده است. این عبارت به کدام گزینه از فهرست اشاره دارد؟ "
|
||||
f"اگر هیچکدام منطبق نیست یا تردید جدی داری «_none» را انتخاب کن."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the per-field fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) -> dict:
|
||||
"""Run Jev per field (parallel); return a map keyed by normalized variant.
|
||||
|
||||
Each value: {"field", "status": exact|ambiguity|new", "variants",
|
||||
"id"?, "name"?, "extra"?, "confidence"?, "candidates": [...]}
|
||||
"""
|
||||
by_field: dict[str, list[dict]] = {}
|
||||
for m in mentions or []:
|
||||
field = str((m or {}).get("field") or "").strip()
|
||||
variants = [str(v).strip() for v in ((m or {}).get("variants") or []) if str(v or "").strip()]
|
||||
if field in FIELD_ORDER and variants:
|
||||
by_field.setdefault(field, []).append({"field": field, "variants": variants[:6]})
|
||||
|
||||
results: dict[str, dict] = {}
|
||||
if not by_field:
|
||||
return results
|
||||
|
||||
async def one_field(field: str, ms: list[dict]) -> None:
|
||||
try:
|
||||
cands = await _candidates(ctx.resolver, field)
|
||||
except Exception:
|
||||
logger.exception("matching: candidate fetch failed for %s", field)
|
||||
return
|
||||
if not cands:
|
||||
return
|
||||
|
||||
by_name: dict[str, object] = {}
|
||||
criteria: dict[str, str] = {}
|
||||
for c in cands:
|
||||
if c.name in criteria:
|
||||
continue
|
||||
criteria[c.name] = _hint(field, c)
|
||||
by_name[c.name] = c
|
||||
criteria["_none"] = "هیچکدام از موارد موجود نیست"
|
||||
|
||||
capped = ms[: settings.JEV_MAX_QUESTIONS_PER_FIELD]
|
||||
questions = {
|
||||
f"mention_{i}": {
|
||||
"type": "choice",
|
||||
"instructions": _instructions(field, m["variants"]),
|
||||
"criteria": criteria,
|
||||
}
|
||||
for i, m in enumerate(capped)
|
||||
}
|
||||
state = {
|
||||
"field": field,
|
||||
"patch_context": json.dumps(patch, ensure_ascii=False, default=str)[:1500],
|
||||
}
|
||||
await obs.event(trace_id, "jev.request", field=field, payload={
|
||||
"n_mentions": len(capped), "n_candidates": len(cands),
|
||||
})
|
||||
resp = await jev_ask(state, questions)
|
||||
await obs.event(trace_id, "jev.result", field=field, ok=resp is not None,
|
||||
model=(resp or {}).get("model"),
|
||||
latency_ms=(resp or {}).get("latency_ms"),
|
||||
payload={"answers": (resp or {}).get("answers"),
|
||||
"usage": (resp or {}).get("usage")})
|
||||
if resp is None:
|
||||
# Field-wide failure → leave no results so callers fall back to difflib.
|
||||
return
|
||||
answers = resp.get("answers") or {}
|
||||
|
||||
decisions = []
|
||||
for i, m in enumerate(capped):
|
||||
ans = answers.get(f"mention_{i}") or {}
|
||||
if not ans:
|
||||
# No answer for this mention → difflib fallback for it.
|
||||
continue
|
||||
choice = ans.get("choice")
|
||||
conf = float(ans.get("confidence") or 0.0)
|
||||
probs = ans.get("probabilities") or {}
|
||||
cand = by_name.get(choice) if choice else None
|
||||
ranked = sorted(
|
||||
({"id": by_name[n].id, "name": n, "prob": float(p or 0.0)}
|
||||
for n, p in probs.items() if n != "_none" and n in by_name),
|
||||
key=lambda x: -x["prob"],
|
||||
)[:6]
|
||||
|
||||
base = {"field": field, "variants": m["variants"], "confidence": conf,
|
||||
"choice": (cand.name if cand is not None else None),
|
||||
"candidates": ranked}
|
||||
|
||||
if cand is not None and conf >= settings.JEV_CONF_EXACT:
|
||||
res = {**base, "status": "exact", "id": cand.id, "name": cand.name,
|
||||
"extra": cand.extra or {}}
|
||||
elif cand is not None and conf >= settings.JEV_CONF_FLOOR:
|
||||
res = {**base, "status": "ambiguity", "id": cand.id, "name": cand.name,
|
||||
"extra": cand.extra or {},
|
||||
"candidates": ranked or [{"id": cand.id, "name": cand.name, "prob": conf}]}
|
||||
elif cand is None:
|
||||
# Explicit «_none» (or an unusable choice): the model judged the whole
|
||||
# list → treat as new (the auto-create path handles it).
|
||||
res = {**base, "status": "new"}
|
||||
else:
|
||||
# Picked something but below the floor = low trust: leave NO result so
|
||||
# the difflib fallback still gets its chance (suppressing it here would
|
||||
# let a correct-but-under-confident pick create duplicates instead).
|
||||
await obs.event(trace_id, "routing.decision", field=field, payload={
|
||||
"source": "jev-lowtrust", "variants": m["variants"],
|
||||
"choice": cand.name, "confidence": conf,
|
||||
})
|
||||
continue
|
||||
for v in m["variants"]:
|
||||
results[normalize_text(v)] = res
|
||||
decisions.append({"variants": m["variants"], "status": res["status"],
|
||||
"choice": res.get("name"), "confidence": conf,
|
||||
"probs_top": [{k: round(v, 3)} for k, v in
|
||||
sorted(probs.items(), key=lambda kv: -kv[1])[:3]]})
|
||||
await obs.event(trace_id, "routing.decision", field=field,
|
||||
payload={"source": "jev", "mentions": decisions})
|
||||
|
||||
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
|
||||
return results
|
||||
|
||||
|
||||
def lookup(jev_map: dict | None, name: str | None) -> dict | None:
|
||||
"""Find the Jev result for a raw name (by normalized variant)."""
|
||||
if not jev_map or not name:
|
||||
return None
|
||||
return jev_map.get(normalize_text(name))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# writing results into drafts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_choice(payload: dict, field: str, cand: dict, variants: list[str]) -> None:
|
||||
"""Bind one chosen candidate (exact match or a user pick) into the payload."""
|
||||
name, cid = cand.get("name"), cand.get("id")
|
||||
extra = cand.get("extra") or {}
|
||||
variants_norm = {normalize_text(v) for v in (variants or []) if v}
|
||||
|
||||
if field == "supplier":
|
||||
payload["supplier_id"] = cid
|
||||
payload["supplier_name"] = name
|
||||
for k in ("location", "phone", "contact_person", "category_name"):
|
||||
if extra.get(k):
|
||||
payload[f"supplier_{k}"] = extra[k]
|
||||
elif field == "project":
|
||||
resolved = payload.setdefault("resolved_projects", {})
|
||||
for v in (variants or []):
|
||||
resolved[v] = cid
|
||||
resolved[name] = cid
|
||||
if cid not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(cid)
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = cid
|
||||
elif field == "material":
|
||||
for item in payload.get("items") or []:
|
||||
if normalize_text(item.get("material_name") or "") in variants_norm:
|
||||
item["material_name"] = name
|
||||
item["material_id"] = cid
|
||||
item["material_row"] = {"id": cid,
|
||||
"canonical_unit_id": extra.get("canonical_unit_id")}
|
||||
elif field == "unit":
|
||||
for item in payload.get("items") or []:
|
||||
if normalize_text(item.get("unit_name") or "") in variants_norm:
|
||||
item["unit_id"] = cid
|
||||
item["unit_explicit"] = True
|
||||
item["unit_name"] = name
|
||||
if not payload.get("items"):
|
||||
payload["canonical_unit_id"] = cid
|
||||
payload["unit_name"] = name
|
||||
elif field == "brand":
|
||||
for item in payload.get("items") or []:
|
||||
if normalize_text(item.get("brand_name") or "") in variants_norm:
|
||||
item["brand_name"] = name
|
||||
elif field == "spec":
|
||||
for item in payload.get("items") or []:
|
||||
specs = item.get("specs") or []
|
||||
for i, s in enumerate(specs):
|
||||
if normalize_text(s) in variants_norm:
|
||||
specs[i] = name
|
||||
|
||||
|
||||
def apply_exact(payload: dict, res: dict) -> None:
|
||||
"""Bind one exact Jev match into a draft payload."""
|
||||
apply_choice(payload, res.get("field"), {"id": res.get("id"), "name": res.get("name"),
|
||||
"extra": res.get("extra") or {}},
|
||||
res.get("variants") or [])
|
||||
|
||||
|
||||
def normalize_brand_spec(payload: dict, jev_map: dict | None) -> None:
|
||||
"""Canonicalize brand/spec strings via exact matches (spec effect per owner:
|
||||
spelling normalization only)."""
|
||||
if not jev_map:
|
||||
return
|
||||
for item in payload.get("items") or []:
|
||||
brand = item.get("brand_name")
|
||||
if brand:
|
||||
hit = lookup(jev_map, brand)
|
||||
if hit and hit.get("status") == "exact":
|
||||
item["brand_name"] = hit["name"]
|
||||
specs = item.get("specs") or []
|
||||
for i, s in enumerate(specs):
|
||||
hit = lookup(jev_map, s)
|
||||
if hit and hit.get("status") == "exact":
|
||||
specs[i] = hit["name"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# phase 4 — ambiguity sub-messages with candidate buttons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _amb_id(field: str, variants: list[str]) -> str:
|
||||
digest = hashlib.sha1("|".join(sorted(variants)).encode()).hexdigest()[:4]
|
||||
return f"em_{field[:4]}_{digest}"
|
||||
|
||||
|
||||
def _fa_num(n: int) -> str:
|
||||
return str(n).translate(str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹"))
|
||||
|
||||
|
||||
def create_ambiguities(payload: dict, jev_map: dict | None) -> None:
|
||||
"""One `entity_match` ambiguity entry per ambiguous mention (deduped)."""
|
||||
if not jev_map:
|
||||
return
|
||||
ambs = payload.setdefault("ambiguities", [])
|
||||
seen = {a.get("id") for a in ambs}
|
||||
unique: dict[int, dict] = {}
|
||||
for res in jev_map.values():
|
||||
unique[id(res)] = res # dedupe identical result objects
|
||||
for res in unique.values():
|
||||
if res.get("status") != "ambiguity" or not res.get("candidates"):
|
||||
continue
|
||||
amb_id = _amb_id(res["field"], res.get("variants") or [])
|
||||
if amb_id in seen:
|
||||
continue
|
||||
cands = res["candidates"][:6]
|
||||
lines = [f"• {c['name']} — {_fa_num(int(round(c['prob'] * 100)))}٪" for c in cands]
|
||||
ambs.append({
|
||||
"id": amb_id,
|
||||
"kind": "entity_match",
|
||||
"field": res["field"],
|
||||
"variants": res.get("variants") or [],
|
||||
"candidates": [{"id": c["id"], "name": c["name"]} for c in cands],
|
||||
"title": f"تطبیق {FIELD_FA.get(res['field'], res['field'])}: «{' / '.join(res.get('variants') or [])}»",
|
||||
"details": "\n".join(lines),
|
||||
"status": "pending",
|
||||
})
|
||||
seen.add(amb_id)
|
||||
+1
-45
@@ -5,7 +5,7 @@ container and owns its own state (links, drafts, message audit log).
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
@@ -83,47 +83,3 @@ class BotMeta(Base):
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str | None] = mapped_column(Text)
|
||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||
|
||||
|
||||
class BotTrace(Base):
|
||||
"""Observability: one row per incoming interaction (message) — the chain root.
|
||||
|
||||
Events of the whole pipeline hang off it (bot_trace_events). Linked to the
|
||||
draft it produced so later button clicks can be attributed to the same chain.
|
||||
"""
|
||||
|
||||
__tablename__ = "bot_traces"
|
||||
|
||||
id: Mapped[str] = _uuid_pk()
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
message_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
kind: Mapped[str] = mapped_column(String(8), nullable=False) # text | voice
|
||||
source: Mapped[str | None] = mapped_column(String(16)) # bale | wizard | api
|
||||
draft_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False))
|
||||
draft_kind: Mapped[str | None] = mapped_column(String(16))
|
||||
committed_quote_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") # open|committed|cancelled
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||
|
||||
|
||||
class BotTraceEvent(Base):
|
||||
"""Observability: append-only pipeline event log (payload schemas by `stage`).
|
||||
|
||||
Written ONLY through app.observability (fire-and-forget, _jsonable-safe).
|
||||
Nothing is ever deleted from here by the bot.
|
||||
"""
|
||||
|
||||
__tablename__ = "bot_trace_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
trace_id: Mapped[str] = mapped_column(
|
||||
UUID(as_uuid=False), ForeignKey("bot_traces.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
stage: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
model: Mapped[str | None] = mapped_column(String(64))
|
||||
field: Mapped[str | None] = mapped_column(String(32))
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
|
||||
+18
-104
@@ -4,14 +4,12 @@ One chat/completions call per message with JSON-mode output. The prompt embeds
|
||||
the current draft summary so the model can decide between "start a new
|
||||
activity" and "refine the open draft" (update_draft with a partial patch).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.nlu")
|
||||
@@ -25,23 +23,6 @@ def normalize_digits(text: str) -> str:
|
||||
|
||||
ZWNJ = "\u200c"
|
||||
|
||||
MENTION_FIELDS = {"project", "supplier", "material", "brand", "spec", "unit"}
|
||||
|
||||
|
||||
def sanitize_mentions(raw) -> list[dict]:
|
||||
"""Keep only valid {field, variants} mention objects from the LLM output."""
|
||||
out: list[dict] = []
|
||||
if not isinstance(raw, list):
|
||||
return out
|
||||
for m in raw:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
field = str(m.get("field") or "").strip()
|
||||
variants = [str(v).strip() for v in (m.get("variants") or []) if str(v or "").strip()]
|
||||
if field in MENTION_FIELDS and variants:
|
||||
out.append({"field": field, "variants": variants[:6]})
|
||||
return out
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
|
||||
@@ -59,7 +40,6 @@ INTENTS = [
|
||||
"show_draft", # «وضعیت پیشنویس؟»
|
||||
"add_supplier",
|
||||
"add_material",
|
||||
"add_project",
|
||||
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
||||
"accept_quote", # پذیرش یک استعلام
|
||||
"arrive_items", # اعلام رسیدن اقلام
|
||||
@@ -67,65 +47,36 @@ INTENTS = [
|
||||
"unknown",
|
||||
]
|
||||
|
||||
SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم ERP ساختمانی و صنعتی در ایران هستی.
|
||||
پیامهای صوتی کاربران به زبان فارسی، با گویش و لهجه اصفهانی و با اصطلاحات محاورهای کارگاهی و بازار بیان شدهاند که نسخههای متنی متعددی توسط موتورهای تبدیل گفتار به متن (STT مانند Whisper و Vosk) از آنها تولید شده است.
|
||||
|
||||
وظیفه تو تحلیل پیام، حل خطاهای شنیداری ناشی از لهجه و گویش، تولید متن اجماعی پالایششده و استخراج دقیق دادههای تدارکاتی است.
|
||||
یک JSON و فقط JSON برگردان (بدون هرگونه متن، مارکداون یا توضیح اضافه) با این ساختار دقیق:
|
||||
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
|
||||
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
|
||||
|
||||
{
|
||||
"consensus_text": "بیانیه رسمی، پالایششده، فشرده و بدون تکیهکلام از تقاضای تدارکات کاربر",
|
||||
"consensus_text": "متن تجمیعشده و اصلاحشده کاربر (فارسی روان و بدون غلط شنیداری)",
|
||||
"intent": یکی از {intents},
|
||||
"patch": {شکل partial هر فعالیتی که کاربر داده},
|
||||
"entity_mentions": [{"field": "project|supplier|material|brand|spec|unit", "variants": ["همهی نگارشها"]}],
|
||||
"confidence": 0.0..1.0
|
||||
}
|
||||
|
||||
قواعد حیاتی:
|
||||
۱. قواعد آوایی و دستوری لهجه اصفهانی در رمزگشایی خطاهای STT:
|
||||
- شناسه مفعولی پیوسته: پسوند «ـشا» یا «ش ها» در پایان کلمات (مانند پولشا، بارشا) به معنای «آن را / آنها را» است.
|
||||
- شناسه دوم شخص جمع (ت به د): پسوندهای «ـِدون»، «برادون»، «برهدون» به معنای «برایتان / به شما» هستند.
|
||||
- تخمین زمانی و مقداری: کلمه «اینا» پس از اعداد یا زمان (مثلاً «۱۰ روز اینا»، «۴۰ تا اینا») قید تخمین و به معنای «حدوداً» است و نباید با کلمات نامربوط (مانند روزنامه) اشتباه گرفته شود.
|
||||
- حذف حشوهای مکالمه: اصطلاحات پرکننده («عرض به خدمتتون»، «پامون درمیاد»، «دونهای اینا»، «شبت بخیر»، «همین») باید کاملاً از 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):
|
||||
قوانین:
|
||||
- اگر ورودی شامل چند نسخه تبدیل گفتار به متن (STT) از یک پیام صوتی کاربر است:
|
||||
۱. این نسخهها از چند مدل مختلف برای افزایش دقت به دست آمدهاند. آنها را با هم مقایسه و مقابله کن.
|
||||
۲. خطاهای شنیداری هر نسخه را بر مبنای منطق تدارکات (نام کالاها، برندها، مشخصات فنی و واحدهای اندازهگیری) تصحیح و حل تعارض کن.
|
||||
۳. متن نهایی اصلاح و تجمیعشده را در فیلد consensus_text قرار بده.
|
||||
۴. استخراج قصد (intent) و موجودیتها (patch) را بر اساس همین متن دقیق انجام بده.
|
||||
- اگر ورودی یک متن عادی است، همان متن را در consensus_text قرار بده.
|
||||
- اگر پیام کاربر اطلاعات جدیدی به پیشنویسِ باز اضافه میکند یا اصلاح میکند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته.
|
||||
- اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft".
|
||||
- اگر لغو است («بیخیال»، «لغو») → intent="discard_draft".
|
||||
- اگر سؤال درباره وضعیت است → intent="status_query".
|
||||
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمینکننده/استعلام.
|
||||
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمینکننده/استعلام و در patch.arrivals مقادیر.
|
||||
|
||||
۶. استخراج موجودیتهای قابل تطبیق (entity_mentions):
|
||||
- فیلدهای مجاز: "project" | "supplier" | "material" | "brand" | "spec" | "unit"
|
||||
- هر نام کالا/تأمینکننده/پروژه/برند/واحد و هر مشخصه که در پیام آمده، یک عضو جداگانه است.
|
||||
- اگر در نسخههای مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «مصطفوی»، «مستفدی»، «مسطفی»)،
|
||||
همه را در variants همان یک عضو بگذار — نه چند عضو جداگانه.
|
||||
- برای پیام متنی معمولی، variants یک عضو تکعنصر است.
|
||||
- مشخصه (spec) و برند (brand) جدا از متریال و جدا از هم استخراج شوند.
|
||||
- وظیفهی تطبیق با موجودیتهای سیستم بر عهدهی تو نیست؛ هیچ نامی را به نزدیکترین نام موجود «حدس» نزن.
|
||||
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
|
||||
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
|
||||
|
||||
شکل patch برای intent="register_quote"/"update_draft":
|
||||
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD",
|
||||
"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, "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}]}
|
||||
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
|
||||
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
|
||||
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
|
||||
|
||||
شکل patch برای intent="add_supplier":
|
||||
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
|
||||
@@ -133,9 +84,6 @@ SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم
|
||||
شکل patch برای intent="add_material":
|
||||
{"material": {"name": str, "unit_name": str, "topic": str}}
|
||||
|
||||
شکل patch برای intent="add_project":
|
||||
{"project": {"name": str, "description": str}}
|
||||
|
||||
شکل patch برای intent="status_query":
|
||||
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
|
||||
|
||||
@@ -172,11 +120,9 @@ def _extract_json(raw: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None,
|
||||
trace_id: str | None = None) -> dict:
|
||||
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict:
|
||||
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
|
||||
fallback = {"consensus_text": "", "intent": "unknown", "patch": {},
|
||||
"entity_mentions": [], "confidence": 0.0}
|
||||
fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "confidence": 0.0}
|
||||
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
|
||||
logger.warning("AI provider not configured; cannot understand message")
|
||||
return fallback
|
||||
@@ -190,30 +136,12 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
lines.append(f"- نسخه {idx} ({name}): «{t}»")
|
||||
lines.append("\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
|
||||
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:
|
||||
user_content = normalize_digits(str(text))
|
||||
first_text = str(text)
|
||||
|
||||
fallback["consensus_text"] = first_text
|
||||
|
||||
system_prompt = _build_system_prompt(draft_summary)
|
||||
prompt_hash = hashlib.sha256(
|
||||
f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode()
|
||||
).hexdigest()[:16]
|
||||
await obs.event(trace_id, "nlu.request", model=settings.LLM_MODEL, payload={
|
||||
"is_multi": isinstance(text, list),
|
||||
"content": user_content[:2000],
|
||||
"prompt_hash": prompt_hash,
|
||||
"prompt_version": settings.OBS_PROMPT_VERSION,
|
||||
})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
@@ -222,7 +150,7 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "system", "content": _build_system_prompt(draft_summary)},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
@@ -231,30 +159,16 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
|
||||
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
|
||||
payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]})
|
||||
return fallback
|
||||
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
parsed = _extract_json(content)
|
||||
if not parsed or parsed.get("intent") not in INTENTS:
|
||||
logger.warning("unparseable LLM output: %r", content[:200])
|
||||
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
|
||||
payload={"error": "unparseable", "raw": content[:500]})
|
||||
return fallback
|
||||
parsed.setdefault("patch", {})
|
||||
parsed["entity_mentions"] = sanitize_mentions(parsed.get("entity_mentions"))
|
||||
if not parsed.get("consensus_text"):
|
||||
parsed["consensus_text"] = first_text
|
||||
await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL, payload={
|
||||
"intent": parsed.get("intent"),
|
||||
"confidence": parsed.get("confidence"),
|
||||
"patch": parsed.get("patch"),
|
||||
"consensus_text": parsed.get("consensus_text"),
|
||||
"entity_mentions": parsed.get("entity_mentions"),
|
||||
})
|
||||
return parsed
|
||||
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
|
||||
logger.exception("NLU failure")
|
||||
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
|
||||
payload={"error": "exception"})
|
||||
return fallback
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"""Silent observer — records the whole bot pipeline into Postgres (phase 1).
|
||||
|
||||
Design rules (final plan v5):
|
||||
- DB is the single source of truth; no external tools, no dashboards here.
|
||||
- ZERO extra LLM tokens: we only persist values that already exist in code.
|
||||
- Never raises, never blocks the reply path (fire-and-forget like runtime._log).
|
||||
- Never deletes anything (no retention/cleanup tasks exist by policy).
|
||||
- `_jsonable` guarantees JSONB-safety for UUID/datetime/Decimal/... payloads.
|
||||
|
||||
Stages captured (see plan §4.3): input.text, input.voice, stt.run, stt.result,
|
||||
nlu.request, nlu.result, entities.extracted, jev.request, jev.result,
|
||||
routing.decision, ambiguity.sent, ambiguity.user_choice, user_choice.pick,
|
||||
draft.render, draft.edit, draft.commit, draft.cancel.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import BotTrace, BotTraceEvent
|
||||
|
||||
logger = logging.getLogger("bot.obs")
|
||||
|
||||
_bg_tasks: set = set()
|
||||
|
||||
|
||||
def _fire(coro) -> None:
|
||||
"""Schedule a coroutine without awaiting; keep a ref so it is not GC'd."""
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(coro)
|
||||
_bg_tasks.add(task)
|
||||
task.add_done_callback(_bg_tasks.discard)
|
||||
except RuntimeError:
|
||||
# No running loop (should not happen inside the bot) — drop silently but log.
|
||||
logger.warning("obs: no running loop; dropped %r", coro)
|
||||
|
||||
|
||||
def _jsonable(obj):
|
||||
"""Recursively make any Python object JSONB-safe (UUID/datetime/Decimal/…)."""
|
||||
if obj is None or isinstance(obj, (bool, int, float, str)):
|
||||
return obj
|
||||
if isinstance(obj, (uuid.UUID, Path)):
|
||||
return str(obj)
|
||||
if isinstance(obj, (datetime, date, time)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Decimal):
|
||||
return str(obj) # دقت حفظ میشود؛ در تحلیل با ::numeric قابل تبدیل است
|
||||
if isinstance(obj, Enum):
|
||||
return _jsonable(obj.value)
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, set, frozenset)):
|
||||
return [_jsonable(v) for v in obj]
|
||||
if hasattr(obj, "model_dump"): # pydantic v2
|
||||
return _jsonable(obj.model_dump())
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
return _jsonable(vars(obj))
|
||||
return str(obj) # تور نهایی — هرگز raise نمیکند
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# traces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def trace_start(chat_id: int, message_id: int | None, kind: str,
|
||||
source: str = "bale") -> str | None:
|
||||
"""Open a new trace; returns its id (str) or None on failure."""
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
t = BotTrace(chat_id=chat_id, message_id=message_id, kind=kind, source=source)
|
||||
s.add(t)
|
||||
await s.commit()
|
||||
return str(t.id)
|
||||
except Exception:
|
||||
logger.exception("obs: trace_start failed")
|
||||
return None
|
||||
|
||||
|
||||
async def link_draft(trace_id: str | None, draft_id, draft_kind: str | None) -> None:
|
||||
"""Attach a draft to its originating trace (so later button clicks can find it)."""
|
||||
if not trace_id:
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
await s.execute(
|
||||
update(BotTrace)
|
||||
.where(BotTrace.id == trace_id)
|
||||
.values(draft_id=str(draft_id), draft_kind=draft_kind)
|
||||
)
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("obs: link_draft failed")
|
||||
|
||||
|
||||
async def latest_trace_for_draft(draft_id) -> str | None:
|
||||
"""Newest trace that produced this draft — used by button/render flows."""
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
row = (await s.execute(
|
||||
select(BotTrace.id)
|
||||
.where(BotTrace.draft_id == str(draft_id))
|
||||
.order_by(BotTrace.created_at.desc())
|
||||
.limit(1)
|
||||
)).scalar()
|
||||
return str(row) if row else None
|
||||
except Exception:
|
||||
logger.exception("obs: latest_trace_for_draft failed")
|
||||
return None
|
||||
|
||||
|
||||
async def set_trace_status(trace_id: str | None, status: str,
|
||||
committed_quote_id: str | None = None) -> None:
|
||||
if not trace_id:
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
values = {"status": status}
|
||||
if committed_quote_id:
|
||||
values["committed_quote_id"] = str(committed_quote_id)
|
||||
await s.execute(update(BotTrace).where(BotTrace.id == trace_id).values(**values))
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("obs: set_trace_status failed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def event(trace_id, stage: str, *, ok: bool = True, latency_ms: int | None = None,
|
||||
model: str | None = None, field: str | None = None,
|
||||
payload: dict | None = None) -> None:
|
||||
"""Append one pipeline event. Never raises; failures are loud in the log."""
|
||||
if not trace_id or not settings.OBS_ENABLED:
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
s.add(BotTraceEvent(
|
||||
trace_id=str(trace_id), stage=stage, ok=ok, latency_ms=latency_ms,
|
||||
model=model, field=field, payload=_jsonable(payload or {}),
|
||||
))
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("obs: event %s failed", stage) # خطای خاموش نمیماند
|
||||
|
||||
|
||||
async def log_resolution(trace_id, field: str, mention, status: str, candidates: list) -> None:
|
||||
"""Record one matching attempt (difflib baseline / fallback paths)."""
|
||||
await event(trace_id, "routing.decision", field=field, payload={
|
||||
"source": "difflib",
|
||||
"mention": mention,
|
||||
"status": status,
|
||||
"candidates": candidates[:4],
|
||||
})
|
||||
|
||||
|
||||
class span:
|
||||
"""with span('stt.run', trace_id=t, provider='whisper'): ... → one DB event with latency/ok."""
|
||||
|
||||
def __init__(self, name: str, trace_id: str | None = None, **attrs):
|
||||
self.name, self.trace_id, self.attrs = name, trace_id, attrs
|
||||
self._t0 = None
|
||||
|
||||
def __enter__(self):
|
||||
import time as _t
|
||||
self._t0 = _t.perf_counter_ns()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
import time as _t
|
||||
try:
|
||||
_fire(event(
|
||||
self.trace_id, self.name, ok=exc_type is None,
|
||||
latency_ms=int((_t.perf_counter_ns() - self._t0) / 1e6),
|
||||
model=self.attrs.get("model"), field=self.attrs.get("field"),
|
||||
payload=self.attrs,
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("obs: span %s failed", self.name)
|
||||
return False # استثنا هرگز بلعیده نمیشود
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# voice persistence (never deleted)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_voice(trace_id: str | None, message_id: int, content: bytes,
|
||||
ext: str = ".ogg") -> str | None:
|
||||
"""Persist a voice note under OBS_VOICE_DIR; creates the day folder safely."""
|
||||
try:
|
||||
day = datetime.utcnow().strftime("%Y%m%d")
|
||||
base = Path(settings.OBS_VOICE_DIR) / day
|
||||
base.mkdir(parents=True, exist_ok=True) # الزام سختسازی: مسیر همیشه ساخته میشود
|
||||
path = base / f"{trace_id}_{message_id}{ext}"
|
||||
path.write_bytes(content)
|
||||
return str(path)
|
||||
except Exception:
|
||||
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
|
||||
return None
|
||||
+4
-115
@@ -37,18 +37,14 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
|
||||
scored = []
|
||||
for item in items:
|
||||
name = normalize_text(item.name)
|
||||
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||
if q == name:
|
||||
ratio = 1.0
|
||||
elif q in name and len(q) >= 3:
|
||||
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:
|
||||
elif q in name:
|
||||
ratio += 0.35
|
||||
scored.append((ratio, item))
|
||||
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||
return [item for ratio, item in scored[:limit]]
|
||||
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
|
||||
|
||||
|
||||
async def _db_units() -> list[Candidate]:
|
||||
@@ -153,43 +149,6 @@ async def _db_materials(q: str | None = None) -> list[Candidate]:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_brands() -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text("SELECT id, name FROM brands"))).mappings().all()
|
||||
return [Candidate(id=str(b["id"]), name=b.get("name") or str(b["id"])) for b in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_specs() -> list[Candidate]:
|
||||
"""Distinct spec values: EAV strings from the catalog + specs used in recent drafts."""
|
||||
values: dict[str, None] = {}
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text(
|
||||
"SELECT DISTINCT value_string FROM material_attribute_values "
|
||||
"WHERE value_string IS NOT NULL AND btrim(value_string) <> '' LIMIT 500"
|
||||
))).scalars().all()
|
||||
for v in rows:
|
||||
values.setdefault(str(v), None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text(
|
||||
"SELECT DISTINCT spec FROM bot_drafts d, "
|
||||
"jsonb_array_elements(COALESCE(d.payload->'items', '[]'::jsonb)) it, "
|
||||
"jsonb_array_elements_text(COALESCE(it->'specs', '[]'::jsonb)) spec "
|
||||
"WHERE btrim(spec) <> '' LIMIT 300"
|
||||
))).scalars().all()
|
||||
for v in rows:
|
||||
values.setdefault(str(v), None)
|
||||
except Exception:
|
||||
pass
|
||||
return [Candidate(id=v, name=v) for v in values]
|
||||
|
||||
|
||||
class Resolver:
|
||||
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
|
||||
|
||||
@@ -273,74 +232,6 @@ class Resolver:
|
||||
items = await _db_materials(q=q)
|
||||
return _rank(q, items) if q else items
|
||||
|
||||
# ------------------------------------------------- full lists (Jev, v5)
|
||||
|
||||
async def _fetch_all(self, fetch_page, max_pages: int = 5) -> list[dict]:
|
||||
"""Page through a paginated ERP list endpoint (page_size=100)."""
|
||||
out: list[dict] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
rows = await fetch_page(page)
|
||||
out += rows
|
||||
if len(rows) < 100:
|
||||
break
|
||||
return out
|
||||
|
||||
async def all_suppliers(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._fetch_all(
|
||||
lambda page: self._api.list_suppliers(page=page, page_size=100)
|
||||
)
|
||||
return [
|
||||
Candidate(
|
||||
id=str(s["id"]),
|
||||
name=s.get("name") or str(s["id"]),
|
||||
extra={
|
||||
"location": s.get("location"),
|
||||
"phone": s.get("phone"),
|
||||
"contact_person": s.get("contact_person"),
|
||||
"category_name": (s.get("category") or {}).get("name")
|
||||
if isinstance(s.get("category"), dict) else s.get("category_name"),
|
||||
},
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
except Exception:
|
||||
return await _db_suppliers()
|
||||
return await self._cached("all_suppliers", fetch)
|
||||
|
||||
async def all_materials(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._fetch_all(
|
||||
lambda page: self._api.list_materials(page=page, page_size=100)
|
||||
)
|
||||
return [
|
||||
Candidate(
|
||||
id=str(m["id"]),
|
||||
name=m.get("name") or str(m["id"]),
|
||||
extra={"canonical_unit_id": m.get("canonical_unit_id")},
|
||||
)
|
||||
for m in rows
|
||||
]
|
||||
except Exception:
|
||||
return await _db_materials()
|
||||
return await self._cached("all_materials", fetch)
|
||||
|
||||
async def brands(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._fetch_all(
|
||||
lambda page: self._api.list_brands(page=page, page_size=100)
|
||||
)
|
||||
return [Candidate(id=str(b["id"]), name=b.get("name") or str(b["id"])) for b in rows]
|
||||
except Exception:
|
||||
return await _db_brands()
|
||||
return await self._cached("brands", fetch)
|
||||
|
||||
async def specs(self) -> list[Candidate]:
|
||||
return await self._cached("specs", _db_specs)
|
||||
|
||||
# ------------------------------------------------------------ resolutions
|
||||
|
||||
async def resolve(self, kind: str, name: str | None) -> Resolution:
|
||||
@@ -368,8 +259,6 @@ class Resolver:
|
||||
top = candidates[0]
|
||||
top_norm = normalize_text(top.name)
|
||||
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):
|
||||
return Resolution("one", [top])
|
||||
return Resolution("many", candidates)
|
||||
|
||||
+10
-32
@@ -9,12 +9,10 @@ import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.stt")
|
||||
@@ -39,8 +37,7 @@ def _to_mp3(raw: bytes, suffix: str) -> bytes:
|
||||
return dst.read_bytes()
|
||||
|
||||
|
||||
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict:
|
||||
"""Call one provider; always returns {"name","ok","text"/"error","model","latency_ms"}."""
|
||||
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None:
|
||||
name = provider.get("name") or "stt"
|
||||
base_url = (provider.get("base_url") or "").rstrip("/")
|
||||
api_key = provider.get("api_key") or ""
|
||||
@@ -49,7 +46,6 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
|
||||
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
|
||||
|
||||
url = f"{base_url}/audio/transcriptions"
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
@@ -58,30 +54,24 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
|
||||
data={"model": model, "language": language},
|
||||
timeout=timeout,
|
||||
)
|
||||
ms = int((time.perf_counter() - t0) * 1000)
|
||||
if resp.status_code == 200:
|
||||
txt = str((resp.json() or {}).get("text") or "").strip()
|
||||
if txt:
|
||||
logger.info("STT provider %s succeeded: %r", name, txt[:60])
|
||||
return {"name": name, "ok": True, "text": txt, "model": model, "latency_ms": ms}
|
||||
return {"name": name, "ok": False, "error": "empty transcript (HTTP 200)",
|
||||
"model": model, "latency_ms": ms}
|
||||
return {"name": name, "text": txt, "model": model}
|
||||
else:
|
||||
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
||||
return {"name": name, "ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
"model": model, "latency_ms": ms}
|
||||
except Exception as exc:
|
||||
logger.warning("STT provider %s failed: %s", name, exc)
|
||||
return {"name": name, "ok": False, "error": str(exc)[:300],
|
||||
"model": model, "latency_ms": int((time.perf_counter() - t0) * 1000)}
|
||||
return None
|
||||
|
||||
|
||||
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||
filename: str | None = None, trace_id: str | None = None) -> list[dict]:
|
||||
filename: str | None = None) -> list[dict]:
|
||||
"""Transcribe audio with all configured STT providers in parallel.
|
||||
|
||||
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
|
||||
Empty list if all failed or silence. Every provider outcome (success AND
|
||||
failure) is recorded in Observability when trace_id is given.
|
||||
Empty list if all failed or silence.
|
||||
"""
|
||||
providers = settings.stt_providers
|
||||
if not providers:
|
||||
@@ -93,32 +83,20 @@ async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | No
|
||||
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
|
||||
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
|
||||
|
||||
await obs.event(trace_id, "stt.run", payload={
|
||||
"providers": [p.get("name") or "stt" for p in providers],
|
||||
"audio_bytes": len(converted),
|
||||
})
|
||||
|
||||
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||
tasks = [_call_single_stt(client, p, converted) for p in providers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
valid_transcripts: list[dict] = []
|
||||
for r in results:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
await obs.event(trace_id, "stt.result", ok=bool(r.get("ok")),
|
||||
latency_ms=r.get("latency_ms"), model=r.get("model"),
|
||||
payload={"provider": r.get("name"), "text": r.get("text"),
|
||||
"error": r.get("error")})
|
||||
if r.get("ok") and r.get("text"):
|
||||
valid_transcripts.append({"name": r["name"], "text": r["text"], "model": r.get("model")})
|
||||
if isinstance(r, dict) and r.get("text"):
|
||||
valid_transcripts.append(r)
|
||||
|
||||
return valid_transcripts
|
||||
|
||||
|
||||
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||
filename: str | None = None, trace_id: str | None = None) -> str:
|
||||
filename: str | None = None) -> str:
|
||||
"""Return the Persian transcript for a voice note (empty string on silence)."""
|
||||
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type,
|
||||
filename=filename, trace_id=trace_id)
|
||||
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename)
|
||||
return transcripts[0]["text"] if transcripts else ""
|
||||
|
||||
@@ -8,7 +8,6 @@ If a draft was updated in the Wizard:
|
||||
import asyncio
|
||||
import logging
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.handlers import quote
|
||||
|
||||
logger = logging.getLogger("bot.sync")
|
||||
@@ -41,19 +40,6 @@ async def sync_drafts_loop(ctx) -> None:
|
||||
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id)
|
||||
|
||||
elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE:
|
||||
import hashlib
|
||||
import json as _json
|
||||
await obs.event(
|
||||
await obs.latest_trace_for_draft(draft.id), "draft.edit",
|
||||
payload={
|
||||
"source": "wizard",
|
||||
"kind": draft.kind,
|
||||
"payload_hash": hashlib.sha1(
|
||||
_json.dumps(draft.payload, ensure_ascii=False,
|
||||
sort_keys=True, default=str).encode()
|
||||
).hexdigest()[:16],
|
||||
},
|
||||
)
|
||||
await quote.render(ctx, draft.chat_id, draft, fresh=False)
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
|
||||
|
||||
+3
-55
@@ -210,20 +210,6 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
|
||||
if 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 []
|
||||
total_amount = 0.0
|
||||
@@ -246,10 +232,7 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
|
||||
# Project
|
||||
item_proj = it.get("project_name") or default_proj
|
||||
if 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}")
|
||||
lines.append(f" 🏗️ {item_proj}")
|
||||
else:
|
||||
lines.append(" 🏗️ پروژه مشخص نشده")
|
||||
|
||||
@@ -385,7 +368,7 @@ def material_review_text(payload: dict, missing: list[dict]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def quote_committed_report(created: dict, payload: dict, remaining: list[str], newly_created: dict | None = None) -> str:
|
||||
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
|
||||
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
|
||||
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
|
||||
if sup:
|
||||
@@ -396,21 +379,8 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str], n
|
||||
sheets = created.get("termsheets") or []
|
||||
if sheets:
|
||||
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||||
if payload.get("project_id") or payload.get("project_ids"):
|
||||
if payload.get("project_id"):
|
||||
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:
|
||||
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||||
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
||||
@@ -418,19 +388,6 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str], n
|
||||
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:
|
||||
return (
|
||||
f"✅ تأمینکننده ثبت شد — {esc(created.get('name'))}\n"
|
||||
@@ -473,12 +430,3 @@ def status_report(kind: str, quotes: list[dict], extra_note: str | None = None)
|
||||
lines.append(extra_note)
|
||||
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ambiguity_message_text(title: str, details: str) -> str:
|
||||
return (
|
||||
f"🤔 آیا نظر شما این بود؟\n"
|
||||
f"موضوع: {title}\n\n"
|
||||
f"{details}"
|
||||
)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,86 +0,0 @@
|
||||
# استقرار و پایش — فاز ۷ (Jev + Observability)
|
||||
|
||||
وضعیت بهروزشده: 2026-10-12 · برنچ: `prod-v7` · **هیچ پوشی انجام نمیشود تا مالک دستور بدهد.**
|
||||
|
||||
## الف) وضعیت فعلی
|
||||
|
||||
- بات همین حالا **روی استک لوکال** اجرا میشود (long-polling بله؛ بدون پورت ورودی) و
|
||||
`JEV_ENABLED` + `OBS_ENABLED` روی آن فعال است.
|
||||
- استقرار بات روی سرور یک **تصمیم جدا** است (نه بخشی از مرج تیم). تا آن تصمیم، بات لوکال میماند.
|
||||
- ⚠️ **قانون یکپولره:** Bale فقط اجازهی یک مصرفکنندهی `getUpdates` میدهد. اگر باتِ سرور و
|
||||
باتِ لوکال همزمان روشن باشند، پیامها بین آنها گم/تکراری میشوند. **قبل از روشنکردن بات سرور،
|
||||
بات لوکال باید خاموش شود.**
|
||||
|
||||
## ب) پیشنیازهای سرور (وقتی تصمیم گرفته شد)
|
||||
|
||||
کلیدها و تنظیمهای بات (در گیت نیستند — از طریق Coolify env یا فایل `bot/.env` روی سرور):
|
||||
|
||||
| متغیر | مقدار |
|
||||
|---|---|
|
||||
| `PROCUREMENT_BOT_BOT_TOKEN` | توکن بله (rotated — بخش امنیتی پایین را ببین) |
|
||||
| `PROCUREMENT_BOT_AI_API_KEY` | کلید DeepSeek |
|
||||
| `PROCUREMENT_BOT_TYPESAFE_API_KEY` | کلید Jev |
|
||||
| `PROCUREMENT_BOT_API_BASE_URL` | `http://api:8000/api/v1` |
|
||||
| `PROCUREMENT_BOT_DATABASE_URL` | `postgresql+asyncpg://postgres:***@postgres:5432/procurement` |
|
||||
| `PROCUREMENT_BOT_JEV_CONF_EXACT` / `_FLOOR` | اختیاری — پیشفرض کالیبره: `0.85` / `0.65` |
|
||||
| `PROCUREMENT_BOT_OBS_PROMPT_VERSION` | `nlu-v2-entity-mentions` (برای prompt_hash) |
|
||||
|
||||
ولومها: `bot/app` (کد)، `bot/tools` (ابزارها/pایش)، `bot/data` (**صدای خام — آرشیو، نه حذف**)،
|
||||
`bot/.env:ro`.
|
||||
|
||||
نکته: **mount جدید فقط با recreate اعمال میشود، نه restart** → `docker compose up -d --force-recreate bot`.
|
||||
|
||||
بلوک آمادهی سرویس بات برای کامپوز دیپلوی Coolify (فقط «در صورت» تصمیم — عمداً به فایل کامپوز
|
||||
اضافه نشده چون سرویسشدن خودکار = خطر دوپولری و تغییر رفتار سرور):
|
||||
|
||||
```yaml
|
||||
bot:
|
||||
image: v15-bot:latest
|
||||
build: { context: ./bot, dockerfile: Dockerfile }
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
volumes:
|
||||
- ./bot/data:/srv/bot/data
|
||||
- ./bot/tools:/srv/bot/tools
|
||||
environment:
|
||||
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN}
|
||||
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY}
|
||||
PROCUREMENT_BOT_TYPESAFE_API_KEY: ${TYPESAFE_API_KEY}
|
||||
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
|
||||
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@postgres:5432/procurement
|
||||
```
|
||||
|
||||
## ج) ترتیب استقرار (فقط وقتی مالک گفت «پوش کن»)
|
||||
|
||||
1. مهندس ارشد `prod-v7` را با بقیه مرج و CI/Coolify دیپلوی ERP را انجام میدهد.
|
||||
2. تصمیم بات سرور (بالا) → در صورت تأیید: خاموشکردن بات لوکال → روشنکردن بات سرور.
|
||||
3. **Smoke test (چکلیست پلن §۷ بند ۱۲):** ۱۰ پیام نمونه شامل «منشیانه/دفتر/مصطفوی» + یک ویس؛
|
||||
بررسی: اتصال دقیقها، پیام ابهام با دکمههای کاندیدا، ساخت جدید، پیشنویس، ثبت نهایی.
|
||||
4. راستیآزمایی پایش: `docker compose exec bot python tools/monitor.py --days 1`
|
||||
→ قیف باید پر شود و رخدادهای همان پیامها در `bot_trace_events` باشد.
|
||||
5. چک سهمیه: `usage` در `jev.result` (توکن ورودی/خروجی) + latency p95.
|
||||
|
||||
## د) Rollback (بدون دیپلوی — فقط env + restart)
|
||||
|
||||
| سوئیچ | اثر |
|
||||
|---|---|
|
||||
| `JEV_ENABLED=false` | بازگشت کامل به مسیر difflib قدیم |
|
||||
| `OBS_ENABLED=false` | خاموششدن ثبت (دادههای قبلی دستنخورده میمانند) |
|
||||
|
||||
## ه) پایش هفتگی
|
||||
|
||||
```bash
|
||||
docker compose exec bot python tools/monitor.py --days 7 # DIGEST
|
||||
docker compose exec bot python tools/monitor.py --days 7 --check # exit 1 اگر هشدار
|
||||
```
|
||||
|
||||
- خروجی: کنسول + فایل `bot/data/reports/monitoring_<ts>.md`
|
||||
- آمار **فقط روی ترافیک واقعی** است (calibration/test حذف میشوند؛ `--include-calibration` برای دیدن همه).
|
||||
- هشدارها (پلن §۴.۵): حجم Observability > ۵GB یا رخداد ماهانه > ۱۰۰هزار → **جلسهی «آرشیو، نه حذف»**؛
|
||||
انحراف نسخهی مدل از پین `jev-1.13.0`؛ نسبت خطای Jev > ۲۰٪.
|
||||
- پروکسی کیفیت: انتخاب کاربر در ابهامها (گزینه اول مدل / گزینه دیگر / «هیچکدام») — این عددِ A/B واقعی است.
|
||||
|
||||
## و) یادآوری امنیتی (از قبل علامتخورده — تصمیم مالک)
|
||||
|
||||
`docker-compose.yml` (نسخه لوکال) هنوز توکن بله و کلید DeepSeek را بهصورت پیشفرض
|
||||
(`:-...`) داخل خود دارد. قبل از هر پوش/جابهجایی سرور، چرخش کلیدها توصیه میشود.
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Phase-6 calibration: A/B difflib vs Jev + confidence-threshold sweep.
|
||||
|
||||
docker compose exec bot python tools/calibrate_matching.py
|
||||
|
||||
- Runs a fixed scenario set (seeded catalog + realistic STT-ish variants and
|
||||
negatives) through BOTH matchers:
|
||||
* difflib baseline: resolve.safe_resolve (the pre-Jev path)
|
||||
* Jev: matching.resolve_mentions (one request per field — that is how the
|
||||
pipeline actually runs آن)
|
||||
- Every run is recorded as real observability data (traces with
|
||||
source="calibration") plus a markdown report under data/reports/.
|
||||
- Prints a summary with recommended JEV_CONF_EXACT / JEV_CONF_FLOOR values.
|
||||
|
||||
Nothing is sent to Bale and no drafts are created.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import matching, observability as obs
|
||||
from app.api.client import ErpClient
|
||||
from app.config import settings
|
||||
from app.resolve import Resolver, safe_resolve
|
||||
|
||||
# (field, variants, truth) — truth=None means "must NOT match anything".
|
||||
SCENARIOS = [
|
||||
("project", ["دفتر"], "دفتر"),
|
||||
("project", ["منشیانه"], "منشانه"),
|
||||
("project", ["منشانه"], "منشانه"),
|
||||
("project", ["باق اسپارت"], "باغ اسپارت"),
|
||||
("project", ["پردیس"], "پردیس کاشان"),
|
||||
("project", ["بابک"], "بابک"),
|
||||
("project", ["آسمون"], "آسمان"),
|
||||
("project", ["تست یک"], "تست 1"),
|
||||
("project", ["انبار قدیم"], None),
|
||||
|
||||
("supplier", ["مصطفوی"], "بازرگانی مصطفوی"),
|
||||
("supplier", ["مستفدی"], "بازرگانی مصطفوی"),
|
||||
("supplier", ["مصطفوی", "مستفدی"], "بازرگانی مصطفوی"),
|
||||
("supplier", ["فولاد مبارکه"], "نمایندگی فولاد مبارکه"),
|
||||
("supplier", ["پلیکا البرز"], "پلیکا فروش البرز"),
|
||||
("supplier", ["راجین"], "راجین"),
|
||||
("supplier", ["آهن فروشی آزاد"], None),
|
||||
|
||||
("material", ["گچ و خاک"], "گچ و خاک"),
|
||||
("material", ["سیمان"], "سیمان پرتلند"),
|
||||
("material", ["میلگرد"], "میلگرد آجدار"),
|
||||
("material", ["تیر آهن"], "تیرآهن"),
|
||||
("material", ["لوله پلیکا"], "لوله پلیکا"),
|
||||
("material", ["کاغذ A4"], None),
|
||||
|
||||
("unit", ["کیلو"], "کیلوگرم"),
|
||||
("unit", ["تن"], "تن"),
|
||||
("unit", ["کیسه"], "کیسه"),
|
||||
("unit", ["شاخه"], "شاخه"),
|
||||
("unit", ["جعبه"], None),
|
||||
|
||||
("brand", ["ذوب آهن"], "ذوب آهن اصفهان"),
|
||||
("brand", ["مبارکه"], "فولاد مبارکه"),
|
||||
("brand", ["سیمان تهران"], "سیمان تهران"),
|
||||
|
||||
("spec", ["سایز 14"], "سایز ۱۴"),
|
||||
("spec", ["50 کیلویی"], "۵۰ کیلویی"),
|
||||
("spec", ["6 اینچ"], "۶ اینچ"),
|
||||
("spec", ["40×40"], "۴۰×۴۰"),
|
||||
("spec", ["۲ میلی"], "۲ میل"),
|
||||
]
|
||||
|
||||
SWEEP = [round(0.55 + 0.05 * i, 2) for i in range(9)] # 0.55 … 0.95
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
|
||||
resolver = Resolver(api)
|
||||
ctx = SimpleNamespace(resolver=resolver)
|
||||
|
||||
fields: dict[str, list[dict]] = {}
|
||||
for field, variants, truth in SCENARIOS:
|
||||
fields.setdefault(field, []).append({"field": field, "variants": variants,
|
||||
"truth": truth, "difflib": None, "jev": None})
|
||||
|
||||
for field, scs in fields.items():
|
||||
trace = await obs.trace_start(0, None, "text", source="calibration")
|
||||
|
||||
# --- difflib baseline -------------------------------------------------
|
||||
for sc in scs:
|
||||
res = await safe_resolve(resolver, field, sc["variants"][0])
|
||||
cands = [{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]]
|
||||
sc["difflib"] = {"status": res.status, "top": cands[0]["name"] if cands else None,
|
||||
"cands": [c["name"] for c in cands]}
|
||||
await obs.log_resolution(trace, field, sc["variants"][0], res.status, cands)
|
||||
|
||||
# --- Jev (one batch for the whole field) ------------------------------
|
||||
# Collect RAW picks: temporarily widen the bands so every confidence is
|
||||
# returned; classification below uses the CONFIGURED thresholds.
|
||||
saved_exact, saved_floor = settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR
|
||||
settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR = 1.01, 0.0
|
||||
try:
|
||||
jev = await matching.resolve_mentions(
|
||||
ctx, {}, [{"field": s["field"], "variants": s["variants"]} for s in scs], trace)
|
||||
finally:
|
||||
settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR = saved_exact, saved_floor
|
||||
for sc in scs:
|
||||
hit = matching.lookup(jev, sc["variants"][0])
|
||||
raw_conf = None if hit is None else hit.get("confidence")
|
||||
raw_choice = None if hit is None else hit.get("choice")
|
||||
if raw_choice is None:
|
||||
sc["jev"] = {"status": "new", "choice": None, "conf": raw_conf}
|
||||
elif raw_conf >= saved_exact:
|
||||
sc["jev"] = {"status": "exact", "choice": raw_choice, "conf": raw_conf}
|
||||
elif raw_conf >= saved_floor:
|
||||
sc["jev"] = {"status": "ambiguity", "choice": raw_choice, "conf": raw_conf}
|
||||
else:
|
||||
sc["jev"] = {"status": "lowtrust", "choice": raw_choice, "conf": raw_conf}
|
||||
await obs.event(trace, "calibration.truth", payload={
|
||||
"field": field,
|
||||
"rows": [{"variants": s["variants"], "truth": s["truth"],
|
||||
"difflib": s["difflib"], "jev": s["jev"]} for s in scs],
|
||||
})
|
||||
|
||||
# ------------------------- metrics ---------------------------------------
|
||||
pos = [s for s in sum(fields.values(), []) if s["truth"]]
|
||||
neg = [s for s in sum(fields.values(), []) if not s["truth"]]
|
||||
|
||||
d_auto = d_amb = d_miss = 0
|
||||
for s in pos:
|
||||
d = s["difflib"]
|
||||
if d["top"] == s["truth"]:
|
||||
d_auto += 1
|
||||
elif s["truth"] in d["cands"]:
|
||||
d_amb += 1
|
||||
else:
|
||||
d_miss += 1
|
||||
d_neg_ok = sum(1 for s in neg if s["difflib"]["status"] == "none")
|
||||
|
||||
j_exact_ok = sum(1 for s in pos if s["jev"]["status"] == "exact"
|
||||
and s["jev"]["choice"] == s["truth"])
|
||||
j_exact_bad = sum(1 for s in pos if s["jev"]["status"] == "exact"
|
||||
and s["jev"]["choice"] != s["truth"])
|
||||
j_amb = sum(1 for s in pos if s["jev"]["status"] == "ambiguity")
|
||||
j_low = sum(1 for s in pos if s["jev"]["status"] == "lowtrust")
|
||||
j_new = sum(1 for s in pos if s["jev"]["status"] == "new")
|
||||
# negatives: rejected cleanly / asked the user (safe but noisy) / wrongly bound
|
||||
j_neg_ok = sum(1 for s in neg if s["jev"]["status"] in ("new", "lowtrust"))
|
||||
j_neg_ask = sum(1 for s in neg if s["jev"]["status"] == "ambiguity")
|
||||
j_neg_bad = sum(1 for s in neg if s["jev"]["status"] == "exact")
|
||||
|
||||
# --------------------- threshold sweep (EXACT) ---------------------------
|
||||
# Binds = positives above the threshold PLUS any pick on a negative scenario
|
||||
# (a wrong pick above EXACT would auto-bind — it must drag precision down).
|
||||
sweep_rows = []
|
||||
for th in SWEEP:
|
||||
pos_binds = [s for s in pos if s["jev"]["choice"] and (s["jev"]["conf"] or 0) >= th]
|
||||
neg_binds = [s for s in neg if s["jev"]["choice"] and (s["jev"]["conf"] or 0) >= th]
|
||||
ok = sum(1 for s in pos_binds if s["jev"]["choice"] == s["truth"])
|
||||
binds = len(pos_binds) + len(neg_binds)
|
||||
prec = (ok / binds) if binds else 0.0
|
||||
cov = len(pos_binds) / len(pos)
|
||||
sweep_rows.append({"th": th, "binds": binds, "correct": ok,
|
||||
"precision": round(prec, 3), "coverage": round(cov, 3)})
|
||||
|
||||
# Conservative recommendation (risk-asymmetric: a wrong auto-bind costs more
|
||||
# than an extra confirmation tap; the sample here is still small):
|
||||
# - EXACT: keep the configured value while its precision is >= 0.95; raise it
|
||||
# to the first threshold with precision >= 0.95 otherwise.
|
||||
# - FLOOR: raise just above the highest confidence of a WRONG pick (a wrong
|
||||
# pick below the floor never again becomes a candidate button).
|
||||
binds_now = [r for r in sweep_rows if r["th"] == settings.JEV_CONF_EXACT]
|
||||
prec_now = binds_now[0]["precision"] if binds_now else 0.0
|
||||
rec_exact = settings.JEV_CONF_EXACT
|
||||
if prec_now < 0.95:
|
||||
for row in sweep_rows:
|
||||
if row["precision"] >= 0.95 and row["coverage"] >= 0.30:
|
||||
rec_exact = row["th"]
|
||||
break
|
||||
|
||||
correct_confs = sorted(s["jev"]["conf"] for s in pos
|
||||
if s["jev"]["choice"] == s["truth"] and s["jev"]["conf"])
|
||||
# a wrong pick on a NEGATIVE scenario (truth=None) matters too: the floor
|
||||
# must sit above it so it never becomes a candidate button offer.
|
||||
wrong_confs = sorted(s["jev"]["conf"] for s in pos + neg
|
||||
if s["jev"]["choice"] and s["jev"]["choice"] != s["truth"] and s["jev"]["conf"])
|
||||
rec_floor = settings.JEV_CONF_FLOOR
|
||||
if wrong_confs:
|
||||
import math
|
||||
# The same scenario's confidence jitters ±0.05 across identical runs
|
||||
# (جعبه observed 0.61/0.60/0.58), so sit one step above the highest wrong
|
||||
# pick + margin — routing uses `conf >= FLOOR`, so equality leaks through.
|
||||
target = max(wrong_confs) + 0.05
|
||||
rec_floor = min(rec_exact, round(math.floor(target * 20 + 0.5) / 20.0, 2))
|
||||
if rec_floor <= max(wrong_confs):
|
||||
rec_floor = min(rec_exact,
|
||||
round((math.floor(max(wrong_confs) * 20) + 1) / 20.0, 2))
|
||||
|
||||
# how much coverage a more aggressive EXACT would buy (info only)
|
||||
lower_info = next((r for r in reversed(sweep_rows)
|
||||
if r["th"] < settings.JEV_CONF_EXACT and r["precision"] >= 0.98), None)
|
||||
|
||||
# ------------------------------ report -----------------------------------
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
|
||||
out_dir = Path(settings.OBS_VOICE_DIR).parent / "reports"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
report = out_dir / f"calibration_{ts}.md"
|
||||
|
||||
lines = [
|
||||
"# گزارش کالیبراسیون تطبیق موجودیتها (فاز ۶)",
|
||||
f"\nتاریخ: {ts} UTC · مدل Jev پینشده: `{settings.JEV_MODEL}` · "
|
||||
f"آستانههای فعلی: EXACT={settings.JEV_CONF_EXACT} · FLOOR={settings.JEV_CONF_FLOOR}",
|
||||
f"\nسناریوها: {len(pos)} مثبت + {len(neg)} منفی (بدون تطبیق) = {len(pos) + len(neg)}",
|
||||
"\n> روش: انتخابهای خام Jev با باند بازِ موقت جمعآوری میشوند و «وضعیت» هر "
|
||||
"سناریو با آستانههای پیکربندیشده بالا محاسبه میشود. status `lowtrust` یعنی "
|
||||
"انتخاب زیر FLOOR بود و در اجرای واقعی رد میشود → مسیر difflib ادامه پیدا میکند.",
|
||||
"\n## خلاصه",
|
||||
f"- **difflib (پایه):** اتصال درست {d_auto}/{len(pos)} · ابهام {d_amb} · از دست رفته {d_miss} "
|
||||
f"· رد درست منفیها {d_neg_ok}/{len(neg)}",
|
||||
f"- **Jev:** exact-درست {j_exact_ok} · exact-غلط {j_exact_bad} · ابهام(سؤال از کاربر) {j_amb} · "
|
||||
f"کماعتماد→difflib {j_low} · new {j_new}",
|
||||
f"- **منفیها:** ردِ تمیز {j_neg_ok}/{len(neg)} · سؤال از کاربر {j_neg_ask} · پذیرش غلط {j_neg_bad}",
|
||||
f"- **پیشنهاد آستانه:** `JEV_CONF_EXACT={rec_exact}` · `JEV_CONF_FLOOR={rec_floor}`"
|
||||
+ (f" _(داده فعلی از کاهش EXACT تا {lower_info['th']} با دقت {lower_info['precision']} "
|
||||
f"و پوشش {lower_info['coverage']} حمایت میکند — با حجم داده واقعی بازبینی شود)_"
|
||||
if lower_info else ""),
|
||||
"\n## جدول sweep آستانه EXACT",
|
||||
"| آستانه | اتصال خودکار (مثبت+منفی) | درست | دقت | پوشش مثبتها |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for r in sweep_rows:
|
||||
lines.append(f"| {r['th']} | {r['binds']} | {r['correct']} | {r['precision']} | {r['coverage']} |")
|
||||
lines += [
|
||||
"\n## توزیع confidence",
|
||||
f"- انتخابهای درست: {correct_confs or '—'}",
|
||||
f"- انتخابهای غلط: {wrong_confs or '—'}",
|
||||
]
|
||||
for field, scs in fields.items():
|
||||
lines += [f"\n## فیلد {field}", "| variants | حقیقت | difflib | Jev (choice/conf) |",
|
||||
"|---|---|---|---|"]
|
||||
for s in scs:
|
||||
j = s["jev"]
|
||||
jtxt = "—" if not j else f"{j['status']}: {j['choice'] or '_none'} @ {j['conf']}"
|
||||
if j and j["status"] == "lowtrust":
|
||||
jtxt += " (→ مسیر difflib)"
|
||||
lines.append(f"| {' / '.join(s['variants'])} | {s['truth'] or '—'} | "
|
||||
f"{s['difflib']['status']}: {s['difflib']['top'] or '—'} | {jtxt} |")
|
||||
report.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
print("\n".join(lines[:14]))
|
||||
print(f"\nگزارش کامل: {report}")
|
||||
print(f"\nپیشنهاد: JEV_CONF_EXACT={rec_exact} JEV_CONF_FLOOR={rec_floor}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -1,292 +0,0 @@
|
||||
"""Phase-7 weekly monitoring digest (plan §7) — all from the observability tables.
|
||||
|
||||
docker compose exec bot python tools/monitor.py [--days 7] [--check] [--include-calibration]
|
||||
|
||||
- Prints a Persian digest and writes the same content as markdown to
|
||||
`data/reports/monitoring_<YYYYMMDD_HHMM>.md`.
|
||||
- No dashboard by design (owner decision): raw tables + SELECTs are the
|
||||
source of truth; this script is the weekly read of them.
|
||||
- Default window covers REAL traffic only: traces with source='calibration'
|
||||
and the test traces (chat_id=0) are excluded from the stats (pass
|
||||
`--include-calibration` to see everything; the volume section always shows
|
||||
the full breakdown).
|
||||
- `--check` exits 1 when a warning trips (space/volume growth per plan §4.5 —
|
||||
>5GB or >100k events/month → the «archive, do not delete» session is due —
|
||||
plus Jev failure ratio and model drift) so it can be wired to a scheduler.
|
||||
- Nothing is ever deleted here (no-deletion policy).
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
|
||||
GB = 1024 ** 3
|
||||
TABLE_SIZE_WARN = 5 * GB # plan §4.5: >5GB of observability storage
|
||||
MONTHLY_EVENTS_WARN = 100_000 # plan §4.5: >100k events / month
|
||||
JEV_FAIL_RATIO_WARN = 0.20 # of jev.result events in the window (n>=5)
|
||||
|
||||
# window predicate reused everywhere: --days 0 → all time
|
||||
WIN = "(:days = 0 or created_at >= now() - (:days * interval '1 day'))"
|
||||
WIN_E = "(:days = 0 or e.created_at >= now() - (:days * interval '1 day'))"
|
||||
# real-traffic filter: keep only traces of genuine chats (no calibration, no chat_id=0 tests)
|
||||
REAL = ("trace_id in (select id from bot_traces where coalesce(source,'bale') <> 'calibration' "
|
||||
"and coalesce(chat_id, 0) <> 0)")
|
||||
REAL_E = ("e.trace_id in (select id from bot_traces where coalesce(source,'bale') <> 'calibration' "
|
||||
"and coalesce(chat_id, 0) <> 0)")
|
||||
|
||||
|
||||
def _n(x) -> str:
|
||||
try:
|
||||
return f"{int(x):,}"
|
||||
except (TypeError, ValueError):
|
||||
return "—"
|
||||
|
||||
|
||||
def _human(nbytes: int) -> str:
|
||||
if not nbytes:
|
||||
return "0 B"
|
||||
step = 1024.0
|
||||
v = float(nbytes)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if v < step or unit == "TB":
|
||||
return f"{v:,.1f} {unit}"
|
||||
v /= step
|
||||
return f"{v:,.1f} TB"
|
||||
|
||||
|
||||
def _join(rows, fmt) -> str:
|
||||
return " · ".join(fmt(r) for r in rows) or "—"
|
||||
|
||||
|
||||
async def build(days: int, include_cal: bool = False) -> tuple[list[str], list[str]]:
|
||||
"""Return (digest lines, warnings)."""
|
||||
flt = "" if include_cal else f" and {REAL}"
|
||||
flt_e = "" if include_cal else f" and {REAL_E}"
|
||||
warnings: list[str] = []
|
||||
L: list[str] = []
|
||||
|
||||
async with SessionLocal() as s:
|
||||
async def rows(sql, **params):
|
||||
return (await s.execute(text(sql), {"days": days, **params})).all()
|
||||
|
||||
async def one(sql, **params):
|
||||
return (await s.execute(text(sql), {"days": days, **params})).first()
|
||||
|
||||
# ---------------------------------------------------------- volume (always all traces)
|
||||
by_src = await rows("""
|
||||
select coalesce(source,'?') src, count(*) n from bot_traces
|
||||
where (:days = 0 or created_at >= now() - (:days * interval '1 day'))
|
||||
group by 1 order by n desc
|
||||
""")
|
||||
per_day = await rows(f"""
|
||||
select to_char(date_trunc('day', created_at), 'MM-DD') d, count(*) n
|
||||
from bot_trace_events where {WIN}{flt}
|
||||
group by 1 order by min(created_at)
|
||||
""")
|
||||
total_events = await one(f"select count(*) n from bot_trace_events where {WIN}{flt}")
|
||||
|
||||
L += [f"پنجره: {'از ابتدا' if days == 0 else f'{days} روز اخیر'}"
|
||||
+ ("" if include_cal else " · فقط ترافیک واقعی (بدون calibration/test)"), ""]
|
||||
L.append("- منابع روندها: " + _join(by_src, lambda r: f"{r.src}: {_n(r.n)}"))
|
||||
if per_day:
|
||||
L.append(f"- رخداد{'' if include_cal else ' واقعی'} در روز: "
|
||||
+ _join(per_day, lambda r: f"{r.d}={_n(r.n)}")
|
||||
+ f" (کل پنجره: {_n(total_events.n)})")
|
||||
|
||||
# ---------------------------------------------------------- stages / latency
|
||||
stages = await rows(f"""
|
||||
select stage, count(*) n,
|
||||
count(*) filter (where not ok) fails,
|
||||
round(percentile_cont(0.5) within group (order by latency_ms)) p50,
|
||||
round(percentile_cont(0.95) within group (order by latency_ms)) p95
|
||||
from bot_trace_events where {WIN}{flt}
|
||||
group by 1 order by n desc
|
||||
""")
|
||||
L += ["", "### مرحلهها (stage · تعداد · خطا · p50/p95 ms)"]
|
||||
if not stages:
|
||||
L.append("- —")
|
||||
for r in stages:
|
||||
lat = f" · {r.p50}/{r.p95}" if r.p50 is not None else ""
|
||||
bad = f" · ⚠️ {_n(r.fails)} خطا" if r.fails else ""
|
||||
L.append(f"- `{r.stage}`: {_n(r.n)}{bad}{lat}")
|
||||
fails = [(r.stage, r.fails) for r in stages if r.fails]
|
||||
if fails:
|
||||
warnings.append("خطا در مراحل: " + ", ".join(f"{a}×{b}" for a, b in fails))
|
||||
|
||||
# ---------------------------------------------------------- funnel
|
||||
nlu = await one(f"""
|
||||
select count(*) n, count(*) filter (where ok) ok from bot_trace_events
|
||||
where stage='nlu.result' and {WIN}{flt}
|
||||
""")
|
||||
mentions = await one(f"""
|
||||
select coalesce(sum(jsonb_array_length(payload->'entity_mentions')), 0) n
|
||||
from bot_trace_events
|
||||
where stage='entities.extracted' and jsonb_typeof(payload->'entity_mentions')='array'
|
||||
and {WIN}{flt}
|
||||
""")
|
||||
jev = await one(f"""
|
||||
select count(*) n, count(*) filter (where ok) ok, count(*) filter (where not ok) fail
|
||||
from bot_trace_events where stage='jev.result' and {WIN}{flt}
|
||||
""")
|
||||
route_src = await rows(f"""
|
||||
select coalesce(payload->>'source','?') src, count(*) n from bot_trace_events
|
||||
where stage='routing.decision' and {WIN}{flt} group by 1 order by n desc
|
||||
""")
|
||||
jev_status = await rows(f"""
|
||||
select coalesce(m->>'status','?') st, count(*) n
|
||||
from bot_trace_events e, jsonb_array_elements(e.payload->'mentions') m
|
||||
where e.stage='routing.decision' and e.payload->>'source'='jev' and {WIN_E}{flt_e}
|
||||
group by 1 order by n desc
|
||||
""")
|
||||
bands = await rows(f"""
|
||||
select case
|
||||
when (m->>'confidence')::float >= :ex then 'exact'
|
||||
when (m->>'confidence')::float >= :fl then 'ambiguity'
|
||||
else 'low' end band, count(*) n
|
||||
from bot_trace_events e, jsonb_array_elements(e.payload->'mentions') m
|
||||
where e.stage='routing.decision' and e.payload->>'source'='jev' and {WIN_E}{flt_e}
|
||||
group by 1 order by n desc
|
||||
""", ex=settings.JEV_CONF_EXACT, fl=settings.JEV_CONF_FLOOR)
|
||||
difflib = await rows(f"""
|
||||
select coalesce(payload->>'status','?') st, count(*) n from bot_trace_events
|
||||
where stage='routing.decision' and payload->>'source'='difflib' and {WIN}{flt}
|
||||
group by 1 order by n desc
|
||||
""")
|
||||
commits = await one(f"""
|
||||
select count(*) filter (where stage='draft.commit') committed,
|
||||
count(*) filter (where stage='draft.cancel') cancelled,
|
||||
count(*) filter (where stage='draft.render') rendered
|
||||
from bot_trace_events where {WIN}{flt}
|
||||
""")
|
||||
|
||||
L += ["", "### قیف پردازش"]
|
||||
L.append(f"- NLU (nlu.result): {_n(nlu.n)} — mentionهای استخراجشده: {_n(mentions.n)}")
|
||||
L.append(f"- Jev (jev.result): {_n(jev.n)} درخواست/فیلد — خطا: {_n(jev.fail)}")
|
||||
L.append(f"- مسیریابی: " + _join(route_src, lambda r: f"{r.src}: {_n(r.n)}"))
|
||||
L.append(f"- وضعیت mentionهای Jev: " + _join(jev_status, lambda r: f"{r.st}: {_n(r.n)}"))
|
||||
L.append(f"- باندهای confidence (EXACT={settings.JEV_CONF_EXACT} / FLOOR={settings.JEV_CONF_FLOOR}): "
|
||||
+ _join(bands, lambda r: f"{r.band}: {_n(r.n)}"))
|
||||
L.append(f"- difflib (fallback): " + _join(difflib, lambda r: f"{r.st}: {_n(r.n)}"))
|
||||
L.append(f"- پیشنویس: نمایش {_n(commits.rendered)} · ثبت نهایی {_n(commits.committed)} · لغو {_n(commits.cancelled)}")
|
||||
|
||||
if jev.n and jev.n >= 5 and jev.fail / jev.n > JEV_FAIL_RATIO_WARN:
|
||||
warnings.append(f"نسبت خطای Jev بالا: {jev.fail}/{jev.n}")
|
||||
|
||||
# ---------------------------------------------------------- user choices (quality proxy)
|
||||
user_amb = await rows(f"""
|
||||
select coalesce(payload->>'action','?') act, coalesce(payload->>'arg','-') arg, count(*) n
|
||||
from bot_trace_events where stage='ambiguity.user_choice' and {WIN}{flt}
|
||||
group by 1,2 order by n desc
|
||||
""")
|
||||
picks = await rows(f"""
|
||||
select case when (payload->>'idx')::int = 0 then 'top' else 'other' end k, count(*) n
|
||||
from bot_trace_events where stage='user_choice.pick' and {WIN}{flt}
|
||||
group by 1 order by n desc
|
||||
""")
|
||||
L += ["", "### انتخاب کاربر (پروکسی کیفیت — A/B واقعی Jev↔difflib↔کاربر)"]
|
||||
L.append("- ابهامها: " + (_join(user_amb, lambda r: f"{r.act}({r.arg}): {_n(r.n)}")
|
||||
if user_amb else "— هنوز کاربری در ابهام انتخاب نکرده"))
|
||||
top = sum(r.n for r in picks if r.k == "top")
|
||||
other = sum(r.n for r in picks if r.k == "other")
|
||||
if picks:
|
||||
L.append(f"- دکمههای گزینه: گزینه اول (پیشنهاد مدل) {_n(top)} · گزینه دیگر {_n(other)}")
|
||||
new_rej = sum(r.n for r in user_amb if r.act == "new")
|
||||
if new_rej:
|
||||
L.append(f"- «هیچکدام/جدید» در ابهام: {_n(new_rej)} (پیشنهاد مدل رد شد)")
|
||||
|
||||
# ---------------------------------------------------------- jev health
|
||||
tokens = await one(f"""
|
||||
select coalesce(sum((payload->'usage'->>'input_tokens')::bigint),0) tin,
|
||||
coalesce(sum((payload->'usage'->>'output_tokens')::bigint),0) tout
|
||||
from bot_trace_events
|
||||
where stage='jev.result' and payload->'usage' is not null and {WIN}{flt}
|
||||
""")
|
||||
models = await rows(f"""
|
||||
select coalesce(model,'?') m, count(*) n from bot_trace_events
|
||||
where stage='jev.result' and {WIN}{flt} group by 1 order by n desc
|
||||
""")
|
||||
L += ["", "### سلامت Jev"]
|
||||
L.append(f"- توکن مصرفی (از usage): ورودی {_n(tokens.tin)} · خروجی {_n(tokens.tout)}")
|
||||
L.append(f"- مدلها: " + _join(models, lambda r: f"`{r.m}`×{_n(r.n)}"))
|
||||
drift = [r.m for r in models if r.m not in ("?", settings.JEV_MODEL)]
|
||||
if drift:
|
||||
warnings.append(f"انحراف نسخه مدل Jev: {drift} ≠ پین {settings.JEV_MODEL}")
|
||||
|
||||
# ---------------------------------------------------------- stt health
|
||||
stt = await rows(f"""
|
||||
select coalesce(payload->>'provider','?') p, count(*) n, count(*) filter (where ok) ok,
|
||||
round(percentile_cont(0.5) within group (order by latency_ms)) p50
|
||||
from bot_trace_events where stage='stt.result' and {WIN}{flt} group by 1 order by n desc
|
||||
""")
|
||||
if stt:
|
||||
L += ["", "### STT"]
|
||||
for r in stt:
|
||||
L.append(f"- {r.p}: {_n(r.ok)}/{_n(r.n)} موفق · p50 {r.p50 if r.p50 is not None else '—'} ms")
|
||||
|
||||
# ---------------------------------------------------------- space / archive guard (always all data)
|
||||
sizes = await one("""
|
||||
select pg_total_relation_size('bot_trace_events') ev_bytes,
|
||||
pg_total_relation_size('bot_traces') tr_bytes,
|
||||
pg_total_relation_size('bot_messages') msg_bytes
|
||||
""")
|
||||
month_events = await one("""
|
||||
select count(*) n from bot_trace_events
|
||||
where created_at >= date_trunc('month', now())
|
||||
""")
|
||||
voices_dir = Path(settings.OBS_VOICE_DIR)
|
||||
v_files = v_bytes = 0
|
||||
if voices_dir.exists():
|
||||
for p in voices_dir.rglob("*"):
|
||||
if p.is_file():
|
||||
v_files += 1
|
||||
v_bytes += p.stat().st_size
|
||||
obs_bytes = (sizes.ev_bytes or 0) + (sizes.tr_bytes or 0) + v_bytes
|
||||
|
||||
L += ["", "### فضا و آرشیو (سیاست: آرشیو، نه حذف — پلن §4.5)"]
|
||||
L.append(f"- bot_trace_events: {_human(sizes.ev_bytes or 0)} · bot_traces: {_human(sizes.tr_bytes or 0)} "
|
||||
f"· bot_messages: {_human(sizes.msg_bytes or 0)} · صدای خام: {_human(v_bytes)} ({_n(v_files)} فایل)")
|
||||
L.append(f"- رخدادهای این ماه تقویمی: {_n(month_events.n)}")
|
||||
if obs_bytes > TABLE_SIZE_WARN:
|
||||
warnings.append(f"حجم Observability از ۵GB گذشت: {_human(obs_bytes)} → جلسه آرشیو لازم است")
|
||||
if month_events.n > MONTHLY_EVENTS_WARN:
|
||||
warnings.append(f"رخداد این ماه از ۱۰۰هزار گذشت: {_n(month_events.n)} → جلسه آرشیو لازم است")
|
||||
|
||||
return L, warnings
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Phase-7 monitoring digest")
|
||||
ap.add_argument("--days", type=int, default=7, help="0 = از ابتدا (default: 7)")
|
||||
ap.add_argument("--check", action="store_true", help="exit 1 when a warning trips")
|
||||
ap.add_argument("--include-calibration", action="store_true",
|
||||
help="شامل ترافیک calibration/test در آمار (پیشفرض: فقط ترافیک واقعی)")
|
||||
args = ap.parse_args()
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
|
||||
lines, warnings = await build(args.days, args.include_calibration)
|
||||
|
||||
header = [f"# پایش هفتگی بات — {ts} UTC", ""]
|
||||
if warnings:
|
||||
header += ["## ⚠️ هشدارها", *[f"- {w}" for w in warnings], ""]
|
||||
|
||||
out_dir = Path(settings.OBS_VOICE_DIR).parent / "reports"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
report = out_dir / f"monitoring_{ts}.md"
|
||||
report.write_text("\n".join(header + lines), encoding="utf-8")
|
||||
|
||||
print("\n".join(header + lines))
|
||||
print(f"\nگزارش: {report}")
|
||||
if args.check and warnings:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,533 +0,0 @@
|
||||
"""Mock-data seeder (ADDITIVE + idempotent) for JeV testing and phase-6 calibration.
|
||||
|
||||
docker compose exec bot python tools/seed_mock.py
|
||||
|
||||
Rules:
|
||||
- Never deletes or modifies existing user data; existing rows (by name/key/id)
|
||||
are skipped, so re-runs are safe and the team's own test data is untouched.
|
||||
- Deterministic ids (uuid5) for every created entity → stable cross-references.
|
||||
- Covers: units, brands, attribute definitions + material EAV values, materials,
|
||||
brand/material links, material unit conversions, suppliers (+categories),
|
||||
supplier↔material edges, projects, warehouses, inventory + ledger, and three
|
||||
full quotes: offered / accepted multi-project / arrived (receipt + allocation
|
||||
+ PURCHASE ledger row + decisions + events).
|
||||
- All numeric parameters use Decimal (asyncpg is strict about numeric columns).
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
|
||||
NS = uuid.UUID("6d3ed6a0-9e11-4b2a-8f7e-000000000000")
|
||||
|
||||
|
||||
def uid(*parts: str) -> uuid.UUID:
|
||||
return uuid.uuid5(NS, "|".join(parts))
|
||||
|
||||
|
||||
def D(v) -> Decimal:
|
||||
return Decimal(str(v))
|
||||
|
||||
|
||||
STATS: dict[str, list[int]] = {}
|
||||
|
||||
|
||||
def _bump(kind: str, created: int, skipped: int) -> None:
|
||||
c, s = STATS.get(kind, [0, 0])
|
||||
STATS[kind] = [c + created, s + skipped]
|
||||
|
||||
|
||||
UNITS_EXTRA = [
|
||||
("sh", "شاخه", "count"),
|
||||
("m2", "متر مربع", "area"),
|
||||
("pack", "بسته", "count"),
|
||||
("roll", "رول", "count"),
|
||||
("sheet", "ورق", "count"),
|
||||
]
|
||||
|
||||
BRANDS = [
|
||||
"فولاد مبارکه", "ذوب آهن اصفهان", "سیمان تهران", "سیمان سپاهان",
|
||||
"پلیکا", "البرز", "سرامیک آسیا",
|
||||
]
|
||||
|
||||
ATTR_DEFS = [
|
||||
("spec_قطر", "قطر"),
|
||||
("spec_سایز", "سایز"),
|
||||
("spec_گرید", "گرید"),
|
||||
("spec_ضخامت", "ضخامت"),
|
||||
("spec_بستهبندی", "بستهبندی"),
|
||||
]
|
||||
|
||||
# name, canonical unit, supplier, brand|None, specs[(key,value)], conversions[(unit, factor)]
|
||||
MATERIALS = [
|
||||
("میلگرد آجدار", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
||||
[("spec_سایز", "سایز ۱۴"), ("spec_گرید", "آجدار A3")], [("شاخه", 12), ("تن", 1000)]),
|
||||
("تیرآهن", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
||||
[("spec_سایز", "سایز ۱۸")], [("شاخه", 226)]),
|
||||
("ورق سیاه", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
||||
[("spec_ضخامت", "۲ میل")], [("ورق", "24.6")]),
|
||||
("پروفیل قوطی", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
|
||||
[("spec_سایز", "۴۰×۴۰"), ("spec_ضخامت", "۲ میل")], []),
|
||||
("سیمان پرتلند", "kg", "بازرگانی مصطفوی", "سیمان تهران",
|
||||
[("spec_بستهبندی", "۵۰ کیلویی")], [("کیسه", 50), ("تن", 1000)]),
|
||||
("گچ و خاک", "kg", "بازرگانی مصطفوی", None,
|
||||
[("spec_بستهبندی", "۳۰ کیلویی")], [("کیسه", 30)]),
|
||||
("گچ ساختمانی", "kg", "بازرگانی مصطفوی", None, [], [("کیسه", 30)]),
|
||||
("شن و ماسه", "kg", "بازرگانی مصطفوی", None, [], []),
|
||||
("لوله پلیکا", "m", "پلیکا فروش البرز", "پلیکا",
|
||||
[("spec_قطر", "۶ اینچ")], [("شاخه", 6)]),
|
||||
("لوله مانیسمان", "m", "تجهیزات آبی پارس", None,
|
||||
[("spec_قطر", "۲ اینچ")], []),
|
||||
("سه راهی پلیکا", "piece", "پلیکا فروش البرز", "پلیکا",
|
||||
[("spec_قطر", "۶ اینچ")], []),
|
||||
("شیر فلکه", "piece", "تجهیزات آبی پارس", None,
|
||||
[("spec_سایز", "۲ اینچ")], []),
|
||||
("کاشی سرامیک", "m2", "سرامیک آسیا", "سرامیک آسیا",
|
||||
[("spec_سایز", "۶۰×۶۰")], []),
|
||||
("رنگ روغنی", "kg", "شیمی صنعت سپاهان", None,
|
||||
[("spec_بستهبندی", "گالن ۳.۶ کیلویی")], []),
|
||||
("الکترود جوشکاری", "kg", "ابزار سازان اصفهان", None,
|
||||
[("spec_سایز", "۳.۲")], []),
|
||||
("نایلون شیرینک", "kg", "شیمی صنعت سپاهان", None, [], []),
|
||||
]
|
||||
|
||||
SUPPLIERS = [
|
||||
("بازرگانی مصطفوی", "construction-materials", "اصفهان، خیابان امام", "031-32223344", "حاج آقا مصطفوی"),
|
||||
("نمایندگی فولاد مبارکه", "metals-steel-sections", "اصفهان، شهرک صنعتی", "031-33445566", "مهندس کریمی"),
|
||||
("پلیکا فروش البرز", "mechanical-installations-pipes", "کرج، جاده مخصوص", "026-34567890", "آقای رضایی"),
|
||||
("تجهیزات آبی پارس", "mechanical-installations-pipes", "تهران، بازار آهن", "021-55667788", "آقای شریفی"),
|
||||
("سرامیک آسیا", "finishing-interior-decoration", "یزد، میبد", "035-32345678", "آقای دهقان"),
|
||||
("ابزار سازان اصفهان", "construction-tools-equipment", "اصفهان، بازار", "031-32112233", "آقای نوری"),
|
||||
("شیمی صنعت سپاهان", "construction-chemicals", "اصفهان، شهرک علمی", "031-33889900", "خانم احمدی"),
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
("دفتر", "دفتر مرکزی شرکت"),
|
||||
("منشانه", "پروژه منشانه"),
|
||||
("باغ اسپارت", "پروژه باغ اسپارت"),
|
||||
("پردیس کاشان", "پروژه پردیس کاشان"),
|
||||
("بابک", "پروژه بابک"),
|
||||
]
|
||||
|
||||
|
||||
async def _name_id(s, table: str, name: str):
|
||||
row = (await s.execute(
|
||||
text(f"SELECT id FROM {table} WHERE name = :n LIMIT 1"), {"n": name}
|
||||
)).scalar()
|
||||
return str(row) if row else None
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
async with SessionLocal() as s:
|
||||
# ---------------- units ----------------
|
||||
created = skipped = 0
|
||||
for code, name_fa, dim in UNITS_EXTRA:
|
||||
r = await s.execute(text(
|
||||
"INSERT INTO units (code, name_fa, dimension, si_factor, is_base) "
|
||||
"VALUES (:c, :n, :d, 1, false) ON CONFLICT (code) DO NOTHING"
|
||||
), {"c": code, "n": name_fa, "d": dim})
|
||||
created += r.rowcount
|
||||
skipped += 1 - r.rowcount
|
||||
_bump("units", created, skipped)
|
||||
_unit_rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).all()
|
||||
unit_map = {r.code: r.id for r in _unit_rows}
|
||||
unit_fa = {r.name_fa: r.id for r in _unit_rows}
|
||||
|
||||
def unit_of(ref) -> int:
|
||||
return unit_map.get(ref) or unit_fa[ref]
|
||||
|
||||
# ---------------- brands ----------------
|
||||
created = skipped = 0
|
||||
brand_id: dict[str, str] = {}
|
||||
for b in BRANDS:
|
||||
existing = await _name_id(s, "brands", b)
|
||||
if existing:
|
||||
brand_id[b] = existing
|
||||
skipped += 1
|
||||
continue
|
||||
bid = uid("brand", b)
|
||||
await s.execute(text(
|
||||
"INSERT INTO brands (id, name, country) VALUES (:id, :name, 'ایران') ON CONFLICT (id) DO NOTHING"
|
||||
), {"id": str(bid), "name": b})
|
||||
brand_id[b] = str(bid)
|
||||
created += 1
|
||||
_bump("brands", created, skipped)
|
||||
|
||||
# ---------------- attribute definitions ----------------
|
||||
created = skipped = 0
|
||||
attr_id: dict[str, str] = {}
|
||||
for key, label in ATTR_DEFS:
|
||||
row = (await s.execute(
|
||||
text("SELECT id FROM attribute_definitions WHERE attribute_key=:k"), {"k": key}
|
||||
)).scalar()
|
||||
if row:
|
||||
attr_id[key] = str(row)
|
||||
skipped += 1
|
||||
continue
|
||||
aid = uid("attr", key)
|
||||
await s.execute(text(
|
||||
"INSERT INTO attribute_definitions (id, attribute_key, label, data_type, enum_values) "
|
||||
"VALUES (:id, :k, :l, 'string', NULL) ON CONFLICT (id) DO NOTHING"
|
||||
), {"id": str(aid), "k": key, "l": label})
|
||||
attr_id[key] = str(aid)
|
||||
created += 1
|
||||
_bump("attribute_definitions", created, skipped)
|
||||
|
||||
# ---------------- suppliers ----------------
|
||||
created = skipped = 0
|
||||
cat_map = {r.slug: str(r.id) for r in
|
||||
(await s.execute(text("SELECT id, slug FROM material_categories"))).all()}
|
||||
supplier_id: dict[str, str] = {}
|
||||
for name, slug, location, phone, contact in SUPPLIERS:
|
||||
existing = await _name_id(s, "suppliers", name)
|
||||
if existing:
|
||||
supplier_id[name] = existing
|
||||
skipped += 1
|
||||
continue
|
||||
sid = uid("supplier", name)
|
||||
await s.execute(text(
|
||||
"INSERT INTO suppliers (id, name, category_id, location, phone, contact_person) "
|
||||
"VALUES (:id, :name, :cat, :loc, :phone, :contact) ON CONFLICT (id) DO NOTHING"
|
||||
), {"id": str(sid), "name": name, "cat": cat_map.get(slug), "loc": location,
|
||||
"phone": phone, "contact": contact})
|
||||
supplier_id[name] = str(sid)
|
||||
created += 1
|
||||
_bump("suppliers", created, skipped)
|
||||
|
||||
# ---------------- materials (+ links, EAV, conversions) ----------------
|
||||
created = skipped = 0
|
||||
material_id: dict[str, str] = {}
|
||||
brand_material_of: dict[str, str | None] = {}
|
||||
brand_of: dict[str, str | None] = {}
|
||||
for name, unit_code, sup_name, brand, specs, convs in MATERIALS:
|
||||
mid = await _name_id(s, "materials", name)
|
||||
if mid is None:
|
||||
mid = str(uid("material", name))
|
||||
await s.execute(text(
|
||||
"INSERT INTO materials (id, name, canonical_unit_id, status) "
|
||||
"VALUES (:id, :name, :unit, 'active') ON CONFLICT (id) DO NOTHING"
|
||||
), {"id": mid, "name": name, "unit": unit_map[unit_code]})
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
material_id[name] = mid
|
||||
|
||||
for key, value in specs:
|
||||
await s.execute(text(
|
||||
"INSERT INTO material_attribute_values "
|
||||
"(id, material_id, attribute_definition_id, value_string) "
|
||||
"VALUES (:id, :m, :a, :v) "
|
||||
"ON CONFLICT (material_id, attribute_definition_id) DO NOTHING"
|
||||
), {"id": str(uid("mav", name, key)), "m": mid, "a": attr_id[key], "v": value})
|
||||
|
||||
if brand:
|
||||
bmid = str(uid("bm", brand, name))
|
||||
await s.execute(text(
|
||||
"INSERT INTO brand_materials (id, brand_id, material_id, is_active) "
|
||||
"VALUES (:id, :b, :m, true) ON CONFLICT (brand_id, material_id) DO NOTHING"
|
||||
), {"id": bmid, "b": brand_id[brand], "m": mid})
|
||||
brand_material_of[name] = bmid
|
||||
brand_of[name] = brand_id[brand]
|
||||
else:
|
||||
brand_material_of[name] = None
|
||||
brand_of[name] = None
|
||||
|
||||
for conv_code, factor in convs:
|
||||
exists = (await s.execute(text(
|
||||
"SELECT 1 FROM material_units WHERE material_id=:m AND brand_material_id IS NULL "
|
||||
"AND unit_id=:u LIMIT 1"
|
||||
), {"m": mid, "u": unit_of(conv_code)})).scalar()
|
||||
if exists:
|
||||
continue
|
||||
await s.execute(text(
|
||||
"INSERT INTO material_units (id, material_id, brand_material_id, unit_id, "
|
||||
"conversion_factor, is_default) VALUES (:id, :m, NULL, :u, :f, false)"
|
||||
), {"id": str(uid("mu", name, conv_code)), "m": mid,
|
||||
"u": unit_of(conv_code), "f": D(factor)})
|
||||
|
||||
await s.execute(text(
|
||||
"INSERT INTO supplier_materials (id, supplier_id, material_id, is_active, "
|
||||
"last_quoted_price, last_quoted_at) VALUES (:id, :sup, :m, true, :price, :at) "
|
||||
"ON CONFLICT (supplier_id, material_id) DO NOTHING"
|
||||
), {"id": str(uid("sm", sup_name, name)), "sup": supplier_id[sup_name],
|
||||
"m": mid, "price": D("100000"), "at": now})
|
||||
_bump("materials", created, skipped)
|
||||
|
||||
# ---------------- projects + warehouses ----------------
|
||||
created = skipped = 0
|
||||
project_id: dict[str, str] = {}
|
||||
for name, desc in PROJECTS:
|
||||
pid = await _name_id(s, "projects", name)
|
||||
if pid is None:
|
||||
pid = str(uid("project", name))
|
||||
await s.execute(text(
|
||||
"INSERT INTO projects (id, name, description) VALUES (:id, :name, :desc) "
|
||||
"ON CONFLICT (id) DO NOTHING"
|
||||
), {"id": pid, "name": name, "desc": desc})
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
project_id[name] = pid
|
||||
_bump("projects", created, skipped)
|
||||
|
||||
created = skipped = 0
|
||||
warehouse_id: dict[tuple[str, str], str] = {}
|
||||
for pname, pid in project_id.items():
|
||||
for wname in ("انبار مرکزی", "انبار کارگاه"):
|
||||
wid = str(uid("wh", pname, wname))
|
||||
exists = (await s.execute(text(
|
||||
"SELECT 1 FROM warehouses WHERE id=:id LIMIT 1"), {"id": wid})).scalar()
|
||||
if exists:
|
||||
skipped += 1
|
||||
else:
|
||||
await s.execute(text(
|
||||
"INSERT INTO warehouses (id, project_id, name, description) "
|
||||
"VALUES (:id, :p, :n, :d)"
|
||||
), {"id": wid, "p": pid, "n": wname, "d": f"{wname} پروژه {pname}"})
|
||||
created += 1
|
||||
warehouse_id[(pname, wname)] = wid
|
||||
_bump("warehouses", created, skipped)
|
||||
|
||||
# ---------------- inventory + ledger ----------------
|
||||
inv_plan = [
|
||||
("منشانه", "انبار مرکزی", "گچ و خاک", None, "200", "kg"),
|
||||
("منشانه", "انبار کارگاه", "سیمان پرتلند", "سیمان تهران", "150", "kg"),
|
||||
("بابک", "انبار مرکزی", "میلگرد آجدار", "فولاد مبارکه", "800", "kg"),
|
||||
("پردیس کاشان", "انبار مرکزی", "لوله پلیکا", "پلیکا", "60", "m"),
|
||||
]
|
||||
created = skipped = 0
|
||||
for pname, wname, mname, bname, qty, unit_code in inv_plan:
|
||||
iid = str(uid("inv", pname, wname, mname))
|
||||
exists = (await s.execute(text(
|
||||
"SELECT 1 FROM inventory WHERE id=:id LIMIT 1"), {"id": iid})).scalar()
|
||||
if exists:
|
||||
skipped += 1
|
||||
continue
|
||||
await s.execute(text(
|
||||
"INSERT INTO inventory (id, warehouse_id, material_id, brand_id, quantity, unit_id, "
|
||||
"specs, specs_hash) VALUES (:id, :w, :m, :b, :q, :u, '{}'::jsonb, 'default')"
|
||||
), {"id": iid, "w": warehouse_id[(pname, wname)], "m": material_id[mname],
|
||||
"b": brand_id.get(bname) if bname else None, "q": D(qty), "u": unit_map[unit_code]})
|
||||
await s.execute(text(
|
||||
"INSERT INTO inventory_transactions (id, inventory_id, warehouse_id, transaction_type, "
|
||||
"quantity, performed_by, transaction_date, note) "
|
||||
"VALUES (:id, :inv, :w, 'PURCHASE', :q, 'کاربر آزمایشی', :at, 'موجودی اولیه (seed)')"
|
||||
), {"id": str(uid("tx", iid, "purchase")), "inv": iid,
|
||||
"w": warehouse_id[(pname, wname)], "q": D(qty), "at": now - timedelta(days=20)})
|
||||
created += 1
|
||||
_bump("inventory", created, skipped)
|
||||
|
||||
# ---------------- quotes ----------------
|
||||
created = skipped = 0
|
||||
admin_uid = (await s.execute(text(
|
||||
"SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1"
|
||||
))).scalar()
|
||||
admin_uid = str(admin_uid) if admin_uid else None
|
||||
|
||||
async def insert_quote_block(qkey, supplier, projects, status, quote_date, expiry,
|
||||
accepted, arrived, is_official, notes, items, termsheets,
|
||||
decisions_map, events, receipts=None, legacy_project=None):
|
||||
qid = str(uid("quote", qkey))
|
||||
exists = (await s.execute(text("SELECT 1 FROM quotes WHERE id=:id"), {"id": qid})).scalar()
|
||||
if exists:
|
||||
return False
|
||||
await s.execute(text(
|
||||
"INSERT INTO quotes (id, supplier_id, project_id, quote_date, expiry_date, "
|
||||
"accepted_date, arrived_date, status, is_official, notes) VALUES "
|
||||
"(:id, :sup, :proj, :qd, :ed, :ad, :ar, CAST(:st AS quote_status), :off, :notes)"
|
||||
), {"id": qid, "sup": supplier_id[supplier],
|
||||
"proj": project_id.get(legacy_project) if legacy_project else None,
|
||||
"qd": quote_date, "ed": expiry, "ad": accepted, "ar": arrived,
|
||||
"st": status, "off": is_official, "notes": notes})
|
||||
for p in projects:
|
||||
await s.execute(text(
|
||||
"INSERT INTO quote_projects (quote_id, project_id) VALUES (:q, :p) "
|
||||
"ON CONFLICT DO NOTHING"), {"q": qid, "p": project_id[p]})
|
||||
|
||||
ts_ids: dict[str, str] = {}
|
||||
for ts in termsheets:
|
||||
tid = str(uid("ts", qkey, ts["label"]))
|
||||
ts_ids[ts["label"]] = tid
|
||||
await s.execute(text(
|
||||
"INSERT INTO termsheets (id, quote_id, label, status, discount_type, discount_value) "
|
||||
"VALUES (:id, :q, :l, CAST(:st AS termsheet_status), CAST(:dt AS discount_type), :dv)"
|
||||
), {"id": tid, "q": qid, "l": ts["label"], "st": ts["status"],
|
||||
"dt": ts.get("discount_type", "none"), "dv": D(ts.get("discount_value", 0))})
|
||||
for i, (due, amt, note) in enumerate(ts["steps"], start=1):
|
||||
await s.execute(text(
|
||||
"INSERT INTO termsheet_schedule_steps (id, termsheet_id, step_no, due_date, "
|
||||
"amount, note) VALUES (:id, :ts, :no, :due, :amt, :note)"
|
||||
), {"id": str(uid("step", qkey, ts["label"], str(i))), "ts": tid, "no": i,
|
||||
"due": due, "amt": D(amt), "note": note})
|
||||
|
||||
line_ids: dict[str, str] = {}
|
||||
its_ids: dict[tuple[str, str], str] = {}
|
||||
for it in items:
|
||||
lid = str(uid("li", qkey, it["material"]))
|
||||
line_ids[it["material"]] = lid
|
||||
await s.execute(text(
|
||||
"INSERT INTO line_items (id, quote_id, material_id, brand_material_id, unit_id, "
|
||||
"quantity, notes) VALUES (:id, :q, :m, :bm, :u, :qty, NULL)"
|
||||
), {"id": lid, "q": qid, "m": material_id[it["material"]],
|
||||
"bm": brand_material_of.get(it["material"]), "u": unit_map[it["unit"]],
|
||||
"qty": D(it["qty"])})
|
||||
for p in it.get("projects", projects):
|
||||
await s.execute(text(
|
||||
"INSERT INTO line_item_projects (line_item_id, project_id) VALUES (:li, :p) "
|
||||
"ON CONFLICT DO NOTHING"), {"li": lid, "p": project_id[p]})
|
||||
if it.get("price") is not None:
|
||||
tlabel = it.get("termsheet", termsheets[0]["label"])
|
||||
iid = str(uid("it", qkey, it["material"], tlabel))
|
||||
its_ids[(it["material"], tlabel)] = iid
|
||||
await s.execute(text(
|
||||
"INSERT INTO item_termsheets (id, line_item_id, termsheet_id, unit_price, "
|
||||
"min_order_qty) VALUES (:id, :li, :ts, :price, NULL)"
|
||||
), {"id": iid, "li": lid, "ts": ts_ids[tlabel], "price": D(it["price"])})
|
||||
|
||||
dec_ids: dict[str, str] = {}
|
||||
for mname, dec in decisions_map.items():
|
||||
did = str(uid("dec", qkey, mname))
|
||||
chosen_its = (its_ids.get((mname, dec.get("termsheet")))
|
||||
if dec.get("status") == "chosen" else None)
|
||||
await s.execute(text(
|
||||
"INSERT INTO item_decisions (id, line_item_id, chosen_item_termsheet_id, status, "
|
||||
"project_id, adjusted_unit_price, decided_at) VALUES "
|
||||
"(:id, :li, :its, CAST(:st AS item_decision_status), :proj, :price, :at)"
|
||||
), {"id": did, "li": line_ids[mname], "its": chosen_its, "st": dec["status"],
|
||||
"proj": project_id.get(dec.get("project")) if dec.get("project") else None,
|
||||
"price": D(dec["price"]) if dec.get("price") is not None else None,
|
||||
"at": accepted if dec.get("status") == "chosen" else None})
|
||||
dec_ids[mname] = did
|
||||
|
||||
for ev in events:
|
||||
at = {"offered": quote_date, "accepted": accepted, "arrived": arrived}.get(ev) or quote_date
|
||||
await s.execute(text(
|
||||
"INSERT INTO quote_events (id, quote_id, event_type, payload, occurred_at, actor) "
|
||||
"VALUES (:id, :q, CAST(:ev AS quote_event_type), '{}'::jsonb, :at, 'کاربر آزمایشی (seed)')"
|
||||
), {"id": str(uid("ev", qkey, ev)), "q": qid, "ev": ev, "at": at})
|
||||
|
||||
for rc in receipts or []:
|
||||
rid = str(uid("rc", qkey, rc["material"]))
|
||||
await s.execute(text(
|
||||
"INSERT INTO item_receipts (id, item_decision_id, confirmed_by, received_at, "
|
||||
"received_qty, on_time, quality_note) VALUES (:id, :d, :by, :at, :qty, true, NULL)"
|
||||
), {"id": rid, "d": dec_ids[rc["material"]], "by": admin_uid,
|
||||
"at": arrived, "qty": D(rc["qty"])})
|
||||
for wname, alloc_qty in rc["allocations"]:
|
||||
aid = str(uid("alloc", qkey, rc["material"], wname))
|
||||
wid = warehouse_id[(rc["project"], wname)]
|
||||
await s.execute(text(
|
||||
"INSERT INTO item_receipt_allocations (id, receipt_id, warehouse_id, quantity) "
|
||||
"VALUES (:id, :r, :w, :q)"
|
||||
), {"id": aid, "r": rid, "w": wid, "q": D(alloc_qty)})
|
||||
inv_row = (await s.execute(text(
|
||||
"SELECT id FROM inventory WHERE warehouse_id=:w AND material_id=:m "
|
||||
"AND COALESCE(brand_id, '00000000-0000-0000-0000-000000000000') = "
|
||||
"COALESCE(CAST(:b AS uuid), '00000000-0000-0000-0000-000000000000') "
|
||||
"AND specs_hash='default' LIMIT 1"
|
||||
), {"w": wid, "m": material_id[rc["material"]],
|
||||
"b": brand_of.get(rc["material"])})).scalar()
|
||||
qty_canonical = D(alloc_qty) * D(rc.get("factor", 1))
|
||||
if inv_row is None:
|
||||
inv_id = str(uid("inv", rc["project"], wname, rc["material"]))
|
||||
await s.execute(text(
|
||||
"INSERT INTO inventory (id, warehouse_id, material_id, brand_id, quantity, "
|
||||
"unit_id, specs, specs_hash) "
|
||||
"VALUES (:id, :w, :m, :b, :q, :u, '{}'::jsonb, 'default')"
|
||||
), {"id": inv_id, "w": wid, "m": material_id[rc["material"]],
|
||||
"b": brand_of.get(rc["material"]), "q": qty_canonical,
|
||||
"u": unit_map[rc.get("canonical", "kg")]})
|
||||
else:
|
||||
inv_id = str(inv_row)
|
||||
await s.execute(text(
|
||||
"UPDATE inventory SET quantity = quantity + :q WHERE id=:id"
|
||||
), {"q": qty_canonical, "id": inv_id})
|
||||
await s.execute(text(
|
||||
"INSERT INTO inventory_transactions (id, inventory_id, warehouse_id, "
|
||||
"receipt_allocation_id, transaction_type, quantity, performed_by, "
|
||||
"transaction_date, note) VALUES (:id, :inv, :w, :alloc, 'PURCHASE', :q, "
|
||||
"'کاربر آزمایشی', :at, :note)"
|
||||
), {"id": str(uid("tx", qkey, rc["material"], wname)), "inv": inv_id,
|
||||
"w": wid, "alloc": aid, "q": qty_canonical, "at": arrived,
|
||||
"note": f"ورود از رسید خرید {rc['material']}"})
|
||||
return True
|
||||
|
||||
d_today = date(2026, 10, 12)
|
||||
|
||||
ok1 = await insert_quote_block(
|
||||
"q1", "پلیکا فروش البرز", ["پردیس کاشان"], "offered",
|
||||
now - timedelta(days=2), d_today + timedelta(days=12), None, None, True,
|
||||
"[mock] استعلام لوله و اتصالات پردیس",
|
||||
items=[
|
||||
{"material": "لوله پلیکا", "unit": "sh", "qty": "120", "price": "450000"},
|
||||
{"material": "سه راهی پلیکا", "unit": "piece", "qty": "40", "price": "85000"},
|
||||
],
|
||||
termsheets=[{"label": "نقد امروز (۵٪ تخفیف)", "status": "offered",
|
||||
"discount_type": "percent", "discount_value": "5",
|
||||
"steps": [(d_today, "60000000", "تسویه نقدی یکباره")]}],
|
||||
decisions_map={"لوله پلیکا": {"status": "pending"}, "سه راهی پلیکا": {"status": "pending"}},
|
||||
events=["offered"],
|
||||
legacy_project="پردیس کاشان",
|
||||
)
|
||||
ok2 = await insert_quote_block(
|
||||
"q2", "بازرگانی مصطفوی", ["منشانه", "دفتر"], "accepted",
|
||||
now - timedelta(days=9), d_today + timedelta(days=5),
|
||||
now - timedelta(days=6), None, False,
|
||||
"[mock] خرید گچ و سیمان پروژههای منشانه و دفتر",
|
||||
items=[
|
||||
{"material": "گچ و خاک", "unit": "bag", "qty": "100", "price": "85000",
|
||||
"termsheet": "سه قسط ماهانه", "projects": ["منشانه"]},
|
||||
{"material": "سیمان پرتلند", "unit": "bag", "qty": "50", "price": "185000",
|
||||
"termsheet": "سه قسط ماهانه", "projects": ["دفتر"]},
|
||||
],
|
||||
termsheets=[{"label": "سه قسط ماهانه", "status": "accepted",
|
||||
"steps": [(d_today, "5875000", "قسط اول"),
|
||||
(d_today + timedelta(days=30), "5875000", "قسط دوم"),
|
||||
(d_today + timedelta(days=60), "5875000", "قسط سوم")]}],
|
||||
decisions_map={
|
||||
"گچ و خاک": {"status": "chosen", "project": "منشانه",
|
||||
"termsheet": "سه قسط ماهانه", "price": "85000"},
|
||||
"سیمان پرتلند": {"status": "chosen", "project": "دفتر",
|
||||
"termsheet": "سه قسط ماهانه", "price": "185000"},
|
||||
},
|
||||
events=["offered", "accepted"],
|
||||
legacy_project="منشانه",
|
||||
)
|
||||
ok3 = await insert_quote_block(
|
||||
"q3", "نمایندگی فولاد مبارکه", ["بابک"], "arrived",
|
||||
now - timedelta(days=15), d_today - timedelta(days=1),
|
||||
now - timedelta(days=12), now - timedelta(days=3), True,
|
||||
"[mock] میلگرد پروژه بابک (رسید کامل)",
|
||||
items=[{"material": "میلگرد آجدار", "unit": "ton", "qty": "2", "price": "42000000"}],
|
||||
termsheets=[{"label": "نقد امروز", "status": "accepted",
|
||||
"steps": [(d_today, "84000000", "تسویه")]}],
|
||||
decisions_map={"میلگرد آجدار": {"status": "chosen", "project": "بابک",
|
||||
"termsheet": "نقد امروز", "price": "42000000"}},
|
||||
events=["offered", "accepted", "arrived"],
|
||||
receipts=[{"material": "میلگرد آجدار", "qty": "2", "project": "بابک",
|
||||
"allocations": [("انبار مرکزی", "2")], "factor": 1000,
|
||||
"canonical": "kg"}],
|
||||
legacy_project="بابک",
|
||||
)
|
||||
_bump("quotes", sum(1 for x in (ok1, ok2, ok3) if x),
|
||||
sum(1 for x in (ok1, ok2, ok3) if not x))
|
||||
|
||||
await s.commit()
|
||||
|
||||
print("\n=== seed summary (created / skipped) ===")
|
||||
for kind, (c, sk) in STATS.items():
|
||||
print(f" {kind:22} created={c:3} skipped={sk:3}")
|
||||
print("done. Nothing was deleted or modified.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
@@ -1,82 +0,0 @@
|
||||
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())
|
||||
+21
-128
@@ -24,7 +24,7 @@ try:
|
||||
except ImportError:
|
||||
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
|
||||
|
||||
CHAT = 1461166742
|
||||
CHAT = 100200300
|
||||
|
||||
_passed = _failed = 0
|
||||
|
||||
@@ -59,9 +59,6 @@ class FakeBale:
|
||||
"reply_markup": reply_markup})
|
||||
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):
|
||||
self.answered.append(callback_query_id)
|
||||
|
||||
@@ -85,7 +82,6 @@ class FakeApi:
|
||||
self.created_quotes: list[dict] = []
|
||||
self.created_suppliers: list[dict] = []
|
||||
self.created_materials: list[dict] = []
|
||||
self.created_projects: list[dict] = []
|
||||
self.accept_payloads: list[dict] = []
|
||||
self.arrive_payloads: list[dict] = []
|
||||
self.login_calls: list[dict] = []
|
||||
@@ -123,10 +119,6 @@ class FakeApi:
|
||||
async def list_projects(self, page_size=100):
|
||||
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):
|
||||
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
||||
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
||||
@@ -188,11 +180,10 @@ def _install_fake_stores() -> None:
|
||||
import app.drafts as drafts
|
||||
import app.runtime as runtime
|
||||
|
||||
async def create(chat_id, kind, payload, source=None, source_message_id=None):
|
||||
async def create(chat_id, kind, payload, source=None):
|
||||
draft_id = _uuid.uuid4()
|
||||
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
||||
missing=None, status="active", source=source,
|
||||
source_message_id=source_message_id,
|
||||
review_message_id=None)
|
||||
MEM["drafts"][str(draft_id)] = draft
|
||||
return draft
|
||||
@@ -202,33 +193,16 @@ def _install_fake_stores() -> None:
|
||||
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
|
||||
return max(found, key=lambda d: d.id) if found else None
|
||||
|
||||
async def get_by_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):
|
||||
for draft in MEM["drafts"].values():
|
||||
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
||||
return draft
|
||||
return None
|
||||
|
||||
async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False):
|
||||
async def save_payload(draft, missing=None, review_message_id=None):
|
||||
draft.missing = missing
|
||||
if review_message_id:
|
||||
draft.review_message_id = review_message_id
|
||||
if source_message_id:
|
||||
draft.source_message_id = source_message_id
|
||||
|
||||
async def set_status(draft_id, status):
|
||||
if draft_id in MEM["drafts"]:
|
||||
@@ -238,7 +212,6 @@ def _install_fake_stores() -> None:
|
||||
return 0
|
||||
|
||||
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
|
||||
|
||||
async def link_get(self, chat_id):
|
||||
@@ -272,10 +245,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -293,17 +262,11 @@ def build_ctx():
|
||||
ctx._log = types.MethodType(fake_log, ctx)
|
||||
H.understand = fake_understand
|
||||
H.transcribe = fake_transcribe
|
||||
H.transcribe_multi = fake_transcribe_multi
|
||||
return ctx, bale, api
|
||||
|
||||
|
||||
def text_update(update_id, text, reply_to=None, chat_id=None, from_user=None):
|
||||
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 text_update(update_id, text):
|
||||
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
|
||||
|
||||
|
||||
def voice_update(update_id):
|
||||
@@ -320,72 +283,36 @@ async def main() -> int:
|
||||
_install_fake_stores()
|
||||
from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload
|
||||
import app.handlers as H
|
||||
from app.nlu import _extract_json, normalize_digits, understand
|
||||
from app.config import settings
|
||||
from app.nlu import _extract_json, normalize_digits
|
||||
|
||||
# ---- pure logic -------------------------------------------------------
|
||||
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
|
||||
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
|
||||
|
||||
# ---- 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()
|
||||
merge_quote_patch(payload, {
|
||||
"supplier_name": "سازان نو",
|
||||
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||
"payment_terms": [{"label": "۳ فاز"}],
|
||||
})
|
||||
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}],
|
||||
})
|
||||
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]})
|
||||
check("merge: no dup item", len(payload["items"]) == 1)
|
||||
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
|
||||
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
||||
missing = compute_quote_missing(payload)
|
||||
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||
body, problems = build_quote_request(payload)
|
||||
check("build: blocked without supplier", body is None)
|
||||
|
||||
# ---- pipeline: whitelist gate & unregistered blocking -----------------
|
||||
# ---- pipeline: linking gate -------------------------------------------
|
||||
ctx, bale, api = build_ctx()
|
||||
UNAUTH_CHAT = 999999999
|
||||
from app.handlers import UNREGISTERED_FILE
|
||||
if UNREGISTERED_FILE.exists():
|
||||
try:
|
||||
UNREGISTERED_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
await H.handle_update(ctx, text_update(1, "سلام"))
|
||||
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
|
||||
|
||||
# 1. Unregistered user is blocked and recorded
|
||||
await H.handle_update(
|
||||
ctx,
|
||||
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())
|
||||
await H.handle_update(ctx, text_update(2, "mohaghegh"))
|
||||
await H.handle_update(ctx, text_update(3, "secret"))
|
||||
check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh")
|
||||
check("link: linked greeting", "اتصال انجام شد" in bale.last_text())
|
||||
|
||||
# ---- voice → draft quote ----------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
|
||||
@@ -416,7 +343,7 @@ async def main() -> int:
|
||||
# refine price via follow-up text
|
||||
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
||||
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
||||
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
|
||||
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد"))
|
||||
draft = await _active("quote")
|
||||
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
||||
|
||||
@@ -490,48 +417,14 @@ async def main() -> int:
|
||||
check("material: posted", api.created_materials
|
||||
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 --------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
||||
await H.handle_update(ctx, text_update(22, "استعلام از سازان نو"))
|
||||
await H.handle_update(ctx, text_update(23, "/cancel"))
|
||||
await H.handle_update(ctx, text_update(18, "استعلام از سازان نو"))
|
||||
await H.handle_update(ctx, text_update(19, "/cancel"))
|
||||
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
||||
|
||||
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
||||
await H.handle_update(ctx, text_update(24, "اسپاگتی بپز"))
|
||||
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز"))
|
||||
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
||||
|
||||
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Phase-2 tests for the Jev client — MOCKED HTTP (no network, no key needed).
|
||||
|
||||
docker compose exec bot python tools/test_jev.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import jev
|
||||
from app.config import settings
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, extra: str = "") -> None:
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
|
||||
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, status: int, body: dict):
|
||||
self.status_code = status
|
||||
self._body = body
|
||||
self.text = str(body)
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Scripted httpx.AsyncClient stand-in: pops one (status, body) per POST."""
|
||||
|
||||
script: list = []
|
||||
posts: int = 0
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
FakeClient.posts += 1
|
||||
status, body = FakeClient.script.pop(0)
|
||||
return FakeResp(status, body)
|
||||
|
||||
|
||||
Q = {"m0": {"type": "choice", "instructions": "i", "criteria": {"x": ""}}}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
orig_client = jev.httpx.AsyncClient
|
||||
saved_key = settings.TYPESAFE_API_KEY
|
||||
jev.httpx.AsyncClient = FakeClient
|
||||
try:
|
||||
# 1) no key -> None (no HTTP at all)
|
||||
settings.TYPESAFE_API_KEY = ""
|
||||
FakeClient.posts = 0
|
||||
r = await jev.ask({"s": 1}, Q)
|
||||
check("no api key -> None (no request)", r is None and FakeClient.posts == 0)
|
||||
|
||||
settings.TYPESAFE_API_KEY = saved_key or "test-key"
|
||||
|
||||
# 2) happy path: answers + model + usage + latency
|
||||
FakeClient.script = [(200, {"model": "jev-1.13.0",
|
||||
"answers": {"m0": {"choice": "x", "confidence": 0.9,
|
||||
"probabilities": {"x": 0.9}}},
|
||||
"usage": {"input_tokens": 10, "output_tokens": 2}})]
|
||||
r = await jev.ask({"s": 1}, Q)
|
||||
ok = bool(r) and r["answers"]["m0"]["choice"] == "x" and r["model"] == "jev-1.13.0" \
|
||||
and r["usage"]["input_tokens"] == 10 and isinstance(r["latency_ms"], int)
|
||||
check("happy path -> answers/model/usage/latency", ok)
|
||||
|
||||
# 3) 429 -> one retry, then success
|
||||
FakeClient.script = [(429, {"error": "rate_limited"}),
|
||||
(200, {"model": "jev-1.13.0", "answers": {}, "usage": {}})]
|
||||
FakeClient.posts = 0
|
||||
r = await jev.ask({"s": 1}, Q)
|
||||
check("429 retried once then succeeds", r is not None and FakeClient.posts == 2)
|
||||
|
||||
# 4) hard 400 -> None, no retry
|
||||
FakeClient.script = [(400, {"error": "bad_request"})]
|
||||
FakeClient.posts = 0
|
||||
r = await jev.ask({"s": 1}, Q)
|
||||
check("400 -> None without retry", r is None and FakeClient.posts == 1)
|
||||
|
||||
# 5) exception -> retried once -> None
|
||||
class BoomClient(FakeClient):
|
||||
async def post(self, url, json=None, headers=None):
|
||||
FakeClient.posts += 1
|
||||
raise RuntimeError("net down")
|
||||
|
||||
jev.httpx.AsyncClient = BoomClient
|
||||
FakeClient.posts = 0
|
||||
r = await jev.ask({"s": 1}, Q)
|
||||
check("exception retried once -> None", r is None and FakeClient.posts == 2)
|
||||
finally:
|
||||
jev.httpx.AsyncClient = orig_client
|
||||
settings.TYPESAFE_API_KEY = saved_key
|
||||
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(f"\n{len(PASSED)}/{total} passed")
|
||||
if FAILED:
|
||||
print("FAILED:", FAILED)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Phase 3-5 tests for entity matching (mock by default; live with RUN_LIVE=1).
|
||||
|
||||
docker compose exec bot python tools/test_matching.py # mock only
|
||||
docker compose exec -e RUN_LIVE=1 bot python tools/test_matching.py # + real NLU + Jev
|
||||
|
||||
The live section calls DeepSeek (NLU) and TypeSafe (Jev) with the REAL keys
|
||||
from bot/.env — it never sends anything to Bale and never creates drafts.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import matching
|
||||
from app.config import settings
|
||||
from app.nlu import normalize_text
|
||||
from app.resolve import Candidate
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, extra: str = "") -> None:
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
|
||||
|
||||
|
||||
class FakeResolver:
|
||||
async def projects(self):
|
||||
return [Candidate(id="p1", name="دفتر"), Candidate(id="p2", name="منشانه")]
|
||||
|
||||
async def all_suppliers(self):
|
||||
return [Candidate(id="s1", name="بازرگانی مصطفوی",
|
||||
extra={"location": "اصفهان", "category_name": "مصالح"})]
|
||||
|
||||
async def all_materials(self):
|
||||
return [Candidate(id="m1", name="گچ و خاک", extra={"canonical_unit_id": 2})]
|
||||
|
||||
async def brands(self):
|
||||
return [Candidate(id="b1", name="سیمان تهران")]
|
||||
|
||||
async def specs(self):
|
||||
return [Candidate(id="سایز ۱۴", name="سایز ۱۴")]
|
||||
|
||||
async def units(self):
|
||||
return [Candidate(id="u1", name="کیسه"), Candidate(id="u2", name="عدد")]
|
||||
|
||||
|
||||
def make_fake_ask(answers_by_mention: dict, model: str = "jev-1.13.0"):
|
||||
async def fake_ask(state, questions):
|
||||
if fake_ask.fail:
|
||||
return None
|
||||
return {"answers": answers_by_mention, "usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
"model": model, "latency_ms": 7}
|
||||
fake_ask.fail = False
|
||||
return fake_ask
|
||||
|
||||
|
||||
async def mock_tests() -> None:
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
orig_ask = matching.jev_ask
|
||||
|
||||
# 1) routing bands: exact / ambiguity / low-trust / new + variant grouping
|
||||
matching.jev_ask = make_fake_ask({
|
||||
"mention_0": {"choice": "منشانه", "confidence": 0.9,
|
||||
"probabilities": {"منشانه": 0.95, "_none": 0.05}},
|
||||
"mention_1": {"choice": "دفتر", "confidence": 0.7,
|
||||
"probabilities": {"دفتر": 0.73, "منشانه": 0.15, "_none": 0.12}},
|
||||
"mention_2": {"choice": "_none", "confidence": 0.9,
|
||||
"probabilities": {"_none": 0.94}},
|
||||
"mention_3": {"choice": "دفتر", "confidence": 0.5,
|
||||
"probabilities": {"دفتر": 0.52, "_none": 0.48}},
|
||||
})
|
||||
mentions = [
|
||||
{"field": "project", "variants": ["منشیانه"]},
|
||||
{"field": "project", "variants": ["دفتر", "دفر"]},
|
||||
{"field": "project", "variants": ["چیز عجیب"]},
|
||||
{"field": "project", "variants": ["دفر دو"]},
|
||||
]
|
||||
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
|
||||
r0 = matching.lookup(res, "منشیانه")
|
||||
r1 = matching.lookup(res, "دفر") # variant key resolves too
|
||||
r2 = matching.lookup(res, "چیز عجیب")
|
||||
check("exact band -> status exact + id", r0 and r0["status"] == "exact" and r0["id"] == "p2")
|
||||
check("mid band -> ambiguity + candidates", r1 and r1["status"] == "ambiguity"
|
||||
and r1["candidates"][0]["name"] == "دفتر")
|
||||
check("variant of same mention shares one result", r1 is r0 if False else
|
||||
(r1 and matching.lookup(res, "دفتر") is r1))
|
||||
check("_none / low conf -> new", r2 and r2["status"] == "new")
|
||||
check("below-floor pick -> NO result (difflib fallback keeps its chance)",
|
||||
matching.lookup(res, "دفر دو") is None)
|
||||
check("below-floor pick did not pollute other mentions", r1 and r1["status"] == "ambiguity")
|
||||
|
||||
# 2) Jev failure -> empty map (callers fall back to difflib)
|
||||
matching.jev_ask = make_fake_ask({})
|
||||
matching.jev_ask.fail = True
|
||||
res_fail = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
|
||||
check("jev failure -> empty results (difflib fallback path)", res_fail == {})
|
||||
|
||||
# 3) apply_exact writes supplier/project ids into a payload
|
||||
payload = {"items": []}
|
||||
matching.apply_exact(payload, {"field": "project", "id": "p2", "name": "منشانه",
|
||||
"variants": ["منشیانه"], "scores": {}})
|
||||
matching.apply_exact(payload, {"field": "supplier", "id": "s1", "name": "بازرگانی مصطفوی",
|
||||
"extra": {"location": "اصفهان"}, "variants": ["مستفدی"]})
|
||||
check("apply_exact -> project_id/resolved_projects/project_ids",
|
||||
payload.get("project_id") == "p2"
|
||||
and payload["resolved_projects"].get("منشیانه") == "p2")
|
||||
check("apply_exact -> supplier_id/name/extra",
|
||||
payload.get("supplier_id") == "s1" and payload.get("supplier_location") == "اصفهان")
|
||||
|
||||
# 4) material/unit/brand/spec application on items
|
||||
payload2 = {"items": [{"material_name": "مصطفی", "unit_name": "کیسه",
|
||||
"brand_name": "سیمان تهران", "specs": ["سایز 14"]}]}
|
||||
matching.apply_choice(payload2, "material", {"id": "m1", "name": "گچ و خاک",
|
||||
"extra": {"canonical_unit_id": 2}},
|
||||
["مصطفی"])
|
||||
matching.apply_choice(payload2, "unit", {"id": "u1", "name": "کیسه"}, ["کیسه"])
|
||||
matching.normalize_brand_spec(payload2, {normalize_text("سایز 14"): {
|
||||
"field": "spec", "status": "exact", "name": "سایز ۱۴", "id": "سایز ۱۴",
|
||||
"variants": ["سایز 14"]}})
|
||||
it = payload2["items"][0]
|
||||
check("material/unit applied on matching items",
|
||||
it["material_id"] == "m1" and it["unit_id"] == "u1" and it["unit_explicit"] is True)
|
||||
check("spec normalization (spelling only)", it["specs"][0] == "سایز ۱۴")
|
||||
|
||||
# 5) create_ambiguities builds an entity_match entry with percentages
|
||||
payload3 = {"items": [], "ambiguities": []}
|
||||
jev_map = {normalize_text("منشیانه"): {
|
||||
"field": "project", "status": "ambiguity", "variants": ["منشیانه"],
|
||||
"candidates": [{"id": "p2", "name": "منشانه", "prob": 0.8},
|
||||
{"id": "p1", "name": "دفتر", "prob": 0.02}]}}
|
||||
matching.create_ambiguities(payload3, jev_map)
|
||||
ambs = payload3["ambiguities"]
|
||||
check("entity_match ambiguity created with candidates + details",
|
||||
len(ambs) == 1 and ambs[0]["kind"] == "entity_match"
|
||||
and len(ambs[0]["candidates"]) == 2 and "۸۰٪" in ambs[0]["details"])
|
||||
matching.create_ambiguities(payload3, jev_map) # idempotent
|
||||
check("create_ambiguities is deduped", len(payload3["ambiguities"]) == 1)
|
||||
|
||||
matching.jev_ask = orig_ask
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# live section (real NLU + real Jev) — opt-in and Bale-free
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HARNESS_TRANSCRIPT = (
|
||||
"مدل 1 (Whisper): الان شبت بخیر می خواستیم که بیست و پنج تا کیسه گچ و خاک از مصطفوی بخریم "
|
||||
"و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی. خریدمون هم عمدتا برای منشیانه و دفتر. "
|
||||
"پیش فاکتورم گفته تا حدودا بیست روزی اوکیه.\n"
|
||||
"مدل 2 (Vosk): میخواستیم 25 تا کیسه گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان "
|
||||
"50 کیلویی، خریدمون هم برای منشیانه و دفتر."
|
||||
)
|
||||
|
||||
|
||||
async def live_tests() -> None:
|
||||
from app.api.client import ErpClient
|
||||
from app.resolve import Resolver
|
||||
from app.nlu import understand
|
||||
|
||||
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
|
||||
ctx = SimpleNamespace(resolver=Resolver(api))
|
||||
|
||||
print("\n--- LIVE: NLU entity_mentions ---")
|
||||
result = await understand(settings, HARNESS_TRANSCRIPT)
|
||||
mentions = result.get("entity_mentions") or []
|
||||
print("intent:", result.get("intent"), "| mentions:", mentions)
|
||||
check("live NLU produced entity_mentions", len(mentions) >= 2, f"{len(mentions)} mentions")
|
||||
sup = next((m for m in mentions if m["field"] == "supplier"), None)
|
||||
check("voice spellings grouped into ONE supplier mention",
|
||||
bool(sup) and len(sup["variants"]) >= 2, str(sup))
|
||||
|
||||
print("\n--- LIVE: Jev per-field matching (real data) ---")
|
||||
jev_map = await matching.resolve_mentions(ctx, result.get("patch") or {}, mentions, None)
|
||||
for key, r in sorted(jev_map.items()):
|
||||
print(f" {key!r}: {r['status']} -> {r.get('name')!r} conf={r.get('confidence')}")
|
||||
check("live matching returned results", len(jev_map) > 0, f"{len(jev_map)} variants")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await mock_tests()
|
||||
if os.environ.get("RUN_LIVE") == "1":
|
||||
if not settings.TYPESAFE_API_KEY:
|
||||
check("live section requires TYPESAFE_API_KEY", False, "bot/.env not mounted?")
|
||||
else:
|
||||
await live_tests()
|
||||
else:
|
||||
print("\n(live section skipped — run with RUN_LIVE=1 to hit real NLU + Jev)")
|
||||
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(f"\n{len(PASSED)}/{total} passed")
|
||||
if FAILED:
|
||||
print("FAILED:", FAILED)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Phase-1 tests for the Observability subsystem (run INSIDE the bot container):
|
||||
|
||||
docker compose exec bot python tools/test_observability.py
|
||||
|
||||
Covers: _jsonable safety (UUID/datetime/Decimal/set/enum/dataclass), trace
|
||||
lifecycle, event round-trip through JSONB, draft linking, status updates,
|
||||
save_voice (day-folder mkdir), and the span() context manager.
|
||||
|
||||
NOTE (policy): test rows/files are tagged `_test` and are NEVER deleted —
|
||||
the observability subsystem has no retention/cleanup by design.
|
||||
"""
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import observability as obs
|
||||
from app.db import SessionLocal
|
||||
from app.db_init import ensure_schema
|
||||
from app.models import BotTrace, BotTraceEvent
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, extra: str = "") -> None:
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
|
||||
|
||||
|
||||
class SampleColor(Enum):
|
||||
RED = "red"
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SampleRec:
|
||||
a: int
|
||||
b: str
|
||||
|
||||
|
||||
def test_jsonable() -> None:
|
||||
payload = {
|
||||
"uuid": uuid.uuid4(),
|
||||
"dt": datetime(2026, 10, 12, 10, 30, tzinfo=timezone.utc),
|
||||
"dec": Decimal("123.45"),
|
||||
"set": {"x", "y"},
|
||||
"enum": SampleColor.RED,
|
||||
"dc": SampleRec(a=1, b="two"),
|
||||
"nested": [{"inner": uuid.UUID(int=1)}],
|
||||
"none": None,
|
||||
}
|
||||
out = obs._jsonable(payload)
|
||||
ok = (
|
||||
isinstance(out["uuid"], str)
|
||||
and isinstance(out["dt"], str)
|
||||
and isinstance(out["dec"], str)
|
||||
and isinstance(out["set"], list)
|
||||
and out["enum"] == "red"
|
||||
and isinstance(out["dc"], dict)
|
||||
)
|
||||
check("_jsonable produces JSON-serializable output", ok,
|
||||
f"dt={out['dt'][:19]}, dec={out['dec']!r}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await ensure_schema()
|
||||
test_jsonable()
|
||||
|
||||
# 1) trace lifecycle
|
||||
tid = await obs.trace_start(chat_id=0, message_id=999001, kind="voice")
|
||||
check("trace_start returns an id", bool(tid), str(tid))
|
||||
|
||||
# 2) events with tricky payloads (must not raise; must be stored)
|
||||
await obs.event(tid, "input.voice", payload={
|
||||
"_test": True, "size": 1234,
|
||||
"when": datetime.now(timezone.utc),
|
||||
"amount": Decimal("9.9"),
|
||||
})
|
||||
await obs.event(tid, "routing.decision", field="project", payload={
|
||||
"_test": True,
|
||||
"candidates": [{"id": uuid.uuid4(), "name": "منشانه"}],
|
||||
})
|
||||
await obs.log_resolution(tid, "supplier", "مصطفوی", "many",
|
||||
[{"id": "x", "name": "بازرگانی مصطفوی"}])
|
||||
|
||||
# 3) draft linking + latest lookup
|
||||
draft_id = str(uuid.uuid4())
|
||||
await obs.link_draft(tid, draft_id, "quote")
|
||||
latest = await obs.latest_trace_for_draft(draft_id)
|
||||
check("latest_trace_for_draft returns the linked trace", latest == tid, latest)
|
||||
|
||||
# 4) status update + JSONB round-trip
|
||||
await obs.set_trace_status(tid, "committed", str(uuid.uuid4()))
|
||||
async with SessionLocal() as s:
|
||||
tr = (await s.execute(select(BotTrace).where(BotTrace.id == tid))).scalar_one()
|
||||
check("trace status + quote id updated",
|
||||
tr.status == "committed" and tr.committed_quote_id is not None)
|
||||
rows = (await s.execute(
|
||||
select(BotTraceEvent).where(BotTraceEvent.trace_id == tid)
|
||||
.order_by(BotTraceEvent.id)
|
||||
)).scalars().all()
|
||||
check("events persisted", len(rows) >= 3, f"{len(rows)} rows")
|
||||
proj = next((r for r in rows if r.stage == "routing.decision"), None)
|
||||
check("JSONB payload round-trip keeps values as strings",
|
||||
proj is not None and isinstance(proj.payload["candidates"][0]["id"], str))
|
||||
|
||||
# 5) save_voice (parents mkdir + day folder)
|
||||
path = obs.save_voice(tid, 999001, b"OGGFAKE", ext=".ogg")
|
||||
check("save_voice writes file under day folder",
|
||||
bool(path) and Path(path).exists(), str(path))
|
||||
|
||||
# 6) span() context manager (fire-and-forget task)
|
||||
with obs.span("span.test", trace_id=tid, note="unit"):
|
||||
pass
|
||||
await asyncio.sleep(0.4)
|
||||
async with SessionLocal() as s:
|
||||
n = (await s.execute(
|
||||
select(BotTraceEvent).where(
|
||||
BotTraceEvent.trace_id == tid, BotTraceEvent.stage == "span.test"
|
||||
)
|
||||
)).scalars().all()
|
||||
check("span() recorded via background task", len(n) == 1, f"{len(n)} rows")
|
||||
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(f"\n{len(PASSED)}/{total} passed")
|
||||
if FAILED:
|
||||
print("FAILED:", FAILED)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+76
-3
@@ -249,6 +249,9 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
- ./backend/app:/srv/backend/app
|
||||
|
||||
networks:
|
||||
- traefik_network
|
||||
- default
|
||||
@@ -322,9 +325,6 @@ services:
|
||||
|
||||
volumes:
|
||||
- ./bot/app:/srv/bot/app
|
||||
- ./bot/tools:/srv/bot/tools
|
||||
- ./bot/data:/srv/bot/data
|
||||
- ./bot/.env:/srv/bot/.env:ro
|
||||
|
||||
environment:
|
||||
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
|
||||
@@ -350,6 +350,78 @@ services:
|
||||
restart: unless-stopped
|
||||
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:
|
||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||
@@ -366,3 +438,4 @@ volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
traefik_certs:
|
||||
pm_files:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||
|
||||
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||
|
||||
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||
|
||||
---
|
||||
|
||||
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||
|
||||
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||
(`backend/app/routers/inventory.py` — list_transactions)
|
||||
|
||||
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||
|
||||
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||
|
||||
---
|
||||
|
||||
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||
|
||||
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||
|
||||
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||
|
||||
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||
|
||||
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||
|
||||
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||
|
||||
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||
|
||||
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||
|
||||
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||
|
||||
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||
|
||||
## ۵. 🟡 Audit Trail ناقص است
|
||||
|
||||
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||
- CRUD انبارها و تغییرات کاربران
|
||||
|
||||
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||
|
||||
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||
|
||||
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||
|
||||
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||
|
||||
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||
|
||||
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||
|
||||
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||
|
||||
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||
|
||||
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||
|
||||
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||
|
||||
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||
|
||||
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||
|
||||
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||
|
||||
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||
|
||||
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||
|
||||
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||
|
||||
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||
|
||||
---
|
||||
|
||||
## چیزهایی که بررسی شد و درست است ✅
|
||||
|
||||
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
||||
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+390
@@ -28,6 +28,7 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
@@ -1366,6 +1367,32 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
|
||||
@@ -1714,6 +1741,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": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
@@ -2003,6 +2042,69 @@
|
||||
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||
"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": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -2033,6 +2135,12 @@
|
||||
"@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": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
@@ -2127,6 +2235,133 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2155,6 +2390,24 @@
|
||||
"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": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2201,6 +2454,25 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"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": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2645,6 +2917,36 @@
|
||||
"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": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -2752,6 +3054,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
|
||||
@@ -2884,6 +3237,12 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -2970,6 +3329,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
|
||||
"short_name": "دفترچه تأمین",
|
||||
"description": "سامانه تدارکات و مدیریت انبار پروژههای ساختمانی",
|
||||
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
|
||||
"short_name": "مدیریت پروژه و تأمین",
|
||||
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
|
||||
"dir": "rtl",
|
||||
"lang": "fa",
|
||||
"start_url": "/",
|
||||
|
||||
+290
-5
@@ -38,11 +38,18 @@ const WarehousesPage = lazy(() =>
|
||||
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
||||
);
|
||||
const OrganizationPage = lazy(() =>
|
||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||
);
|
||||
const AdminPanelPage = lazy(() =>
|
||||
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() {
|
||||
return (
|
||||
@@ -56,6 +63,7 @@ function Loading() {
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
// در صورت داشتن شناسه پیشنویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -68,16 +76,251 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
||||
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}</>;
|
||||
}
|
||||
|
||||
// صفحهی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
|
||||
// صفحهی اصلی بر اساس لایه پنلها (جایگزین نقشها):
|
||||
function HomeRoute() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
||||
const panels = user?.panels ?? {};
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
// ۱. اگر پنل تدارکات دارد:
|
||||
if (hasProcurement) {
|
||||
if (panels.procurement === "limited" && !isAdmin) {
|
||||
return <Navigate to="/wizard" replace />;
|
||||
}
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
// ۲. اگر پنل انبار دارد:
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
|
||||
// ۳. اگر پنل PM دارد:
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
|
||||
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
|
||||
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنلهای سامانه</h3>
|
||||
<p className="mt-2 text-xs text-muted leading-6">
|
||||
حساب کاربری شما فعال است اما در حال حاضر به هیچیک از پنلهای سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای تدارکات و پنلهای تدارکات (full یا limited)
|
||||
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const panels = user?.panels ?? {};
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager" || user?.role === "pm");
|
||||
|
||||
if (hasProcurement) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
@@ -97,10 +340,13 @@ export default function App() {
|
||||
path="/wizard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequireProcurementWrite>
|
||||
<WizardPage />
|
||||
</RequireProcurementWrite>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
|
||||
<Route
|
||||
path="/suppliers"
|
||||
element={
|
||||
@@ -144,6 +390,17 @@ export default function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<MvpDashboardPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/units"
|
||||
element={
|
||||
@@ -177,7 +434,9 @@ export default function App() {
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<RequireInventoryChange>
|
||||
<ChangeInventoryPage />
|
||||
</RequireInventoryChange>
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
@@ -197,18 +456,24 @@ export default function App() {
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<RequireWarehouseManage>
|
||||
<WarehousesPage />
|
||||
</RequireWarehouseManage>
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/inventory/org"
|
||||
element={<Navigate to="/organization" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/organization"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<RequireOrganization>
|
||||
<OrganizationPage />
|
||||
</RequirePmProjects>
|
||||
</RequireOrganization>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -220,6 +485,26 @@ export default function App() {
|
||||
</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 />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -1,48 +1,84 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
|
||||
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
|
||||
// دقیقاً مثل منوی دفتر: عنوان «پنلها»، ماژول فعلی با استایل فعال (پر)
|
||||
// + «✓ فعلی»، ماژول دیگر بهعنوان سوییچر، و در پایان جداکننده + خروج.
|
||||
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز میشود که همهی
|
||||
// پروژههای تخصیصیافته را نشان میدهد؛ پروژهی فعال با «✓ فعلی» مشخص است و
|
||||
// انتخاب هر پروژه فقط «پروژهی فعال» را عوض میکند (نشست/توکن دستنخورده).
|
||||
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
|
||||
// ۱. مدیریت انبار (/inventory)
|
||||
// ۲. مدیریت تدارکات (/)
|
||||
// ۳. مدیریت پروژه (/panel/pm)
|
||||
// ۴. سازماندهی (/organization)
|
||||
export function AppSwitcherMenu({
|
||||
activeModule,
|
||||
canSwitchToProcurement = true,
|
||||
}: {
|
||||
activeModule: "warehouse" | "procurement";
|
||||
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
|
||||
activeModule: "warehouse" | "procurement" | "organization";
|
||||
canSwitchToProcurement?: boolean;
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
const { pathname } = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setActiveProject = useAuthStore((s) => s.setActiveProject);
|
||||
const panels = user?.panels ?? {};
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
// فقط نقشهای ادمین، مدیر پروژه و مدیر تدارکات به پنل سازماندهی دسترسی دارند
|
||||
const canManageOrg =
|
||||
isAdmin ||
|
||||
user?.role === "procurement_manager" ||
|
||||
user?.role === "pm";
|
||||
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
const isPm = user?.role === "pm";
|
||||
const pmProjects = isPm ? user?.projects ?? [] : [];
|
||||
const projectChildren: MenuItem[] | undefined =
|
||||
activeModule === "warehouse" && isPm
|
||||
? pmProjects.map((p) => ({
|
||||
label: p.name,
|
||||
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض میشود
|
||||
active: p.id === user?.project_id,
|
||||
onClick: () => setActiveProject(p.id),
|
||||
}))
|
||||
: undefined;
|
||||
if (hasInventory) {
|
||||
items.push({
|
||||
label: "انبار",
|
||||
to: "/inventory",
|
||||
active: activeModule === "warehouse",
|
||||
});
|
||||
}
|
||||
|
||||
if (activeModule === "procurement") {
|
||||
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
||||
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
||||
} else {
|
||||
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
|
||||
if (canSwitchToProcurement) {
|
||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
||||
if (hasProcurement && canSwitchToProcurement) {
|
||||
items.push({
|
||||
label: "تدارکات",
|
||||
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
|
||||
active: activeModule === "procurement",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPm) {
|
||||
items.push({
|
||||
label: "مدیریت پروژه",
|
||||
to: "/panel/pm",
|
||||
});
|
||||
}
|
||||
|
||||
if (canManageOrg) {
|
||||
items.push({
|
||||
label: "سازماندهی",
|
||||
to: "/organization",
|
||||
active: activeModule === "organization",
|
||||
});
|
||||
}
|
||||
|
||||
items.push({ divider: true });
|
||||
items.push({ label: "خروج از حساب", onClick: logout });
|
||||
return <PanelMenu title="پنلها" items={items} />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
@@ -16,7 +17,11 @@ export interface ComboboxProps {
|
||||
value?: string;
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||
pinnedIds?: string[];
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||
addSuffix?: string; // متن بعد از +
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
@@ -25,6 +30,11 @@ export interface ComboboxProps {
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
// با ارائهی این تابع، دکمهی «×» داخل فیلد ظاهر میشود تا انتخاب فعلی
|
||||
// (و فیلتر وابسته به آن) بهتنهایی پاک شود.
|
||||
onClear?: () => void;
|
||||
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||
openOnFocus?: boolean;
|
||||
@@ -43,7 +53,9 @@ export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
pinnedIds,
|
||||
allowAdd = true,
|
||||
addPrefix,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
placeholder,
|
||||
className,
|
||||
@@ -52,11 +64,17 @@ export function Combobox({
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
onClear,
|
||||
inputRef,
|
||||
openOnFocus = true,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
// کلیکِ روی اینپوتِ در حالت فوکوسنشده، اول onFocus را صدا میزند و منو باز
|
||||
// میشود؛ این نشانگر میگوید «همین لحظه با فوکوس باز شد» تا همان کلیک
|
||||
// دوباره منو را نبندد. کلیکهای بعدی = تاگل باز/بسته.
|
||||
const justOpenedByFocus = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setText(value ?? "");
|
||||
@@ -74,19 +92,37 @@ export function Combobox({
|
||||
return all.filter((x) => normalize(x.label).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === 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 listCount = filtered.length + (showAdd ? 1 : 0);
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
|
||||
|
||||
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||
|
||||
const listCount = visible.length + (showAdd ? 1 : 0);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const pick = (it: ComboboxItem) => {
|
||||
setText(it.label);
|
||||
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||
const nextText = it.id ? it.label : "";
|
||||
setText(nextText);
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onChange?.(it);
|
||||
onInputChange?.(it.label);
|
||||
onInputChange?.(nextText);
|
||||
};
|
||||
|
||||
const addCurrent = () => {
|
||||
@@ -111,9 +147,9 @@ export function Combobox({
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
pick(filtered[highlight]);
|
||||
} else if (highlight === filtered.length && showAdd) {
|
||||
if (highlight >= 0 && highlight < visible.length) {
|
||||
pick(visible[highlight]);
|
||||
} else if (highlight === visible.length && showAdd) {
|
||||
addCurrent();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -125,8 +161,10 @@ export function Combobox({
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
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",
|
||||
showClear && "pe-8",
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
@@ -140,24 +178,53 @@ export function Combobox({
|
||||
onInputChange?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (openOnFocus) setOpen(true);
|
||||
if (openOnFocus && !open) {
|
||||
setOpen(true);
|
||||
justOpenedByFocus.current = true;
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیکهای بعدی تاگل میکنند
|
||||
if (justOpenedByFocus.current) {
|
||||
justOpenedByFocus.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
onBlur={onBlur}
|
||||
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 && (
|
||||
<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 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
{visible.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it.id + "|" + i}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
@@ -182,9 +249,15 @@ export function Combobox({
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
{addPrefix ? (
|
||||
<span>
|
||||
{addPrefix} «<b>{text.trim()}</b>»
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
|
||||
export interface MenuItem {
|
||||
label?: string;
|
||||
to?: string;
|
||||
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز میشود
|
||||
href?: string;
|
||||
active?: boolean;
|
||||
divider?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
|
||||
</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 ? (
|
||||
<Link
|
||||
key={i}
|
||||
|
||||
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
||||
const setProjects = useAuthStore((s) => s.setProjects);
|
||||
const logout = useLogout();
|
||||
|
||||
const isPm = user?.role === "pm";
|
||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
||||
// پنل انبار limited هم مثل سرپرست: پروژههای فعالش از عضویتها اعتبارسنجی میشود
|
||||
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== "pm") {
|
||||
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||
if (!user || !scoped) {
|
||||
setState("ok");
|
||||
return;
|
||||
}
|
||||
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, user?.role, isDev]);
|
||||
}, [user?.id, user?.role, user?.panels, isDev]);
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// ============================================================
|
||||
// ویجت ۴ — کنترل روزانه (امروز)
|
||||
// کارتهای 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// ============================================================
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||
// داده از /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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// ============================================================
|
||||
// ویجت ۲ — جدول موجودی راکد (سرمایهی خوابیده)
|
||||
// ردیفهایی با موجودی مثبت که هیچ تراکنشی در بازهی رکود نداشتهاند.
|
||||
// آستانهی ۶۰/۹۰ روز با چیپ انتخاب میشود؛ روزهای رکود رنگی است.
|
||||
// ============================================================
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
// ============================================================
|
||||
// ویجت ۱ — ماتریس 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// ============================================================
|
||||
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
||||
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
||||
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
||||
// خط مانده پلهای (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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// ============================================================
|
||||
// ویجت ۶ — شبکهی وضعیت سفارشهای در راه
|
||||
// جدول کمی سفارشهای باز: متریال و تأمینکننده | سفارششده | رسیده
|
||||
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
|
||||
// داده از /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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user