Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707e40e840 | ||
|
|
ca2c1bb8ce | ||
|
|
d38cf38bd9 | ||
|
|
a1220db844 | ||
|
|
1190ec42eb | ||
|
|
2747f9d4bb | ||
|
|
934b74ebbd | ||
|
|
dcc4d693f5 | ||
|
|
64bd292335 | ||
|
|
98c4b28815 | ||
|
|
aba9ba6486 | ||
|
|
965054093e | ||
|
|
3931baadd5 | ||
|
|
638076dbaa | ||
|
|
df3ff4ea14 | ||
|
|
4b84844b54 | ||
|
|
ea6a377023 | ||
|
|
5400588751 | ||
|
|
0ea0322967 | ||
|
|
a84b33dde3 | ||
|
|
fbd34277c0 | ||
|
|
8c5f4ab770 | ||
|
|
07643dbdf5 | ||
|
|
e9fef5a104 | ||
|
|
4a3c226518 | ||
|
|
0a7db5ebe9 | ||
|
|
da68fc683f | ||
|
|
17458d8697 | ||
|
|
1b5fc589fd | ||
|
|
a2e7c27e85 | ||
|
|
09245cdc2c | ||
|
|
dc31f837fd | ||
|
|
57e0e51c16 | ||
|
|
c7d860f431 | ||
|
|
9e19be8354 | ||
|
|
4cfb9ec6f6 | ||
|
|
6ee0822a96 |
@@ -26,8 +26,11 @@ frontend/.env
|
|||||||
|
|
||||||
# ---- Local session / backups ----
|
# ---- Local session / backups ----
|
||||||
.zcode/
|
.zcode/
|
||||||
|
.zcodeignore
|
||||||
|
scratch/
|
||||||
v1.2 - Copy/
|
v1.2 - Copy/
|
||||||
inv dash/
|
inv dash/
|
||||||
|
voice/
|
||||||
|
|
||||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
||||||
|
|
||||||
Self-contained ship package: everything needed to run the stack is in this
|
Self-contained ship package: everything needed to run the stack is in this
|
||||||
@@ -12,7 +13,9 @@ folder — no external files or sibling folders required.
|
|||||||
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
|
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
|
||||||
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
|
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Browser ──► Traefik :80 (edge)
|
Browser ──► Traefik :80 (edge)
|
||||||
│
|
│
|
||||||
├── Host(localhost) ──► frontend (nginx SPA :80)
|
├── Host(localhost) ──► frontend (nginx SPA :80)
|
||||||
@@ -25,6 +28,7 @@ Browser ──► Traefik :80 (edge)
|
|||||||
│
|
│
|
||||||
├── postgres :5432 (procurement + casdoor DBs)
|
├── postgres :5432 (procurement + casdoor DBs)
|
||||||
└── minio :9000 (invoice/proforma files)
|
└── minio :9000 (invoice/proforma files)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,6 +78,7 @@ v1.3/
|
|||||||
├── Projects/ (projects)
|
├── Projects/ (projects)
|
||||||
├── Units/ (unit conversions)
|
├── Units/ (unit conversions)
|
||||||
└── Inventory/ (warehouse management panel — React port)
|
└── 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 —
|
(password grant), verifies the returned JWT, and starts the session —
|
||||||
no browser redirect to Casdoor.
|
no browser redirect to Casdoor.
|
||||||
|
|
||||||
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
||||||
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
||||||
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
||||||
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
||||||
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
||||||
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
||||||
@@ -111,11 +116,11 @@ for production builds).
|
|||||||
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
||||||
Casdoor organization, application and users (idempotent). Full details —
|
Casdoor organization, application and users (idempotent). Full details —
|
||||||
addresses, credentials, role model, failure scenarios and the checklist —
|
addresses, credentials, role model, failure scenarios and the checklist —
|
||||||
are in [`docs/casdoor-auth.md`](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
|
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
> 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
|
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||||
assigned inside the app (`users.role` — see «Login» above and
|
assigned inside the app (`users.role` — see «Login» above and
|
||||||
[`docs/casdoor-auth.md`](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)
|
### 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
|
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||||
use the panel):
|
use the panel):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
You will also land in a normal SSH session — that is fine; the tunnel
|
You will also land in a normal SSH session — that is fine; the tunnel
|
||||||
lives as long as the window stays open.
|
lives as long as the window stays open.
|
||||||
|
|
||||||
2. In your browser, open: **http://localhost:9001**
|
2. In your browser, open: **http://localhost:9001**
|
||||||
|
|
||||||
3. Log in with the production MinIO root credentials from the server's
|
3. Log in with the production MinIO root credentials from the server's
|
||||||
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||||
**never** written in this repo or its history. See
|
**never** written in this repo or its history. See
|
||||||
[`docs/SECRET-MANAGEMENT.md`](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
|
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||||
ERP stores, browsable and downloadable from there.
|
ERP stores, browsable and downloadable from there.
|
||||||
@@ -168,11 +172,11 @@ tunnel.
|
|||||||
|
|
||||||
Notes from the server setup:
|
Notes from the server setup:
|
||||||
|
|
||||||
- The panel is published only on the server's loopback, so the internet
|
* The panel is published only on the server's loopback, so the internet
|
||||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||||
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
* Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||||
why the console uses `9001`.
|
why the console uses `9001`.
|
||||||
- If you ever need the raw S3 API from your machine, or a clickable URL for
|
* If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||||
with a password prompt on top), that can be arranged on request — the
|
with a password prompt on top), that can be arranged on request — the
|
||||||
tunnel remains the safer default.
|
tunnel remains the safer default.
|
||||||
@@ -181,9 +185,9 @@ Notes from the server setup:
|
|||||||
|
|
||||||
## Prerequisites (target machine)
|
## Prerequisites (target machine)
|
||||||
|
|
||||||
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||||
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||||
- Internet access on first build (image + `npm ci` downloads)
|
* Internet access on first build (image + `npm ci` downloads)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -192,6 +196,7 @@ Notes from the server setup:
|
|||||||
```powershell
|
```powershell
|
||||||
cd <this-folder>
|
cd <this-folder>
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
First run downloads base images and builds the frontend (takes a few minutes).
|
First run downloads base images and builds the frontend (takes a few minutes).
|
||||||
@@ -201,6 +206,7 @@ Wait until all services are up:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All should show `Up` (postgres also `(healthy)`).
|
All should show `Up` (postgres also `(healthy)`).
|
||||||
@@ -225,11 +231,12 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
|
|||||||
```powershell
|
```powershell
|
||||||
backend\tools\smoke_test.py # read paths
|
backend\tools\smoke_test.py # read paths
|
||||||
backend\tools\smoke_test_write.py / smoke_test_misc.py
|
backend\tools\smoke_test_write.py / smoke_test_misc.py
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Every API route now requires a Casdoor JWT — a bare
|
> Every API route now requires a Casdoor JWT — a bare
|
||||||
> `curl http://localhost/api/v1/quotes` returns **401** by design.
|
> `curl http://localhost/api/v1/quotes` returns **401** by design.
|
||||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](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 |
|
| Web console | http://localhost:9001 |
|
||||||
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
||||||
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
|
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
|
||||||
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
|
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
|
||||||
|
|
||||||
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
|
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
|
||||||
|
|
||||||
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\mc.cmd ls --recursive
|
.\mc.cmd ls --recursive
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -261,10 +269,12 @@ Port: 5433
|
|||||||
Database: procurement
|
Database: procurement
|
||||||
Username: postgres
|
Username: postgres
|
||||||
Password: postgres
|
Password: postgres
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker exec -it procurement-postgres psql -U postgres -d procurement
|
docker exec -it procurement-postgres psql -U postgres -d procurement
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -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
|
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|
||||||
`postgres_data` volume; then run the seeders:
|
`postgres_data` volume; then run the seeders:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
||||||
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||||
# Demo dataset (optional):
|
# Demo dataset (optional):
|
||||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
2. **Restore your own dump** — create dumps on the server (kept server-side,
|
2. **Restore your own dump** — create dumps on the server (kept server-side,
|
||||||
e.g. `/srv/erp/backups/`), never in git:
|
e.g. `/srv/erp/backups/`), never in git:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
|
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
|
||||||
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||||
first — this **replaces all data**). The scheduled backup sidecar
|
first — this **replaces all data**). The scheduled backup sidecar
|
||||||
(see `docs/OPERATIONS.md`) automates this.
|
(see `docs/OPERATIONS.md`) automates this.
|
||||||
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
|
|||||||
docker compose down # stop (keep data)
|
docker compose down # stop (keep data)
|
||||||
docker compose up -d --build # rebuild after code changes
|
docker compose up -d --build # rebuild after code changes
|
||||||
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known behavior
|
## Known behavior
|
||||||
|
|
||||||
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||||
every API route** except `/health`, `/health/ready` and `/auth/login`
|
every API route** except `/health`, `/health/ready` and `/auth/login`
|
||||||
(401 without a token, 403 without a role). The former dev-header fallback
|
(401 without a token, 403 without a role). The former dev-header fallback
|
||||||
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
||||||
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
||||||
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||||
the procurement manager can only assign projects to project managers.
|
the procurement manager can only assign projects to project managers.
|
||||||
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
||||||
grant/revoke «مدیر تدارکات»).
|
grant/revoke «مدیر تدارکات»).
|
||||||
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
||||||
at least one item with a valid quantity is enough to submit; other items
|
at least one item with a valid quantity is enough to submit; other items
|
||||||
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
||||||
badges and a «رسید جزیی» badge on the quote.
|
badges and a «رسید جزیی» badge on the quote.
|
||||||
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
||||||
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
||||||
dumped/restored; see `docs/casdoor-auth.md`).
|
dumped/restored; see `docs/casdoor-auth.md`).
|
||||||
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
||||||
serves the SPA and proxies `/api` internally. Production (real domains,
|
serves the SPA and proxies `/api` internally. Production (real domains,
|
||||||
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
||||||
(see the prod file header for the required env vars).
|
(see the prod file header for the required env vars).
|
||||||
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
||||||
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
||||||
for the units-dashboard tables and `quote_attachments` at startup.
|
for the units-dashboard tables and `quote_attachments` at startup.
|
||||||
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||||
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||||
invoice/proforma download links from MinIO.
|
invoice/proforma download links from MinIO.
|
||||||
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
* **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 the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
* 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`.
|
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.config import settings
|
||||||
from app.db.engine import AsyncSessionLocal
|
from app.db.engine import AsyncSessionLocal
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
@@ -106,6 +106,10 @@ class UserContext:
|
|||||||
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
||||||
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
||||||
display_name: str
|
display_name: str
|
||||||
|
# لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', 'inventory': ...}
|
||||||
|
panels: dict | None = None
|
||||||
|
# عضویت M:N در پروژهها — محدوده داده سطح limited (جدا از مسئولیت PM)
|
||||||
|
member_project_ids: list | None = None
|
||||||
|
|
||||||
|
|
||||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||||
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
if not casdoor_id:
|
if not casdoor_id:
|
||||||
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
||||||
|
|
||||||
|
username = str(claims.get("name") or claims.get("preferred_username") or "")
|
||||||
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(
|
user = User(
|
||||||
casdoor_id=casdoor_id,
|
casdoor_id=casdoor_id,
|
||||||
|
username=username or None,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
claims.get("firstName")
|
claims.get("firstName")
|
||||||
or claims.get("firstname")
|
or claims.get("firstname")
|
||||||
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
elif username and not user.username:
|
||||||
|
user.username = username
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
project_ids: list = []
|
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
||||||
if user.role == "pm":
|
panels: dict = {}
|
||||||
projects = (
|
panel_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for row in panel_rows:
|
||||||
|
panels[row.panel] = row.level
|
||||||
|
|
||||||
|
member_project_ids: list = [
|
||||||
|
row.project_id
|
||||||
|
for row in (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership)
|
||||||
|
.where(UserProjectMembership.user_id == user.id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
]
|
||||||
|
|
||||||
|
pm_projects = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(Project.pm_user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
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
|
project_id = project_ids[0] if project_ids else None
|
||||||
|
|
||||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||||
@@ -155,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
project_ids=project_ids,
|
project_ids=project_ids,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
|
panels=panels,
|
||||||
|
member_project_ids=member_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||||
"""Every project id managed by the given PM user (oldest first)."""
|
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||||
rows = (
|
pm_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project.id)
|
select(Project.id)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(Project.pm_user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return list(rows)
|
member_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership.project_id)
|
||||||
|
.where(UserProjectMembership.user_id == user.id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
|
||||||
|
|
||||||
|
|
||||||
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||||
@@ -217,3 +257,31 @@ def require_role(*roles: str):
|
|||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
return _dep
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def require_procurement_writer():
|
||||||
|
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
|
||||||
|
(دوره مهاجرت از مدل نقش به مدل پنل)."""
|
||||||
|
|
||||||
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
|
return ctx
|
||||||
|
if (ctx.panels or {}).get("procurement") == "full":
|
||||||
|
return ctx
|
||||||
|
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def allow_quote_create():
|
||||||
|
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
|
||||||
|
(محدود به پروژههای عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام میشود)."""
|
||||||
|
|
||||||
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
|
return ctx
|
||||||
|
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
|
||||||
|
return ctx
|
||||||
|
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class Settings(BaseSettings):
|
|||||||
MINIO_BUCKET: str = "procurement-files"
|
MINIO_BUCKET: str = "procurement-files"
|
||||||
MINIO_SECURE: bool = False
|
MINIO_SECURE: bool = False
|
||||||
|
|
||||||
|
# Security token for standalone wizard review URL (must match bot)
|
||||||
|
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
|
||||||
# Background auto-expiry of offered quotes whose expiry_date has passed.
|
# Background auto-expiry of offered quotes whose expiry_date has passed.
|
||||||
AUTO_EXPIRE_ENABLED: bool = True
|
AUTO_EXPIRE_ENABLED: bool = True
|
||||||
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ _V26_DDL = [
|
|||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
|
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
@@ -414,6 +414,53 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
|||||||
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_rbac_schema(engine: AsyncEngine) -> None:
|
||||||
|
"""Panel-access schema (pm branch): users.username, projects planning
|
||||||
|
columns, user_panels and user_project_members. Idempotent; safe on any
|
||||||
|
volume — the RBAC model expects these and login 500s without them."""
|
||||||
|
stmts = (
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
|
||||||
|
# The live user_role enum predates 'supervisor' — CREATE TYPE no-ops on
|
||||||
|
# an existing type, so without this ALTER, assigning the role 500s.
|
||||||
|
"ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'supervisor'",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE'",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0",
|
||||||
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_panels (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
panel TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_user_panel UNIQUE (user_id, panel)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_user_panels_user_id ON user_panels(user_id)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_project_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_user_project_member UNIQUE (user_id, project_id)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_user_project_members_user_id ON user_project_members(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_user_project_members_project_id ON user_project_members(project_id)",
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
for stmt in stmts:
|
||||||
|
await conn.execute(text(stmt))
|
||||||
|
logger.info("RBAC schema ensured (username / project columns / user_panels / user_project_members)")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||||
ddl = """
|
ddl = """
|
||||||
@@ -438,6 +485,39 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
|
|||||||
logger.info("audit_log table ensured")
|
logger.info("audit_log table ensured")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
|
||||||
|
"""Ensure bot_drafts table and indexes exist for Bale bot quote sync."""
|
||||||
|
ddl = """
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_drafts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
chat_id BIGINT NOT NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
missing JSONB,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||||
|
source VARCHAR(16),
|
||||||
|
source_message_id BIGINT,
|
||||||
|
review_message_id BIGINT,
|
||||||
|
last_bale_update_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
indexes = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts(chat_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts(chat_id, status)",
|
||||||
|
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
|
||||||
|
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts(review_message_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts(source_message_id)",
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
for stmt in indexes:
|
||||||
|
await conn.execute(text(stmt))
|
||||||
|
logger.info("bot_drafts table ensured")
|
||||||
|
|
||||||
|
|
||||||
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
|
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
|
||||||
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
|
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
|
||||||
ddl = """
|
ddl = """
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ from app.db.engine import AsyncSessionLocal, engine
|
|||||||
from app.db.init_db import (
|
from app.db.init_db import (
|
||||||
ensure_attachment_table,
|
ensure_attachment_table,
|
||||||
ensure_audit_table,
|
ensure_audit_table,
|
||||||
|
ensure_bot_drafts_table,
|
||||||
ensure_drop_category_tables,
|
ensure_drop_category_tables,
|
||||||
ensure_dashboard_tables,
|
ensure_dashboard_tables,
|
||||||
ensure_material_topic_column,
|
ensure_material_topic_column,
|
||||||
ensure_quote_projects,
|
ensure_quote_projects,
|
||||||
|
ensure_rbac_schema,
|
||||||
ensure_project_transfers_table,
|
ensure_project_transfers_table,
|
||||||
ensure_v26,
|
ensure_v26,
|
||||||
)
|
)
|
||||||
@@ -106,8 +108,10 @@ async def lifespan(_app: FastAPI):
|
|||||||
await ensure_material_topic_column(engine)
|
await ensure_material_topic_column(engine)
|
||||||
await ensure_quote_projects(engine)
|
await ensure_quote_projects(engine)
|
||||||
await ensure_v26(engine)
|
await ensure_v26(engine)
|
||||||
|
await ensure_rbac_schema(engine)
|
||||||
await ensure_audit_table(engine)
|
await ensure_audit_table(engine)
|
||||||
await ensure_drop_category_tables(engine)
|
await ensure_drop_category_tables(engine)
|
||||||
|
await ensure_bot_drafts_table(engine)
|
||||||
await ensure_project_transfers_table(engine)
|
await ensure_project_transfers_table(engine)
|
||||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||||
try:
|
try:
|
||||||
@@ -235,12 +239,15 @@ from app.routers.auth import router as _auth_router # noqa: E402
|
|||||||
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
# attach an Authorization header to <iframe>/<img> loads, so the two file GET
|
||||||
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
# routes accept a short-lived signed ?t= token as well as a Bearer session.
|
||||||
from app.routers.files import router as _files_router # noqa: E402
|
from app.routers.files import router as _files_router # noqa: E402
|
||||||
|
from app.routers.drafts import router as _drafts_router # noqa: E402
|
||||||
|
|
||||||
for router in ROUTERS:
|
for router in ROUTERS:
|
||||||
if router is _auth_router:
|
if router is _auth_router:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX)
|
app.include_router(router, prefix=API_V1_PREFIX)
|
||||||
elif router is _files_router:
|
elif router is _files_router:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX)
|
app.include_router(router, prefix=API_V1_PREFIX)
|
||||||
|
elif router is _drafts_router:
|
||||||
|
app.include_router(router, prefix=API_V1_PREFIX)
|
||||||
else:
|
else:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy import BigInteger, Column, DateTime, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
class BotDraft(Base):
|
||||||
|
__tablename__ = "bot_drafts"
|
||||||
|
|
||||||
|
id = Column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
|
||||||
|
chat_id = Column(BigInteger, nullable=False)
|
||||||
|
kind = Column(String(16), nullable=False)
|
||||||
|
payload = Column(JSONB, nullable=False, default=dict)
|
||||||
|
missing = Column(JSONB)
|
||||||
|
status = Column(String(16), nullable=False, default="active")
|
||||||
|
source = Column(String(16))
|
||||||
|
source_message_id = Column(BigInteger)
|
||||||
|
review_message_id = Column(BigInteger)
|
||||||
|
last_bale_update_at = Column(DateTime(timezone=True))
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
@@ -26,7 +26,7 @@ from app.db.base import Base
|
|||||||
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
||||||
|
|
||||||
user_role_enum = Enum(
|
user_role_enum = Enum(
|
||||||
"pm", "procurement_manager", "admin",
|
"pm", "procurement_manager", "admin", "supervisor",
|
||||||
name="user_role", native_enum=True,
|
name="user_role", native_enum=True,
|
||||||
)
|
)
|
||||||
quote_status_enum = Enum(
|
quote_status_enum = Enum(
|
||||||
@@ -58,6 +58,7 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(Text)
|
||||||
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
phone: Mapped[str | None] = mapped_column(Text)
|
phone: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -65,6 +66,47 @@ class User(Base):
|
|||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanel(Base):
|
||||||
|
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
|
||||||
|
|
||||||
|
panel: 'procurement' | 'inventory' | 'pm'
|
||||||
|
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژهها
|
||||||
|
(بهجز تغییر موجودی)
|
||||||
|
'limited' — تدارکات: فقط افزودن استعلام برای پروژههای عضویت؛
|
||||||
|
انبار: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_panels"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
panel: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
level: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProjectMembership(Base):
|
||||||
|
"""user_project_members — عضویت M:N کاربر در پروژهها، جدا از مسئولیت
|
||||||
|
پروژه (projects.pm_user_id). محدودهی دادهی سطح limited را تعیین میکند."""
|
||||||
|
|
||||||
|
__tablename__ = "user_project_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
|
||||||
|
|
||||||
|
|
||||||
class Supplier(Base):
|
class Supplier(Base):
|
||||||
__tablename__ = "suppliers"
|
__tablename__ = "suppliers"
|
||||||
|
|
||||||
@@ -148,12 +190,23 @@ class Project(Base):
|
|||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
|
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
start_date: Mapped[date | None] = mapped_column(Date)
|
start_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
|
||||||
|
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
|
||||||
# v2.6: the single PM responsible for this project (confirms arrivals).
|
# v2.6: the single PM responsible for this project (confirms arrivals).
|
||||||
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="SET NULL")
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Quote(Base):
|
class Quote(Base):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from app.routers import (
|
|||||||
auth,
|
auth,
|
||||||
brand_materials,
|
brand_materials,
|
||||||
brands,
|
brands,
|
||||||
|
drafts,
|
||||||
files,
|
files,
|
||||||
inventory,
|
inventory,
|
||||||
item_decisions,
|
item_decisions,
|
||||||
@@ -53,4 +54,5 @@ ROUTERS = [
|
|||||||
pm.router,
|
pm.router,
|
||||||
project_transfers.router,
|
project_transfers.router,
|
||||||
users.router,
|
users.router,
|
||||||
|
drafts.router,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||||
from app.models.procurement import LineItemSpec
|
from app.models.procurement import LineItemSpec
|
||||||
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
||||||
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_attribute_definition(
|
||||||
|
payload: AttributeDefinitionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
|||||||
|
|
||||||
@router.patch("/{definition_id}")
|
@router.patch("/{definition_id}")
|
||||||
async def update_attribute_definition(
|
async def update_attribute_definition(
|
||||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
definition_id,
|
||||||
|
payload: AttributeDefinitionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{definition_id}")
|
@router.delete("/{definition_id}")
|
||||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
async def delete_attribute_definition(
|
||||||
|
definition_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.config import settings
|
|||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.routers.users import _user_projects, user_to_dict
|
from app.routers.users import _user_projects, user_to_dict
|
||||||
from app.services import analytics
|
from app.services import analytics
|
||||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
|
||||||
from app.utils.ratelimit import limiter
|
from app.utils.ratelimit import limiter
|
||||||
|
|
||||||
logger = logging.getLogger("procurement.auth")
|
logger = logging.getLogger("procurement.auth")
|
||||||
@@ -78,6 +78,9 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
claims = await _verify_token(access_token)
|
claims = await _verify_token(access_token)
|
||||||
ctx = await context_from_claims(claims, db)
|
ctx = await context_from_claims(claims, db)
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
|
||||||
|
if ctx.role == "supervisor" and not has_projects:
|
||||||
|
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||||
await analytics.track(
|
await analytics.track(
|
||||||
"login_success",
|
"login_success",
|
||||||
{"username": payload.username, "role": ctx.role},
|
{"username": payload.username, "role": ctx.role},
|
||||||
@@ -92,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"expires_in": body.get("expires_in"),
|
"expires_in": body.get("expires_in"),
|
||||||
"user": user_to_dict(ctx.user, projects),
|
"user": await user_to_dict(ctx.user, projects, db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material
|
from app.models.catalog import Brand, BrandMaterial, Material
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||||
@@ -70,7 +72,11 @@ async def list_brand_materials(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand_material(
|
||||||
|
payload: BrandMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
if await db.get(Brand, payload.brand_id) is None:
|
if await db.get(Brand, payload.brand_id) is None:
|
||||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||||
if await db.get(Material, payload.material_id) is None:
|
if await db.get(Material, payload.material_id) is None:
|
||||||
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{bm_id}")
|
@router.patch("/{bm_id}")
|
||||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand_material(
|
||||||
|
bm_id,
|
||||||
|
payload: BrandMaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{bm_id}")
|
@router.delete("/{bm_id}")
|
||||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand_material(
|
||||||
|
bm_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial
|
from app.models.catalog import Brand, BrandMaterial
|
||||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def brand_to_dict(brand: Brand) -> dict:
|
def brand_to_dict(brand: Brand) -> dict:
|
||||||
@@ -42,7 +44,11 @@ async def list_brands(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand(
|
||||||
|
payload: BrandCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||||
if any(b.country == payload.country for b in rows):
|
if any(b.country == payload.country for b in rows):
|
||||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{brand_id}")
|
@router.patch("/{brand_id}")
|
||||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand(
|
||||||
|
brand_id,
|
||||||
|
payload: BrandUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{brand_id}")
|
@router.delete("/{brand_id}")
|
||||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand(
|
||||||
|
brand_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Drafts router — exposes bot_drafts to frontend for Wizard pre-population and bi-directional sync."""
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.engine import get_db
|
||||||
|
from app.models.draft import BotDraft
|
||||||
|
from app.utils.draft_token import verify_draft_token
|
||||||
|
from app.utils.errors import NotFoundError, UnauthorizedError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/drafts", tags=["drafts"])
|
||||||
|
|
||||||
|
|
||||||
|
class DraftUpdateIn(BaseModel):
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DraftCommitIn(BaseModel):
|
||||||
|
quote_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_draft(draft: BotDraft) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(draft.id),
|
||||||
|
"chat_id": draft.chat_id,
|
||||||
|
"kind": draft.kind,
|
||||||
|
"status": draft.status,
|
||||||
|
"payload": draft.payload or {},
|
||||||
|
"missing": draft.missing or [],
|
||||||
|
"source": draft.source,
|
||||||
|
"review_message_id": draft.review_message_id,
|
||||||
|
"last_bale_update_at": draft.last_bale_update_at.isoformat() if draft.last_bale_update_at else None,
|
||||||
|
"created_at": draft.created_at.isoformat() if draft.created_at else None,
|
||||||
|
"updated_at": draft.updated_at.isoformat() if draft.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_token_or_raise(token: str) -> dict:
|
||||||
|
token_payload = verify_draft_token(token)
|
||||||
|
if not token_payload or not token_payload.get("draft_id"):
|
||||||
|
raise UnauthorizedError("لینک بازبینی نامعتبر یا منقضی شده است")
|
||||||
|
return token_payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify")
|
||||||
|
async def verify_and_get_draft(token: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Public endpoint for token-based draft review without ERP login."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft), "token_payload": token_payload}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/by-token")
|
||||||
|
async def update_draft_by_token(token: str, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Update draft payload using verified token."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
if body.payload is not None:
|
||||||
|
merged = dict(draft.payload or {})
|
||||||
|
merged.update(body.payload)
|
||||||
|
draft.payload = merged
|
||||||
|
|
||||||
|
if body.status is not None:
|
||||||
|
draft.status = body.status
|
||||||
|
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/by-token/commit")
|
||||||
|
async def commit_draft_by_token(token: str, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Commit draft using verified token."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
draft.status = "committed"
|
||||||
|
payload = dict(draft.payload or {})
|
||||||
|
if body.quote_id:
|
||||||
|
payload["committed_quote_id"] = body.quote_id
|
||||||
|
draft.payload = payload
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/by-token/cancel")
|
||||||
|
async def cancel_draft_by_token(token: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Cancel draft using verified token."""
|
||||||
|
token_payload = _verify_token_or_raise(token)
|
||||||
|
draft_id = UUID(token_payload["draft_id"])
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
draft.status = "cancelled"
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{draft_id}")
|
||||||
|
async def get_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{draft_id}")
|
||||||
|
async def update_draft(draft_id: UUID, body: DraftUpdateIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
if body.payload is not None:
|
||||||
|
merged = dict(draft.payload or {})
|
||||||
|
merged.update(body.payload)
|
||||||
|
draft.payload = merged
|
||||||
|
|
||||||
|
if body.status is not None:
|
||||||
|
draft.status = body.status
|
||||||
|
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{draft_id}/commit")
|
||||||
|
async def commit_draft(draft_id: UUID, body: DraftCommitIn, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
draft.status = "committed"
|
||||||
|
payload = dict(draft.payload or {})
|
||||||
|
if body.quote_id:
|
||||||
|
payload["committed_quote_id"] = body.quote_id
|
||||||
|
draft.payload = payload
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{draft_id}/cancel")
|
||||||
|
async def cancel_draft(draft_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = select(BotDraft).where(BotDraft.id == draft_id)
|
||||||
|
draft = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if not draft:
|
||||||
|
raise NotFoundError("پیشنویس استعلام یافت نشد")
|
||||||
|
|
||||||
|
draft.status = "cancelled"
|
||||||
|
draft.updated_at = func.now()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(draft)
|
||||||
|
|
||||||
|
return {"data": _serialize_draft(draft)}
|
||||||
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
|
|||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||||
# برای هر کاربر واردشده آزاد است.
|
# برای هر کاربر واردشده آزاد است.
|
||||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||||
|
_attachment_writer = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||||
@@ -125,7 +126,7 @@ async def _attachment_or_404(
|
|||||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
|
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
|||||||
# Proforma — no status restriction
|
# Proforma — no status restriction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
|
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
@@ -58,6 +59,7 @@ class Scope:
|
|||||||
role: str
|
role: str
|
||||||
project_id: uuid.UUID | None
|
project_id: uuid.UUID | None
|
||||||
actor: str | None
|
actor: str | None
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def inventory_scope(
|
async def inventory_scope(
|
||||||
@@ -75,25 +77,41 @@ async def inventory_scope(
|
|||||||
if request.headers.get("authorization"):
|
if request.headers.get("authorization"):
|
||||||
user: UserContext = await get_optional_user(request, db)
|
user: UserContext = await get_optional_user(request, db)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if user.role is None:
|
panels = user.panels or {}
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
if user.role is None and not panels:
|
||||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
|
||||||
if user.role == "procurement_manager":
|
# ادمین: دستنخورده — همهچیز از جمله تغییر موجودی
|
||||||
|
if user.role == "admin":
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
if user.role not in ("pm", "admin"):
|
# لایه پنلها (جایگزین نقشها): پنل انبار اولویت دارد
|
||||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
if "inventory" in panels:
|
||||||
if user.role == "pm":
|
if panels["inventory"] == "full":
|
||||||
allowed = list(user.project_ids or [])
|
# کل سیستم و تمام پروژهها — بهجز تغییر موجودی
|
||||||
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
|
||||||
|
# limited: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||||
|
allowed = list(user.member_project_ids or [])
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("پروژهای به شما تخصیص داده نشده است")
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="pm", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||||
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||||
|
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||||
|
if user.role in ("procurement_manager", "pm"):
|
||||||
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
|
||||||
|
if user.role == "supervisor":
|
||||||
|
allowed = list(user.project_ids or [])
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ValidationError(
|
raise ForbiddenError("پروژه تخصیص داده نشده است")
|
||||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
if project_id is not None:
|
||||||
)
|
if project_id not in allowed:
|
||||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
return Scope(role="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)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
|
raise ValidationError("نقش نامعتبر است", field="role")
|
||||||
|
|
||||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||||
@@ -128,9 +146,9 @@ async def inventory_scope(
|
|||||||
return Scope(role=role, project_id=project_id, actor=actor)
|
return Scope(role=role, project_id=project_id, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
def _require_pm(scope: Scope) -> None:
|
def _require_inventory_modifier(scope: Scope) -> None:
|
||||||
if scope.role != ROLE_PM:
|
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -216,8 +234,16 @@ async def list_warehouses(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(Warehouse)
|
stmt = select(Warehouse)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
stmt = stmt.order_by(Warehouse.name)
|
stmt = stmt.order_by(Warehouse.name)
|
||||||
@@ -249,9 +275,11 @@ async def create_warehouse(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if payload.project_id not in allowed:
|
||||||
|
raise ValidationError("شما فقط میتوانید برای پروژه تخصیصیافته به خود انبار بسازید", field="project_id")
|
||||||
if await db.get(Project, payload.project_id) is None:
|
if await db.get(Project, payload.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||||
@@ -268,8 +296,9 @@ async def update_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if scope.project_id != wh.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
wh.name = payload.name
|
wh.name = payload.name
|
||||||
@@ -293,7 +322,9 @@ async def delete_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||||
# inventory cascade — block deletion if any stock history exists.
|
# inventory cascade — block deletion if any stock history exists.
|
||||||
@@ -324,7 +355,19 @@ async def list_inventory(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(InventoryItem)
|
stmt = select(InventoryItem)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == project_id
|
||||||
|
)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id.in_(allowed)
|
||||||
|
)
|
||||||
|
else:
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
Warehouse.project_id == scope.project_id
|
Warehouse.project_id == scope.project_id
|
||||||
)
|
)
|
||||||
@@ -389,7 +432,15 @@ async def list_transactions(
|
|||||||
stmt = select(InventoryTransaction).join(
|
stmt = select(InventoryTransaction).join(
|
||||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
@@ -398,9 +449,18 @@ async def list_transactions(
|
|||||||
if transaction_type is not None:
|
if transaction_type is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||||
|
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||||
|
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
|
|
||||||
@@ -455,7 +515,9 @@ async def inventory_history(
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise NotFoundError("موجودی یافت نشد")
|
raise NotFoundError("موجودی یافت نشد")
|
||||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||||
txs = (await db.execute(
|
txs = (await db.execute(
|
||||||
select(InventoryTransaction)
|
select(InventoryTransaction)
|
||||||
@@ -475,17 +537,19 @@ async def create_transaction(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_require_pm(scope)
|
_require_inventory_modifier(scope)
|
||||||
# No explicit db.begin(): the session may already be inside an implicit
|
# No explicit db.begin(): the session may already be inside an implicit
|
||||||
# transaction (inventory_scope read from it in the JWT path); get_db
|
# transaction (inventory_scope read from it in the JWT path); get_db
|
||||||
# commits on success / rolls back on failure.
|
# commits on success / rolls back on failure.
|
||||||
with nullcontext():
|
with nullcontext():
|
||||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||||
if wh.project_id != scope.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if payload.dest_warehouse_id is not None:
|
if payload.dest_warehouse_id is not None:
|
||||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||||
if dest.project_id != scope.project_id:
|
if dest.project_id not in allowed:
|
||||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||||
await audit_record(
|
await audit_record(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||||
tags=["item-receipts"],
|
tags=["item-receipts"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
async def create_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await _decision_or_404(db, line_item_id)
|
decision = await _decision_or_404(db, line_item_id)
|
||||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||||
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("")
|
||||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||||
if decision is None:
|
if decision is None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||||
tags=["item-termsheets"],
|
tags=["item-termsheets"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_item_termsheet(
|
async def create_item_termsheet(
|
||||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ItemTermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
@@ -93,7 +99,12 @@ async def create_item_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{item_termsheet_id}")
|
@router.patch("/{item_termsheet_id}")
|
||||||
async def update_item_termsheet(
|
async def update_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
payload: ItemTermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
|||||||
|
|
||||||
@router.delete("/{item_termsheet_id}")
|
@router.delete("/{item_termsheet_id}")
|
||||||
async def delete_item_termsheet(
|
async def delete_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Unit
|
from app.models.catalog import AttributeDefinition, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||||
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
|
|||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
|
|||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("")
|
||||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
async def put_line_item_specs(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: SpecsPutIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, Unit
|
from app.models.catalog import Material, Unit
|
||||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||||
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
async def create_line_item(
|
||||||
|
quote_id,
|
||||||
|
payload: LineItemCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
|||||||
|
|
||||||
@router.patch("/{line_item_id}")
|
@router.patch("/{line_item_id}")
|
||||||
async def update_line_item(
|
async def update_line_item(
|
||||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: LineItemUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
@@ -131,7 +142,12 @@ async def update_line_item(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{line_item_id}")
|
@router.delete("/{line_item_id}")
|
||||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
async def delete_line_item(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Supplier
|
from app.models.procurement import Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def category_to_dict(category: MaterialCategory) -> dict:
|
def category_to_dict(category: MaterialCategory) -> dict:
|
||||||
@@ -46,7 +48,11 @@ async def list_categories(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
async def create_category(
|
||||||
|
payload: MaterialCategoryCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||||
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{category_id}")
|
@router.patch("/{category_id}")
|
||||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_category(
|
||||||
|
category_id,
|
||||||
|
payload: MaterialCategoryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{category_id}")
|
@router.delete("/{category_id}")
|
||||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
async def delete_category(
|
||||||
|
category_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import (
|
from app.models.catalog import (
|
||||||
AttributeDefinition,
|
AttributeDefinition,
|
||||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def _coerce_filter_value(data_type: str, raw: str):
|
def _coerce_filter_value(data_type: str, raw: str):
|
||||||
@@ -125,7 +127,10 @@ async def list_materials(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_material(
|
async def create_material(
|
||||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
payload: MaterialCreate,
|
||||||
|
expand: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
material = await material_service.create_material(db, payload)
|
material = await material_service.create_material(db, payload)
|
||||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||||
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{material_id}")
|
@router.patch("/{material_id}")
|
||||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_material(
|
||||||
|
material_id,
|
||||||
|
payload: MaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}")
|
@router.delete("/{material_id}")
|
||||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material(
|
||||||
|
material_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
@@ -248,7 +262,10 @@ async def list_material_units(
|
|||||||
|
|
||||||
@router.post("/{material_id}/units", status_code=201)
|
@router.post("/{material_id}/units", status_code=201)
|
||||||
async def create_material_unit(
|
async def create_material_unit(
|
||||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialUnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
await _material_or_404(db, material_id)
|
await _material_or_404(db, material_id)
|
||||||
@@ -287,7 +304,11 @@ async def create_material_unit(
|
|||||||
|
|
||||||
@router.patch("/{material_id}/units/{mu_id}")
|
@router.patch("/{material_id}/units/{mu_id}")
|
||||||
async def update_material_unit(
|
async def update_material_unit(
|
||||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
payload: MaterialUnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
@@ -319,7 +340,12 @@ async def update_material_unit(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}/units/{mu_id}")
|
@router.delete("/{material_id}/units/{mu_id}")
|
||||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material_unit(
|
||||||
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||||
)
|
)
|
||||||
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
|
|||||||
|
|
||||||
@router.put("/{material_id}/attribute-values")
|
@router.put("/{material_id}/attribute-values")
|
||||||
async def put_material_attribute_values(
|
async def put_material_attribute_values(
|
||||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialAttributeValuesPut,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
|
|||||||
+33
-12
@@ -71,16 +71,17 @@ class ArrivalScope:
|
|||||||
user_id: uuid.UUID | None
|
user_id: uuid.UUID | None
|
||||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||||
display_name: str
|
display_name: str
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def arrivals_scope(
|
async def arrivals_scope(
|
||||||
request: Request,
|
request: Request,
|
||||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای دادههای پروژه)"),
|
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: دادههای پروژه)"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ArrivalScope:
|
) -> ArrivalScope:
|
||||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||||
|
|
||||||
PM → scoped to the requested project, which must be one they manage
|
PM / supervisor → scoped to the requested project, which must be one they manage
|
||||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||||
Admin / procurement manager → all projects (param = optional filter).
|
Admin / procurement manager → all projects (param = optional filter).
|
||||||
"""
|
"""
|
||||||
@@ -89,19 +90,19 @@ async def arrivals_scope(
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
if ctx.role is None:
|
if ctx.role is None:
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
if ctx.role == "pm":
|
if ctx.role in ("pm", "supervisor"):
|
||||||
allowed = list(ctx.project_ids or [])
|
allowed = list(ctx.project_ids or [])
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
|
||||||
if ctx.role in ("admin", "procurement_manager"):
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
|
||||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
|
||||||
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||||
# Disabled by default: an ungated header identity in production would
|
# Disabled by default: an ungated header identity in production would
|
||||||
# let anyone confirm receipts as PM.
|
# let anyone confirm receipts as PM.
|
||||||
@@ -110,8 +111,10 @@ async def arrivals_scope(
|
|||||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = "admin"
|
role = "admin"
|
||||||
|
elif role == "supervisor":
|
||||||
|
role = "pm"
|
||||||
if role not in ("pm", "admin"):
|
if role not in ("pm", "admin"):
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||||
# The explicit query param wins; otherwise the dev header carries it.
|
# The explicit query param wins; otherwise the dev header carries it.
|
||||||
if project_id is None and role == "pm":
|
if project_id is None and role == "pm":
|
||||||
raw_project = request.headers.get("x-project-id")
|
raw_project = request.headers.get("x-project-id")
|
||||||
@@ -189,10 +192,17 @@ async def pm_on_the_way(
|
|||||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
.where(ItemDecision.status == "chosen")
|
.where(ItemDecision.status == "chosen")
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if ctx.role == "pm":
|
||||||
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if not allowed:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
if ctx.project_id is not None:
|
if ctx.project_id is not None:
|
||||||
# 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)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||||
|
|
||||||
@@ -243,6 +253,10 @@ async def pm_confirm_receipt(
|
|||||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||||
if decision is None or decision.status != "chosen":
|
if decision is None or decision.status != "chosen":
|
||||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||||
|
if ctx.role == "pm":
|
||||||
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if decision.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||||
|
|
||||||
@@ -451,10 +465,17 @@ async def pm_receipts(
|
|||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(Project, Project.id == ItemDecision.project_id)
|
.join(Project, Project.id == ItemDecision.project_id)
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if ctx.role == "pm":
|
||||||
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if not allowed:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
if ctx.project_id is not None:
|
if ctx.project_id is not None:
|
||||||
# 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)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||||
receipts = [r.ItemReceipt for r in rows]
|
receipts = [r.ItemReceipt for r in rows]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, Quote, User
|
from app.models.procurement import Project, Quote, User
|
||||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||||
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_date, to_iso
|
from app.utils.serialization import to_date, to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||||
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
d = {
|
d = {
|
||||||
"id": str(project.id),
|
"id": str(project.id),
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"location": project.location or "",
|
||||||
|
"project_manager": project.project_manager or "",
|
||||||
|
"description": project.description or "",
|
||||||
"start_date": to_date(project.start_date),
|
"start_date": to_date(project.start_date),
|
||||||
|
"status": project.status or "ACTIVE",
|
||||||
|
"progress": float(project.progress) if project.progress is not None else 0.0,
|
||||||
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
||||||
"created_at": to_iso(project.created_at),
|
"created_at": to_iso(project.created_at),
|
||||||
}
|
}
|
||||||
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
pm = await db.get(User, project.pm_user_id)
|
pm = await db.get(User, project.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif project.project_manager:
|
||||||
|
d["pm_name"] = project.project_manager
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +62,14 @@ async def list_projects(
|
|||||||
):
|
):
|
||||||
stmt = select(Project)
|
stmt = select(Project)
|
||||||
if q:
|
if q:
|
||||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Project.name.ilike(f"%{q}%"),
|
||||||
|
Project.description.ilike(f"%{q}%"),
|
||||||
|
Project.location.ilike(f"%{q}%"),
|
||||||
|
Project.project_manager.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, Project, sort)
|
stmt = sort_query(stmt, Project, sort)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
pm_map = await _pm_map(db, items)
|
pm_map = await _pm_map(db, items)
|
||||||
@@ -64,24 +78,38 @@ async def list_projects(
|
|||||||
d = {
|
d = {
|
||||||
"id": str(p.id),
|
"id": str(p.id),
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"description": p.description,
|
"location": p.location or "",
|
||||||
|
"project_manager": p.project_manager or "",
|
||||||
|
"description": p.description or "",
|
||||||
"start_date": to_date(p.start_date),
|
"start_date": to_date(p.start_date),
|
||||||
|
"status": p.status or "ACTIVE",
|
||||||
|
"progress": float(p.progress) if p.progress is not None else 0.0,
|
||||||
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
||||||
"created_at": to_iso(p.created_at),
|
"created_at": to_iso(p.created_at),
|
||||||
}
|
}
|
||||||
pm = pm_map.get(p.pm_user_id)
|
pm = pm_map.get(p.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif p.project_manager:
|
||||||
|
d["pm_name"] = p.project_manager
|
||||||
data.append(d)
|
data.append(d)
|
||||||
return {"data": data, "meta": meta}
|
return {"data": data, "meta": meta}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
async def create_project(
|
||||||
|
payload: ProjectCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = Project(
|
project = Project(
|
||||||
name=payload.name,
|
name=payload.name.strip(),
|
||||||
description=payload.description,
|
location=payload.location.strip() if payload.location else "",
|
||||||
|
project_manager=payload.project_manager.strip() if payload.project_manager else "",
|
||||||
|
description=payload.description.strip() if payload.description else "",
|
||||||
start_date=payload.start_date,
|
start_date=payload.start_date,
|
||||||
|
planned_start=payload.start_date,
|
||||||
|
status="ACTIVE",
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -103,37 +131,51 @@ async def update_project(
|
|||||||
request: Request,
|
request: Request,
|
||||||
ctx: UserContext | None = Depends(_optional_ctx),
|
ctx: UserContext | None = Depends(_optional_ctx),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
project.name = payload.name
|
project.name = payload.name.strip()
|
||||||
|
if payload.location is not None:
|
||||||
|
project.location = payload.location.strip()
|
||||||
|
if payload.project_manager is not None:
|
||||||
|
project.project_manager = payload.project_manager.strip()
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
project.description = payload.description
|
project.description = payload.description.strip()
|
||||||
if payload.start_date is not None:
|
if payload.start_date is not None:
|
||||||
project.start_date = payload.start_date
|
project.start_date = payload.start_date
|
||||||
|
project.planned_start = payload.start_date
|
||||||
|
if payload.status is not None:
|
||||||
|
project.status = payload.status
|
||||||
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
||||||
# Admin / procurement manager only (same rule as the Organization panel).
|
# Admin / procurement manager only (same rule as the Organization panel).
|
||||||
if "pm_user_id" in payload.model_fields_set:
|
if "pm_user_id" in payload.model_fields_set:
|
||||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role not in ("admin", "procurement_manager"):
|
if role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||||
if payload.pm_user_id is None:
|
if payload.pm_user_id is None:
|
||||||
project.pm_user_id = None
|
project.pm_user_id = None
|
||||||
else:
|
else:
|
||||||
pm_user = await db.get(User, payload.pm_user_id)
|
pm_user = await db.get(User, payload.pm_user_id)
|
||||||
if pm_user is None:
|
if pm_user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||||
if pm_user.role != "pm":
|
if pm_user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
|
||||||
project.pm_user_id = pm_user.id
|
project.pm_user_id = pm_user.id
|
||||||
|
if not project.project_manager:
|
||||||
|
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": await project_to_dict(db, project)}
|
return {"data": await project_to_dict(db, project)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
async def delete_project(
|
||||||
|
project_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote, QuoteEvent
|
from app.models.procurement import Quote, QuoteEvent
|
||||||
from app.schemas.procurement import NoteEventIn
|
from app.schemas.procurement import NoteEventIn
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -34,7 +36,12 @@ async def list_events(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
async def create_event(
|
||||||
|
quote_id,
|
||||||
|
payload: NoteEventIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
if quote is None:
|
if quote is None:
|
||||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import exists, func, or_, select
|
from sqlalchemy import exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material
|
from app.models.catalog import Material
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
@@ -36,12 +37,15 @@ from app.services.expand_service import (
|
|||||||
serialize_quote_with_children,
|
serialize_quote_with_children,
|
||||||
serialize_quotes,
|
serialize_quotes,
|
||||||
)
|
)
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||||
from app.utils.pagination import sort_query
|
from app.utils.pagination import sort_query
|
||||||
from app.utils.security import get_actor
|
from app.utils.security import get_actor
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||||
|
|
||||||
|
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
# --- list filter helpers ---------------------------------------------------
|
# --- list filter helpers ---------------------------------------------------
|
||||||
|
|
||||||
@@ -156,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def create_quote(
|
||||||
|
payload: QuoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
ctx: UserContext = Depends(allow_quote_create()),
|
||||||
|
_auth=None,
|
||||||
|
):
|
||||||
|
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||||
|
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||||
|
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||||
|
if ctx.project_id:
|
||||||
|
allowed.add(ctx.project_id)
|
||||||
|
allowed_str = {str(pid) for pid in allowed if pid}
|
||||||
|
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||||
|
if not wanted:
|
||||||
|
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||||
|
if not wanted <= allowed_str:
|
||||||
|
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||||
quote = await quote_service.create_quote(db, payload, actor)
|
quote = await quote_service.create_quote(db, payload, actor)
|
||||||
return {"data": await serialize_quote_with_children(db, quote)}
|
return {"data": await serialize_quote_with_children(db, quote)}
|
||||||
|
|
||||||
@@ -167,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
|||||||
|
|
||||||
@router.patch("/{quote_id}")
|
@router.patch("/{quote_id}")
|
||||||
async def update_quote(
|
async def update_quote(
|
||||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await serialize_quote(db, quote, set())}
|
return {"data": await serialize_quote(db, quote, set())}
|
||||||
@@ -175,7 +200,11 @@ async def update_quote(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/children")
|
@router.patch("/{quote_id}/children")
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteChildrenReplaceIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||||
@@ -188,7 +217,11 @@ async def replace_quote_children(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/accept")
|
@router.patch("/{quote_id}/accept")
|
||||||
async def accept_quote(
|
async def accept_quote(
|
||||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: AcceptQuoteIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await _accept_response(db, quote)}
|
return {"data": await _accept_response(db, quote)}
|
||||||
@@ -224,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/arrive")
|
@router.patch("/{quote_id}/arrive")
|
||||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def arrive_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -237,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/arrive-items")
|
@router.patch("/{quote_id}/arrive-items")
|
||||||
async def arrive_items(
|
async def arrive_items(
|
||||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: ArriveItemsIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||||
return {
|
return {
|
||||||
@@ -259,14 +301,23 @@ async def arrive_items(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/reject")
|
@router.patch("/{quote_id}/reject")
|
||||||
async def reject_quote(
|
async def reject_quote(
|
||||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: RejectQuoteIn | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/expire")
|
@router.patch("/{quote_id}/expire")
|
||||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def expire_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
@@ -276,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.delete("/{quote_id}")
|
@router.delete("/{quote_id}")
|
||||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
async def delete_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
result = await quote_service.delete_quote(db, quote_id)
|
result = await quote_service.delete_quote(db, quote_id)
|
||||||
return {"data": result}
|
return {"data": result}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||||
tags=["schedule-steps"],
|
tags=["schedule-steps"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||||
@@ -57,7 +59,11 @@ async def list_schedule_steps(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_schedule_step(
|
async def create_schedule_step(
|
||||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: ScheduleStepCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
step_no = payload.step_no
|
step_no = payload.step_no
|
||||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
|||||||
|
|
||||||
@router.patch("/{step_id}")
|
@router.patch("/{step_id}")
|
||||||
async def update_schedule_step(
|
async def update_schedule_step(
|
||||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
payload: ScheduleStepUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||||
@@ -109,7 +120,13 @@ async def update_schedule_step(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{step_id}")
|
@router.delete("/{step_id}")
|
||||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
async def delete_schedule_step(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
count = await db.scalar(
|
count = await db.scalar(
|
||||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import StandardConversion
|
from app.models.catalog import StandardConversion
|
||||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_standard_conversion(
|
||||||
|
payload: StandardConversionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
select(StandardConversion.id).where(
|
select(StandardConversion.id).where(
|
||||||
StandardConversion.from_unit == payload.from_unit,
|
StandardConversion.from_unit == payload.from_unit,
|
||||||
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{sc_id}")
|
@router.patch("/{sc_id}")
|
||||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
payload: StandardConversionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{sc_id}")
|
@router.delete("/{sc_id}")
|
||||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
async def delete_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
||||||
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
||||||
GET /materials/{id}/suppliers — price-comparison data source.
|
GET /materials/{id}/suppliers — suppliers linked to a material.
|
||||||
"""
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import Material, MaterialCategory
|
||||||
from app.models.procurement import Supplier, SupplierMaterial
|
from app.models.procurement import Supplier, SupplierMaterial
|
||||||
@@ -18,31 +21,50 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(tags=["supplier-materials"])
|
router = APIRouter(tags=["supplier-materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
def link_to_dict(
|
||||||
|
link: SupplierMaterial,
|
||||||
|
material: Material | None = None,
|
||||||
category: MaterialCategory | 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 {
|
return {
|
||||||
"id": str(link.id),
|
"id": str(link.id),
|
||||||
"supplier_id": str(link.supplier_id),
|
"supplier_id": str(link.supplier_id),
|
||||||
"material_id": str(link.material_id),
|
"material_id": str(link.material_id),
|
||||||
"is_active": link.is_active,
|
"is_active": link.is_active,
|
||||||
"last_quoted_price": 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,
|
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
||||||
"created_at": to_iso(link.created_at),
|
"created_at": to_iso(link.created_at),
|
||||||
"material": {
|
"material": (
|
||||||
|
{
|
||||||
"id": str(material.id),
|
"id": str(material.id),
|
||||||
"name": material.name,
|
"name": material.name,
|
||||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
"category": (
|
||||||
} if material else None,
|
{"id": str(category.id), "name": category.name} if category else None
|
||||||
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
),
|
||||||
|
}
|
||||||
|
if material
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"supplier": (
|
||||||
|
{"id": str(supplier.id), "name": supplier.name} if supplier else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /suppliers/{supplier_id}/materials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/suppliers/{supplier_id}/materials")
|
@router.get("/suppliers/{supplier_id}/materials")
|
||||||
async def list_supplier_materials(
|
async def list_supplier_materials(
|
||||||
supplier_id,
|
supplier_id: UUID,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -56,23 +78,45 @@ async def list_supplier_materials(
|
|||||||
.order_by(SupplierMaterial.created_at)
|
.order_by(SupplierMaterial.created_at)
|
||||||
)
|
)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
mat_ids = {l.material_id for l in items}
|
mat_ids = [lnk.material_id for lnk in items]
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials: dict = {}
|
||||||
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
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)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
async def create_supplier_material(
|
async def create_supplier_material(
|
||||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
supplier_id: UUID,
|
||||||
|
payload: SupplierMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||||
material = await db.get(Material, payload.material_id)
|
material = await db.get(Material, payload.material_id)
|
||||||
return {"data": link_to_dict(link, material, None)}
|
return {"data": link_to_dict(link, material)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /suppliers/{supplier_id}/materials/{material_id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||||
async def delete_supplier_material(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():
|
async with db.begin():
|
||||||
await unlink_supplier_material(db, supplier_id, material_id)
|
await unlink_supplier_material(db, supplier_id, material_id)
|
||||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||||
@@ -83,17 +127,80 @@ async def list_material_suppliers(
|
|||||||
material_id,
|
material_id,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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: 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)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(SupplierMaterial)
|
select(Supplier)
|
||||||
|
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
.where(SupplierMaterial.material_id == material_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)
|
suppliers = (await db.execute(stmt)).scalars().all()
|
||||||
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 {
|
||||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
"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 import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Quote, Supplier
|
from app.models.procurement import Quote, Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
async def create_supplier(
|
||||||
|
payload: SupplierCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await _category_or_404(db, payload.category_id)
|
category = await _category_or_404(db, payload.category_id)
|
||||||
supplier = Supplier(
|
supplier = Supplier(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{supplier_id}")
|
@router.patch("/{supplier_id}")
|
||||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_supplier(
|
||||||
|
supplier_id,
|
||||||
|
payload: SupplierUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{supplier_id}")
|
@router.delete("/{supplier_id}")
|
||||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier(
|
||||||
|
supplier_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
async def create_termsheet(
|
||||||
|
quote_id,
|
||||||
|
payload: TermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}")
|
@router.patch("/{termsheet_id}")
|
||||||
async def update_termsheet(
|
async def update_termsheet(
|
||||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
if ts.status != "offered":
|
if ts.status != "offered":
|
||||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}/status")
|
@router.patch("/{termsheet_id}/status")
|
||||||
async def update_termsheet_status(
|
async def update_termsheet_status(
|
||||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetStatusUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{termsheet_id}")
|
@router.delete("/{termsheet_id}")
|
||||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
async def delete_termsheet(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec
|
from app.models.procurement import LineItem, LineItemSpec
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/units", tags=["units"])
|
router = APIRouter(prefix="/units", tags=["units"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def unit_full_to_dict(unit: Unit) -> dict:
|
def unit_full_to_dict(unit: Unit) -> dict:
|
||||||
@@ -45,7 +47,11 @@ async def list_units(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
async def create_unit(
|
||||||
|
payload: UnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{unit_id}")
|
@router.patch("/{unit_id}")
|
||||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
payload: UnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}")
|
@router.delete("/{unit_id}")
|
||||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
async def delete_unit(
|
||||||
|
unit_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
|
|||||||
+186
-31
@@ -17,15 +17,21 @@ import uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, select, update, text, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
from app.auth import UserContext, get_current_user, require_role
|
||||||
from app.utils.audit import record as audit_record
|
from app.utils.audit import record as audit_record
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||||
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
from app.schemas.procurement import (
|
||||||
|
UserCreate,
|
||||||
|
UserPanelsUpdate,
|
||||||
|
UserPanelProjectsUpdate,
|
||||||
|
UserProjectUpdate,
|
||||||
|
UserRoleUpdate,
|
||||||
|
)
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ConflictError,
|
ConflictError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -41,17 +47,47 @@ USER_ROLE_LABELS = {
|
|||||||
"pm": "مدیر پروژه",
|
"pm": "مدیر پروژه",
|
||||||
"procurement_manager": "مدیر تدارکات",
|
"procurement_manager": "مدیر تدارکات",
|
||||||
"admin": "ادمین",
|
"admin": "ادمین",
|
||||||
|
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||||
|
"supervisor": "سرپرست پروژه",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
PANEL_LEVELS = ("full", "limited")
|
||||||
|
PANELS = ("procurement", "inventory", "pm")
|
||||||
|
|
||||||
|
|
||||||
|
async def _panels_of(db: AsyncSession, user_id) -> dict:
|
||||||
|
"""panel -> level map for the user (لایه پنلها — جایگزین نقشها)."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return {row.panel: row.level for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
|
||||||
|
"""عضویت M:N کاربر در پروژهها (جدا از مسئولیت) — رشته."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership.project_id)
|
||||||
|
.where(UserProjectMembership.user_id == user_id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [str(pid) for pid in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
|
||||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||||
project_id/project_name stay as the first (oldest) one for compatibility."""
|
project_id/project_name stay as the first (oldest) one for compatibility.
|
||||||
|
با db، پنلهای تخصیصی و عضویت پروژهای هم برمیگردند."""
|
||||||
projects = projects or []
|
projects = projects or []
|
||||||
first = projects[0] if projects else None
|
first = projects[0] if projects else None
|
||||||
return {
|
out = {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"casdoor_id": user.casdoor_id,
|
"casdoor_id": user.casdoor_id,
|
||||||
|
"username": user.username,
|
||||||
"first_name": user.first_name,
|
"first_name": user.first_name,
|
||||||
"last_name": user.last_name,
|
"last_name": user.last_name,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
@@ -62,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
|||||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||||
"created_at": to_iso(user.created_at),
|
"created_at": to_iso(user.created_at),
|
||||||
}
|
}
|
||||||
|
if db is not None:
|
||||||
|
out["panels"] = await _panels_of(db, user.id)
|
||||||
|
member_pids = await _member_project_ids_of(db, user.id)
|
||||||
|
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
|
||||||
|
out["member_project_ids"] = all_pids
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
"""Admin OR procurement manager (the same person) — organization management."""
|
"""Admin, procurement manager, or project manager — organization management."""
|
||||||
if ctx.role not in ("admin", "procurement_manager"):
|
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
"""Every project assigned to the user (supervisor or PM) (oldest first).
|
||||||
if user.role != "pm":
|
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
|
||||||
|
"""
|
||||||
|
if user.role not in ("supervisor", "pm"):
|
||||||
return []
|
return []
|
||||||
|
member_subq = select(UserProjectMembership.project_id).where(
|
||||||
|
UserProjectMembership.user_id == user.id
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(
|
||||||
|
or_(
|
||||||
|
Project.pm_user_id == user.id,
|
||||||
|
Project.id.in_(member_subq),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -127,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
|
|||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
return {"data": user_to_dict(ctx.user, projects)}
|
return {"data": await user_to_dict(ctx.user, projects, db)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/projects")
|
@router.get("/me/projects")
|
||||||
@@ -138,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
|
|||||||
active project against this list and falls back gracefully when a
|
active project against this list and falls back gracefully when a
|
||||||
project is revoked."""
|
project is revoked."""
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
# عضویتهای M:N (لایه پنلها) هم به فهرست اضافه میشود
|
||||||
|
member_ids = set(ctx.member_project_ids or [])
|
||||||
|
have = {p.id for p in projects}
|
||||||
|
if member_ids:
|
||||||
|
extra = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.id.in_(member_ids))
|
||||||
|
.order_by(Project.created_at, Project.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
projects = list(projects) + [p for p in extra if p.id not in have]
|
||||||
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,20 +212,20 @@ async def list_users(
|
|||||||
):
|
):
|
||||||
"""Every user (with role + assigned projects) — Organization panel."""
|
"""Every user (with role + assigned projects) — Organization panel."""
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in projs:
|
for p in projs:
|
||||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||||
return {
|
return {
|
||||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||||
"meta": {"total": len(users)},
|
"meta": {"total": len(users)},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,30 +251,30 @@ async def create_user(
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": await user_to_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}")
|
@router.patch("/{user_id}")
|
||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
ctx: UserContext = Depends(require_role("admin")),
|
ctx: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
if payload.role == "admin" and ctx.role != "admin":
|
||||||
unassigns the user from their project."""
|
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
previous_role = user.role
|
previous_role = user.role
|
||||||
if user.role == "pm" and payload.role != "pm":
|
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
user.role = payload.role
|
user.role = payload.role
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
||||||
projects = await _user_projects(db, user)
|
projects = await _user_projects(db, user)
|
||||||
return {"data": user_to_dict(user, projects)}
|
return {"data": await user_to_dict(user, projects)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync")
|
@router.post("/sync")
|
||||||
@@ -231,8 +295,15 @@ async def sync_casdoor_users(
|
|||||||
continue
|
continue
|
||||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
by_uname = await db.scalar(select(User).where(User.username == uname))
|
||||||
|
if by_uname is not None:
|
||||||
|
by_uname.casdoor_id = cid
|
||||||
|
continue
|
||||||
db.add(User(
|
db.add(User(
|
||||||
casdoor_id=cid,
|
casdoor_id=cid,
|
||||||
|
username=uname,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
cu.get("firstName")
|
cu.get("firstName")
|
||||||
or cu.get("displayName")
|
or cu.get("displayName")
|
||||||
@@ -243,23 +314,27 @@ async def sync_casdoor_users(
|
|||||||
phone=str(cu.get("phone") or "") or None,
|
phone=str(cu.get("phone") or "") or None,
|
||||||
))
|
))
|
||||||
created += 1
|
created += 1
|
||||||
|
elif not existing.username:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
existing.username = uname
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in projs:
|
for p in projs:
|
||||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||||
return {
|
return {
|
||||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||||
"meta": {"total": len(users), "created": created},
|
"meta": {"total": len(users), "created": created},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,17 +357,97 @@ async def assign_user_project(
|
|||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
if user.role != "pm":
|
if user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||||
|
|
||||||
if payload.project_id is None:
|
if payload.project_id is None:
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
|
||||||
|
{"uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": await user_to_dict(user)}
|
||||||
|
|
||||||
project = await db.get(Project, payload.project_id)
|
project = await db.get(Project, payload.project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
project.pm_user_id = user.id
|
project.pm_user_id = user.id
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO project_assignments (id, user_id, project_id)
|
||||||
|
SELECT gen_random_uuid(), pu.id, :pid
|
||||||
|
FROM pm_users pu
|
||||||
|
WHERE pu.username = :uname
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project_assignments pa
|
||||||
|
WHERE pa.user_id = pu.id AND pa.project_id = :pid
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"pid": project.id, "uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
return {"data": await user_to_dict(user, await _user_projects(db, user))}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# پنلها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/panels")
|
||||||
|
@router.patch("/{user_id}/panels")
|
||||||
|
async def set_user_panels(
|
||||||
|
user_id,
|
||||||
|
payload: UserPanelsUpdate,
|
||||||
|
_: UserContext = Depends(_org_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
for panel, level in payload.panels.items():
|
||||||
|
if panel not in PANELS:
|
||||||
|
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
|
||||||
|
if level not in PANEL_LEVELS:
|
||||||
|
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
|
||||||
|
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
|
||||||
|
for panel, level in payload.panels.items():
|
||||||
|
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
|
||||||
|
await db.flush()
|
||||||
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/panel-projects")
|
||||||
|
@router.patch("/{user_id}/panel-projects")
|
||||||
|
async def set_user_panel_projects(
|
||||||
|
user_id,
|
||||||
|
payload: UserPanelProjectsUpdate,
|
||||||
|
_: UserContext = Depends(_org_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
wanted = list(dict.fromkeys(payload.project_ids))
|
||||||
|
if wanted:
|
||||||
|
found = (
|
||||||
|
await db.execute(select(Project).where(Project.id.in_(wanted)))
|
||||||
|
).scalars().all()
|
||||||
|
found_ids = {p.id for p in found}
|
||||||
|
unknown = [str(pid) for pid in wanted if pid not in found_ids]
|
||||||
|
if unknown:
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
for pid in wanted:
|
||||||
|
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
|
||||||
|
await db.flush()
|
||||||
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||||
|
|||||||
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
|
|||||||
|
|
||||||
class ProjectCreate(APIModel):
|
class ProjectCreate(APIModel):
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(APIModel):
|
class ProjectUpdate(APIModel):
|
||||||
name: str | None = Field(default=None, min_length=1)
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
status: str | None = None
|
||||||
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
||||||
# Admin / procurement manager only; None unassigns this project's PM.
|
# Admin / procurement manager only; None unassigns this project's PM.
|
||||||
pm_user_id: UUID | None = None
|
pm_user_id: UUID | None = None
|
||||||
@@ -209,13 +214,13 @@ class UserCreate(APIModel):
|
|||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
casdoor_id: str | None = None
|
casdoor_id: str | None = None
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserRoleUpdate(APIModel):
|
class UserRoleUpdate(APIModel):
|
||||||
"""role=None revokes the role (and any project assignment)."""
|
"""role=None revokes the role (and any project assignment)."""
|
||||||
|
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserProjectUpdate(APIModel):
|
class UserProjectUpdate(APIModel):
|
||||||
@@ -347,3 +352,16 @@ class ReceiptUpdate(APIModel):
|
|||||||
received_qty: Decimal | None = Field(default=None, gt=0)
|
received_qty: Decimal | None = Field(default=None, gt=0)
|
||||||
on_time: bool | None = None
|
on_time: bool | None = None
|
||||||
quality_note: str | None = None
|
quality_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanelsUpdate(APIModel):
|
||||||
|
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
|
||||||
|
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف میشود."""
|
||||||
|
|
||||||
|
panels: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanelProjectsUpdate(APIModel):
|
||||||
|
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
|
||||||
|
|
||||||
|
project_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""HMAC-SHA256 token verification for standalone draft review."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - (len(s) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret(secret: str | None = None) -> str:
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
try:
|
||||||
|
from app.config import settings
|
||||||
|
return settings.DRAFT_TOKEN_SECRET
|
||||||
|
except Exception:
|
||||||
|
return "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft_token(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
secret: str | None = None,
|
||||||
|
expires_in: int = 7200,
|
||||||
|
) -> str:
|
||||||
|
sec = _get_secret(secret)
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"draft_id": str(draft_id),
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + expires_in,
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
payload_b64 = _b64url_encode(payload_bytes)
|
||||||
|
|
||||||
|
sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
sig_b64 = _b64url_encode(sig)
|
||||||
|
|
||||||
|
return f"{payload_b64}.{sig_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_draft_token(
|
||||||
|
token: str,
|
||||||
|
secret: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sec = _get_secret(secret)
|
||||||
|
payload_b64, sig_b64 = token.split(".", 1)
|
||||||
|
expected_sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
actual_sig = _b64url_decode(sig_b64)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_bytes = _b64url_decode(payload_b64)
|
||||||
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None and int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Bale Bot API — token from Bale's BotFather (required)
|
||||||
|
PROCUREMENT_BOT_BOT_TOKEN=
|
||||||
|
PROCUREMENT_BOT_BALE_API_BASE=https://tapi.bale.ai
|
||||||
|
|
||||||
|
# ERP procurement API (in Docker: http://api:8000/api/v1)
|
||||||
|
PROCUREMENT_BOT_API_BASE_URL=http://localhost:8000/api/v1
|
||||||
|
PROCUREMENT_BOT_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/procurement
|
||||||
|
|
||||||
|
# LLM provider — any OpenAI-compatible endpoint (JSON-mode chat completions)
|
||||||
|
PROCUREMENT_BOT_AI_BASE_URL=https://api.deepseek.com
|
||||||
|
PROCUREMENT_BOT_AI_API_KEY=
|
||||||
|
PROCUREMENT_BOT_LLM_MODEL=deepseek-flash
|
||||||
|
|
||||||
|
# STT provider — must expose POST /audio/transcriptions.
|
||||||
|
# Falls back to AI_* when unset. DeepSeek has no audio API, so voice needs
|
||||||
|
# its own whisper-compatible provider (OpenAI, GLM, self-hosted faster-whisper…).
|
||||||
|
PROCUREMENT_BOT_STT_BASE_URL=
|
||||||
|
PROCUREMENT_BOT_STT_API_KEY=
|
||||||
|
PROCUREMENT_BOT_STT_MODEL=whisper-1
|
||||||
|
PROCUREMENT_BOT_STT_LANGUAGE=fa
|
||||||
|
|
||||||
|
# Bale chat ids allowed without Casdoor linking (comma separated)
|
||||||
|
PROCUREMENT_BOT_ADMIN_CHAT_IDS=
|
||||||
|
# Optional bearer for the day the ERP starts enforcing auth on procurement routes
|
||||||
|
PROCUREMENT_BOT_SERVICE_TOKEN=
|
||||||
|
# Drafts older than this are swept into `expired`
|
||||||
|
PROCUREMENT_BOT_DRAFT_TTL_HOURS=24
|
||||||
|
PROCUREMENT_BOT_LOG_LEVEL=INFO
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# ---- Env / secrets ----
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ---- Python ----
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# ---- OS junk ----
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Bale procurement bot — a long-polling worker against tapi.bale.ai.
|
||||||
|
# ffmpeg converts Bale voice notes (OGG/Opus) before they are sent to STT.
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /srv/bot
|
||||||
|
|
||||||
|
# Dependencies first (layer caching). PIP_INDEX_URL overridable like the api image.
|
||||||
|
ARG PIP_INDEX_URL=https://pypi.org/simple
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
|
||||||
|
|
||||||
|
# Application
|
||||||
|
COPY app ./app
|
||||||
|
COPY tools ./tools
|
||||||
|
|
||||||
|
CMD ["python", "-m", "app.main"]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
|
||||||
|
|
||||||
|
> این سند وضعیت نهایی یکپارچهسازی بات تلگرام/بله (procurement AI agent) با پروژهی ERP را
|
||||||
|
> توضیح میدهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
|
||||||
|
> نکات امنیتی و نحوهی استفاده. تاریخ آخرین بهروزرسانی: برنچ `prod-v7`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. مشکل چی بود؟
|
||||||
|
|
||||||
|
سرویس `bot` در `docker-compose.yml` اینطور تعریف شده بود:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: ./bot # ← پوشهای که در گیت وجود نداشت!
|
||||||
|
volumes:
|
||||||
|
- ./bot/app:/srv/bot/app
|
||||||
|
```
|
||||||
|
|
||||||
|
اما:
|
||||||
|
|
||||||
|
1. **پوشهی `bot/` هیچوقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
|
||||||
|
2. کد ایجنت در پوشهای به نام `ai procurement/` (با فاصله در نام) زندگی میکرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمیکرد.
|
||||||
|
3. روی دستگاه reza.g یک **Windows Junction** (میانبر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره میکرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب میداد.
|
||||||
|
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
|
||||||
|
|
||||||
|
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست میخورد —
|
||||||
|
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
|
||||||
|
|
||||||
|
## ۲. راهحل اعمالشده (راهحل شماره ۲)
|
||||||
|
|
||||||
|
1. Junction ویندوزی `bot` حذف شد (فقط میانبر؛ فایلی از دست نرفت).
|
||||||
|
2. پوشهی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
|
||||||
|
**تاریخچهی گیت هر ۴۱ فایل بهصورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
|
||||||
|
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشهی بات دیگر ignore نشود.
|
||||||
|
(`voice/` سر جایش ماند — نتهای صوتی رuntimes هستند و نباید کامیت شوند.)
|
||||||
|
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار میکند:
|
||||||
|
`context: ./bot` و `./bot/app` دقیقاً به فایلهای کامیتشده اشاره میکنند.
|
||||||
|
5. همهچیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایلها دسترسی داشته باشد.
|
||||||
|
|
||||||
|
### قواعد ignore فعلی (عمدی)
|
||||||
|
|
||||||
|
| مسیر | وضعیت | دلیل |
|
||||||
|
|---|---|---|
|
||||||
|
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکنها — هرگز کامیت نمیشوند |
|
||||||
|
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
|
||||||
|
| `voice/` | ignore (ریشه) | فایلهای صوتی دریافتی از بله در زمان اجرا |
|
||||||
|
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیتشده** | بکاپ تاریخچهی اصلی ایجنت (~۲۰۰KB) |
|
||||||
|
|
||||||
|
## ۳. وضعیت فعلی گیت (تاریخ این سند)
|
||||||
|
|
||||||
|
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` همSHA).
|
||||||
|
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **بهعلاوهی** `bot/`
|
||||||
|
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
|
||||||
|
(اندپوینتهای همگامسازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
|
||||||
|
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
|
||||||
|
تا «feat(bot,wizard): smart message filtering…».
|
||||||
|
- بقیهی برنچهای تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایلهای بات را ندارند** —
|
||||||
|
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
|
||||||
|
|
||||||
|
## ۴. ⚠️ نکتهی امنیتی (مهم — اقدام لازم)
|
||||||
|
|
||||||
|
در همان `docker-compose.yml`، مقادیر **واقعی** بهعنوان پیشفرض env کامیت و پوش شدهاند:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
|
||||||
|
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
|
||||||
|
```
|
||||||
|
|
||||||
|
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچهی مخزن مشترک است و **هر کسی که به ریپو
|
||||||
|
دسترسی دارد میتواند آنها را ببیند** (`.env` کامیت نشده، ولی این پیشفرضها عملاً همان لو رفتن راز است).
|
||||||
|
|
||||||
|
اقدامات توصیهشده (هنوز انجام نشده — نیاز به تصمیم مالک توکنها):
|
||||||
|
|
||||||
|
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
|
||||||
|
2. مقادیر پیشفرض را از compose حذف کنید و بهجای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
|
||||||
|
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
|
||||||
|
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
|
||||||
|
|
||||||
|
## ۵. نحوهی استفاده
|
||||||
|
|
||||||
|
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
|
||||||
|
cd ERP
|
||||||
|
git checkout prod-v7
|
||||||
|
|
||||||
|
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
|
||||||
|
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
|
||||||
|
# BALE_BOT_TOKEN=... توکن بات از بله
|
||||||
|
# AI_API_KEY=... کلید سرویس LLM
|
||||||
|
# BALE_ADMIN_CHAT_IDS=... چتآیدی مدیران
|
||||||
|
|
||||||
|
# ۲) کل استک + بات:
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
سرویس `bot` (کانتینر `procurement-bot`) بهصورت long-polling به `tapi.bale.ai` وصل میشود،
|
||||||
|
به `api:8000/api/v1` (همان ERP) و پایگاهدادهی `procurement` وصل است و
|
||||||
|
draft های گفتگو را در جدول `bot_drafts` مینویسد.
|
||||||
|
|
||||||
|
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose build bot
|
||||||
|
docker compose up -d bot
|
||||||
|
docker compose logs -f bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
|
||||||
|
|
||||||
|
1. کاربر در بله به بات پیام میدهد (متن یا وویس — وویس با STT به متن تبدیل میشود).
|
||||||
|
2. بات با NLU موجودی/تأمینکننده/کالا را استخراج میکند و یک **draft** میسازد
|
||||||
|
(وضعیت `active` در جدول `bot_drafts`) و کارت پیشنمایش با فیلدهای خالی میفرستد.
|
||||||
|
3. کاربر با دکمهی ✅ تأیید میکند؛ بات draft را به ERP کامیت میکند
|
||||||
|
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
|
||||||
|
4. ویزارد فرانتاند همان draft را از `GET /api/v1/drafts` میخواند و پیشپر میکند —
|
||||||
|
همگامسازی دوطرفه با ذخیرهی خودکار (debounced) بین مرورگر و بات.
|
||||||
|
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی میشود (reply-based edit، بدون کارت تکراری).
|
||||||
|
|
||||||
|
### ۵.۴ تست سریع سلامت
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose ps bot # باید Up باشد
|
||||||
|
docker compose logs bot | Select-Object -Last 20
|
||||||
|
# داخل کانتینر:
|
||||||
|
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵.۵ مسیرهای مهم
|
||||||
|
|
||||||
|
| مسیر | محتوا |
|
||||||
|
|---|---|
|
||||||
|
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
|
||||||
|
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
|
||||||
|
| `bot/docs/SETUP.md` | راهاندازی جزئیتر ایجنت |
|
||||||
|
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
|
||||||
|
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
|
||||||
|
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچهی مستقل قبلی ایجنت |
|
||||||
|
|
||||||
|
## ۶. نکات باقیمانده برای تیم
|
||||||
|
|
||||||
|
- **ادغام با برنچهای جدید تیم:** فایلهای بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
|
||||||
|
آنها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
|
||||||
|
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
|
||||||
|
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
|
||||||
|
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
|
||||||
|
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# procurement_ai_agent — Bale chatbot for the procurement ERP
|
||||||
|
|
||||||
|
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
|
||||||
|
into procurement actions in the
|
||||||
|
[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) —
|
||||||
|
draft-first: it captures what it understood, tells you **what it did** and
|
||||||
|
**what is still empty**, and **commits only after you approve** with a ✅ button.
|
||||||
|
|
||||||
|
```
|
||||||
|
you (Bale, text/voice) ┌─────────────────────────────────┐
|
||||||
|
│ getUpdates (long poll) │ procurement_ai_agent │
|
||||||
|
▼ │ ffmpeg → STT (Persian) │
|
||||||
|
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │
|
||||||
|
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
|
||||||
|
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│
|
||||||
|
└───────────────┬─────────────────┘
|
||||||
|
│ HTTP + X-Actor-Id
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ERP api (/api/v1) │
|
||||||
|
│ quotes · suppliers · materials│
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it can do
|
||||||
|
|
||||||
|
| Activity | Example (voice or text) | Committed via |
|
||||||
|
|---|---|---|
|
||||||
|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
|
||||||
|
| Add a supplier | «تأمینکننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
|
||||||
|
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
|
||||||
|
| Status queries | «استعلامهای در انتظار؟» / «کدامها بهزودی منقضی میشوند؟» | `GET /quotes?…` |
|
||||||
|
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
|
||||||
|
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
|
||||||
|
|
||||||
|
### The draft → approve loop
|
||||||
|
|
||||||
|
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM
|
||||||
|
extracts a typed **intent + entities** (JSON mode, Persian digits
|
||||||
|
normalized).
|
||||||
|
2. Names are fuzzy-matched against the ERP. Several candidates → inline
|
||||||
|
**pick buttons**; no match → listed as missing.
|
||||||
|
3. The bot replies with a **review card**: everything captured + a
|
||||||
|
✅/⚠️/❌ list of what is still empty.
|
||||||
|
4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and
|
||||||
|
re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
|
||||||
|
5. **Nothing is written to the ERP until you tap ✅.** After commit you get the
|
||||||
|
created quote id and the fields left empty.
|
||||||
|
|
||||||
|
### Team access & attribution
|
||||||
|
|
||||||
|
Each member links their Bale chat once: `/start` → Casdoor username → password
|
||||||
|
(validated via the ERP's real `POST /auth/login`, used once, never stored).
|
||||||
|
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
|
||||||
|
records the **human** who asked.
|
||||||
|
|
||||||
|
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink`
|
||||||
|
|
||||||
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY
|
||||||
|
docker build -t procurement-ai-agent .
|
||||||
|
docker run -d --name procurement-bot --env-file .env \
|
||||||
|
--add-host host.docker.internal:host-gateway \
|
||||||
|
procurement-ai-agent
|
||||||
|
docker logs -f procurement-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Or as part of the ERP stack — see [`integration/`](integration/README.md)
|
||||||
|
(compose service + the one tiny ERP-side patch).
|
||||||
|
|
||||||
|
Full setup, env-var reference, flows and troubleshooting:
|
||||||
|
**[docs/SETUP.md](docs/SETUP.md)**.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop
|
||||||
|
├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
|
||||||
|
├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files)
|
||||||
|
├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope)
|
||||||
|
├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions
|
||||||
|
├── nlu.py Persian intent+entity extraction (JSON-mode chat completions)
|
||||||
|
├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches
|
||||||
|
├── drafts.py draft store · QuoteCreate payload builder · missing-field logic
|
||||||
|
├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
|
||||||
|
├── texts.py Persian message templates
|
||||||
|
├── runtime.py context, link store, in-memory pending flows
|
||||||
|
├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema)
|
||||||
|
└── handlers/ dispatch · commands · callbacks · links ·
|
||||||
|
quote · supplier · material · queries · decisions
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
|
||||||
|
- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling —
|
||||||
|
no public port, no webhook, resumable offset persisted across restarts.
|
||||||
|
- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is
|
||||||
|
converted into the exact `POST /quotes` request body (temp_id
|
||||||
|
cross-references) only on approval; the API deliberately tolerates
|
||||||
|
incomplete quotes, so partial commits are explicit, never accidental.
|
||||||
|
- **Accept flow echoes the termsheet's existing `schedule_steps`** back to
|
||||||
|
`PATCH /quotes/{id}/accept` (the API replaces steps with what it receives).
|
||||||
|
- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding,
|
||||||
|
Arabic ya/ka folding, difflib ranking on top of the API's ilike search.
|
||||||
|
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before
|
||||||
|
STT. The LLM provider and the STT provider are configured independently
|
||||||
|
(e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler
|
||||||
|
pipeline with fake updates — no token, DB or API key needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python tools/smoke_test.py # → ALL PASSED: 48 passed
|
||||||
|
```
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Bale procurement chatbot — drafts ERP actions from chat text/voice,
|
||||||
|
reports what it captured + what is missing, and commits only on approval."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""ERP API client — the only way the bot touches procurement data."""
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Async client for the procurement API (/api/v1).
|
||||||
|
|
||||||
|
Attribution: every call carries ``X-Actor-Id`` (the linked ERP user id) so
|
||||||
|
``quote_events.actor`` records the human who asked, not the bot. A service
|
||||||
|
bearer token is sent when configured (procurement endpoints are currently
|
||||||
|
open — the slot is for the day auth lands).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.api")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
def __init__(self, status: int, code: str, message: str, field: str | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
|
||||||
|
class ErpClient:
|
||||||
|
def __init__(self, base_url: str, timeout: float = 30.0, service_token: str = ""):
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
self._service_token = service_token
|
||||||
|
|
||||||
|
def _actor_headers(self, actor_id: str | None, actor_name: str | None) -> dict:
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if self._service_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self._service_token}"
|
||||||
|
if actor_id:
|
||||||
|
headers["X-Actor-Id"] = actor_id
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def _request(self, method: str, path: str, *, actor_id: str | None = None,
|
||||||
|
json: Any = None, params: dict | None = None) -> Any:
|
||||||
|
url = f"{self._base}{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.request(
|
||||||
|
method, url, json=json, params=params, headers=self._actor_headers(actor_id, None)
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ApiError(0, "NETWORK", f"ERP API unreachable: {exc}") from exc
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return resp.json()
|
||||||
|
try:
|
||||||
|
err = resp.json().get("error") or {}
|
||||||
|
except ValueError:
|
||||||
|
err = {}
|
||||||
|
raise ApiError(
|
||||||
|
resp.status_code,
|
||||||
|
str(err.get("code") or "INTERNAL_ERROR"),
|
||||||
|
str(err.get("message") or f"HTTP {resp.status_code}"),
|
||||||
|
err.get("field"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ auth
|
||||||
|
|
||||||
|
async def login(self, username: str, password: str) -> dict:
|
||||||
|
body = await self._request("POST", "/auth/login", json={"username": username, "password": password})
|
||||||
|
return body.get("data") or {}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- lookup
|
||||||
|
|
||||||
|
async def list_suppliers(self, q: str | None = None, page_size: int = 20) -> list[dict]:
|
||||||
|
params: dict = {"page_size": page_size}
|
||||||
|
if q:
|
||||||
|
params["q"] = q
|
||||||
|
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
|
||||||
|
|
||||||
|
async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {}
|
||||||
|
|
||||||
|
async def list_materials(self, q: str | None = None, page_size: int = 20) -> list[dict]:
|
||||||
|
params: dict = {"page_size": page_size}
|
||||||
|
if q:
|
||||||
|
params["q"] = q
|
||||||
|
return (await self._request("GET", "/materials", params=params)).get("data") or []
|
||||||
|
|
||||||
|
async def create_material(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request("POST", "/materials", json=payload, actor_id=actor_id)).get("data") or []
|
||||||
|
|
||||||
|
async def list_units(self, q: str | None = None, page_size: int = 100) -> list[dict]:
|
||||||
|
params: dict = {"page_size": page_size}
|
||||||
|
if q:
|
||||||
|
params["q"] = q
|
||||||
|
return (await self._request("GET", "/units", params=params)).get("data") or []
|
||||||
|
|
||||||
|
async def list_projects(self, page_size: int = 100) -> list[dict]:
|
||||||
|
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
|
||||||
|
|
||||||
|
async def list_categories(self, page_size: int = 100) -> list[dict]:
|
||||||
|
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- quotes
|
||||||
|
|
||||||
|
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
|
||||||
|
expand: str | None = None, sort: str | None = None,
|
||||||
|
page: int = 1, page_size: int = 10) -> tuple[list[dict], dict]:
|
||||||
|
params: dict = {"page": page, "page_size": page_size}
|
||||||
|
if status:
|
||||||
|
params["status"] = status
|
||||||
|
if q:
|
||||||
|
params["q"] = q
|
||||||
|
if expand:
|
||||||
|
params["expand"] = expand
|
||||||
|
if sort:
|
||||||
|
params["sort"] = sort
|
||||||
|
body = await self._request("GET", "/quotes", params=params)
|
||||||
|
return body.get("data") or [], body.get("meta") or {}
|
||||||
|
|
||||||
|
async def get_quote(self, quote_id: str, expand: str | None = None, actor_id: str | None = None) -> dict:
|
||||||
|
params = {"expand": expand} if expand else None
|
||||||
|
return (await self._request("GET", f"/quotes/{quote_id}", params=params, actor_id=actor_id)).get("data") or {}
|
||||||
|
|
||||||
|
async def create_quote(self, payload: dict, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request("POST", "/quotes", json=payload, actor_id=actor_id)).get("data") or {}
|
||||||
|
|
||||||
|
async def accept_quote(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
|
||||||
|
path = f"/quotes/{quote_id}/accept"
|
||||||
|
return (await self._request("PATCH", path, json=payload, actor_id=actor_id)).get("data") or {}
|
||||||
|
|
||||||
|
async def arrive_quote(self, quote_id: str, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive", actor_id=actor_id)).get("data") or {}
|
||||||
|
|
||||||
|
async def arrive_items(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive-items", json=payload, actor_id=actor_id)).get("data") or {}
|
||||||
|
|
||||||
|
async def reject_quote(self, quote_id: str, reason: str | None = None, actor_id: str | None = None) -> dict:
|
||||||
|
return (await self._request(
|
||||||
|
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
|
||||||
|
)).get("data") or {}
|
||||||
|
|
||||||
|
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
|
||||||
|
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
|
||||||
|
url = f"{self._base}/quotes/{quote_id}/{kind}"
|
||||||
|
files = {"file": (file_name, content, mime_type)}
|
||||||
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to upload quote attachment to ERP: %s", e)
|
||||||
|
return {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai)."""
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Thin async client for the Bale Bot API.
|
||||||
|
|
||||||
|
Bale's bot surface is a Telegram Bot API clone: ``POST {base}/bot{token}/{method}``
|
||||||
|
and file downloads at ``{base}/file/bot{token}/{file_path}``. Long polling via
|
||||||
|
``getUpdates`` avoids any webhook/TLS setup on the server.
|
||||||
|
|
||||||
|
HTML parse_mode is requested first; if Bale rejects it for a given payload we
|
||||||
|
transparently retry as plain text so messages never get lost.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.bale")
|
||||||
|
|
||||||
|
REPLY_MARKUP = "reply_markup"
|
||||||
|
|
||||||
|
|
||||||
|
class BaleError(Exception):
|
||||||
|
def __init__(self, description: str, error_code: int | None = None):
|
||||||
|
super().__init__(description)
|
||||||
|
self.description = description
|
||||||
|
self.error_code = error_code
|
||||||
|
|
||||||
|
|
||||||
|
class BaleClient:
|
||||||
|
def __init__(self, base_url: str, token: str, timeout: float = 60.0):
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._token = token
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ core
|
||||||
|
|
||||||
|
def _method_url(self, method: str) -> str:
|
||||||
|
return f"{self._base}/bot{self._token}/{method}"
|
||||||
|
|
||||||
|
def _file_url(self, file_path: str) -> str:
|
||||||
|
return f"{self._base}/file/bot{self._token}/{file_path.lstrip('/')}"
|
||||||
|
|
||||||
|
async def _call(self, method: str, payload: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout or self._timeout) as client:
|
||||||
|
try:
|
||||||
|
resp = await client.post(self._method_url(method), json=payload or {})
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise BaleError(f"network error calling {method}: {exc}") from exc
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise BaleError(f"non-JSON response for {method} (HTTP {resp.status_code})") from exc
|
||||||
|
if not body.get("ok"):
|
||||||
|
raise BaleError(str(body.get("description") or "unknown bale error"), resp.status_code)
|
||||||
|
return body.get("result")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- receiving
|
||||||
|
|
||||||
|
async def get_updates(self, offset: int, timeout_seconds: int) -> list[dict]:
|
||||||
|
"""Long poll. ``timeout_seconds=0`` returns immediately (drain mode)."""
|
||||||
|
result = await self._call(
|
||||||
|
"getUpdates",
|
||||||
|
{"offset": offset, "timeout": timeout_seconds, "allowed_updates": ["message", "callback_query"]},
|
||||||
|
timeout=timeout_seconds + 15,
|
||||||
|
)
|
||||||
|
return result or []
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- sending
|
||||||
|
|
||||||
|
async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None,
|
||||||
|
reply_to: int | None = None) -> dict:
|
||||||
|
payload: dict = {"chat_id": chat_id, "text": text[:4000]}
|
||||||
|
if reply_markup:
|
||||||
|
payload[REPLY_MARKUP] = reply_markup
|
||||||
|
if reply_to:
|
||||||
|
payload["reply_to_message_id"] = reply_to
|
||||||
|
return await self._call("sendMessage", payload)
|
||||||
|
|
||||||
|
async def edit_message_text(self, chat_id: int, message_id: int, text: str,
|
||||||
|
reply_markup: dict | None = None) -> dict:
|
||||||
|
payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000]}
|
||||||
|
if reply_markup:
|
||||||
|
payload[REPLY_MARKUP] = reply_markup
|
||||||
|
return await self._call("editMessageText", payload)
|
||||||
|
|
||||||
|
async def delete_message(self, chat_id: int, message_id: int) -> bool:
|
||||||
|
payload: dict = {"chat_id": chat_id, "message_id": message_id}
|
||||||
|
try:
|
||||||
|
return bool(await self._call("deleteMessage", payload))
|
||||||
|
except BaleError as exc:
|
||||||
|
logger.warning("deleteMessage failed for chat %s msg %s: %s", chat_id, message_id, exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("unexpected error in deleteMessage for chat %s msg %s: %s", chat_id, message_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def answer_callback_query(self, callback_query_id: str, text: str | None = None) -> None:
|
||||||
|
payload: dict = {"callback_query_id": callback_query_id}
|
||||||
|
if text:
|
||||||
|
payload["text"] = text[:200]
|
||||||
|
try:
|
||||||
|
await self._call("answerCallbackQuery", payload)
|
||||||
|
except BaleError as exc:
|
||||||
|
logger.warning("answerCallbackQuery failed: %s", exc)
|
||||||
|
|
||||||
|
async def send_chat_action(self, chat_id: int, action: str = "typing") -> None:
|
||||||
|
try:
|
||||||
|
await self._call("sendChatAction", {"chat_id": chat_id, "action": action})
|
||||||
|
except BaleError as exc:
|
||||||
|
logger.debug("sendChatAction failed: %s", exc)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- files
|
||||||
|
|
||||||
|
async def get_file(self, file_id: str) -> dict:
|
||||||
|
return await self._call("getFile", {"file_id": file_id})
|
||||||
|
|
||||||
|
async def download_file(self, file_id: str) -> bytes:
|
||||||
|
"""Resolve a file_id to bytes (getFile → file_path → download URL)."""
|
||||||
|
info = await self.get_file(file_id)
|
||||||
|
file_path = info.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
raise BaleError("getFile returned no file_path")
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
resp = await client.get(self._file_url(file_path))
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_loop(client: BaleClient, on_update, offset_key: str = "bale_offset") -> None:
|
||||||
|
"""Long-poll supervisor: resumable offset, one update at a time, never dies.
|
||||||
|
|
||||||
|
``on_update`` must be an async callable receiving the raw update dict.
|
||||||
|
"""
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import BotMeta
|
||||||
|
|
||||||
|
async def load_offset() -> int:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
row = (await session.execute(
|
||||||
|
__import__("sqlalchemy").select(BotMeta.value).where(BotMeta.key == offset_key)
|
||||||
|
)).scalar()
|
||||||
|
return int(row) if row else 0
|
||||||
|
|
||||||
|
async def save_offset(value: int) -> None:
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
stmt = pg_insert(BotMeta).values(key=offset_key, value=str(value))
|
||||||
|
stmt = stmt.on_conflict_do_update(index_elements=[BotMeta.key], set_={"value": str(value)})
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
offset = await load_offset()
|
||||||
|
if offset == 0:
|
||||||
|
# Skip any backlog accumulated while the bot was down: peek once
|
||||||
|
# without waiting and jump straight to the newest update.
|
||||||
|
backlog = await client.get_updates(0, 0)
|
||||||
|
if backlog:
|
||||||
|
offset = max(u["update_id"] for u in backlog) + 1
|
||||||
|
|
||||||
|
logger.info("polling started (offset=%s)", offset)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
updates = await client.get_updates(offset, 25)
|
||||||
|
for update in updates:
|
||||||
|
offset = max(offset, update["update_id"] + 1)
|
||||||
|
await save_offset(offset)
|
||||||
|
try:
|
||||||
|
await on_update(update)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("handler crashed on update %s", update.get("update_id"))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except BaleError as exc:
|
||||||
|
logger.warning("poll error: %s", exc)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("unexpected poll error")
|
||||||
|
await asyncio.sleep(3)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Bot configuration.
|
||||||
|
|
||||||
|
All settings can be overridden with environment variables prefixed with
|
||||||
|
``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local
|
||||||
|
``.env`` file. Docker/compose passes them in directly (same convention as
|
||||||
|
the api service's ``PROCUREMENT_*`` prefix).
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="PROCUREMENT_BOT_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai.
|
||||||
|
BALE_API_BASE: str = "https://tapi.bale.ai"
|
||||||
|
BOT_TOKEN: str = "" # from Bale's BotFather
|
||||||
|
POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window
|
||||||
|
POLL_ERROR_SLEEP_SECONDS: float = 3.0
|
||||||
|
|
||||||
|
# ERP API (in-network service URL inside Docker).
|
||||||
|
API_BASE_URL: str = "http://localhost:8000/api/v1"
|
||||||
|
API_TIMEOUT_SECONDS: float = 30.0
|
||||||
|
# Optional bearer for the day procurement endpoints start enforcing auth.
|
||||||
|
SERVICE_TOKEN: str = ""
|
||||||
|
|
||||||
|
# Frontend URL (for opening Wizard from Bale bot)
|
||||||
|
FRONTEND_URL: str = "http://localhost:8081"
|
||||||
|
|
||||||
|
# Security token for standalone wizard review URL
|
||||||
|
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
DRAFT_TOKEN_EXPIRE_SECONDS: int = 7200 # 2 hours
|
||||||
|
|
||||||
|
# Persistent storage directory for draft attachments (proformas)
|
||||||
|
ATTACHMENTS_DIR: str = "data/attachments/proformas"
|
||||||
|
|
||||||
|
# OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter.
|
||||||
|
AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1
|
||||||
|
AI_API_KEY: str = ""
|
||||||
|
LLM_MODEL: str = "gpt-4o-mini"
|
||||||
|
LLM_TIMEOUT_SECONDS: float = 60.0
|
||||||
|
|
||||||
|
# STT providers — support multiple models/APIs in parallel.
|
||||||
|
# 1. JSON list: [{"name": "m1", "base_url": "...", "api_key": "...", "model": "..."}, ...]
|
||||||
|
STT_PROVIDERS_JSON: str = ""
|
||||||
|
|
||||||
|
# 2. Individual provider variables (up to 3 distinct STT models)
|
||||||
|
STT_PROVIDER_1_NAME: str = "model-1"
|
||||||
|
STT_PROVIDER_1_BASE_URL: str = ""
|
||||||
|
STT_PROVIDER_1_API_KEY: str = ""
|
||||||
|
STT_PROVIDER_1_MODEL: str = ""
|
||||||
|
STT_PROVIDER_1_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
STT_PROVIDER_2_NAME: str = "model-2"
|
||||||
|
STT_PROVIDER_2_BASE_URL: str = ""
|
||||||
|
STT_PROVIDER_2_API_KEY: str = ""
|
||||||
|
STT_PROVIDER_2_MODEL: str = ""
|
||||||
|
STT_PROVIDER_2_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
STT_PROVIDER_3_NAME: str = "model-3"
|
||||||
|
STT_PROVIDER_3_BASE_URL: str = ""
|
||||||
|
STT_PROVIDER_3_API_KEY: str = ""
|
||||||
|
STT_PROVIDER_3_MODEL: str = ""
|
||||||
|
STT_PROVIDER_3_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
# Legacy single STT provider fallback:
|
||||||
|
STT_BASE_URL: str = ""
|
||||||
|
STT_API_KEY: str = ""
|
||||||
|
STT_MODEL: str = "whisper-1"
|
||||||
|
STT_LANGUAGE: str = "fa"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stt_base_url(self) -> str:
|
||||||
|
return self.STT_BASE_URL or self.AI_BASE_URL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stt_api_key(self) -> str:
|
||||||
|
return self.STT_API_KEY or self.AI_API_KEY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stt_providers(self) -> list[dict]:
|
||||||
|
import json
|
||||||
|
if self.STT_PROVIDERS_JSON:
|
||||||
|
try:
|
||||||
|
data = json.loads(self.STT_PROVIDERS_JSON)
|
||||||
|
if isinstance(data, list) and len(data) > 0:
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check explicit STT_PROVIDER_1 / 2 / 3
|
||||||
|
custom_list: list[dict] = []
|
||||||
|
for idx in (1, 2, 3):
|
||||||
|
b_url = getattr(self, f"STT_PROVIDER_{idx}_BASE_URL")
|
||||||
|
key = getattr(self, f"STT_PROVIDER_{idx}_API_KEY")
|
||||||
|
model = getattr(self, f"STT_PROVIDER_{idx}_MODEL")
|
||||||
|
name = getattr(self, f"STT_PROVIDER_{idx}_NAME")
|
||||||
|
lang = getattr(self, f"STT_PROVIDER_{idx}_LANGUAGE") or "fa"
|
||||||
|
if b_url and key:
|
||||||
|
custom_list.append({
|
||||||
|
"name": name or f"model-{idx}",
|
||||||
|
"base_url": b_url,
|
||||||
|
"api_key": key,
|
||||||
|
"model": model or self.STT_MODEL,
|
||||||
|
"language": lang,
|
||||||
|
"timeout": 30.0,
|
||||||
|
})
|
||||||
|
if custom_list:
|
||||||
|
return custom_list
|
||||||
|
|
||||||
|
# Fallback to single legacy STT provider if set
|
||||||
|
if self.stt_base_url and self.stt_api_key:
|
||||||
|
return [{
|
||||||
|
"name": "default",
|
||||||
|
"base_url": self.stt_base_url,
|
||||||
|
"api_key": self.stt_api_key,
|
||||||
|
"model": self.STT_MODEL,
|
||||||
|
"language": self.STT_LANGUAGE,
|
||||||
|
"timeout": 30.0,
|
||||||
|
}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
# PostgreSQL — same instance/database as the api service.
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
|
||||||
|
|
||||||
|
# Chats that may use the bot without Casdoor linking (comma separated).
|
||||||
|
ADMIN_CHAT_IDS: str = ""
|
||||||
|
|
||||||
|
# Drafts older than this are marked expired by the sweeper loop.
|
||||||
|
DRAFT_TTL_HOURS: int = 24
|
||||||
|
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_chat_ids(self) -> set[int]:
|
||||||
|
out: set[int] = set()
|
||||||
|
for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part.isdigit():
|
||||||
|
out.add(int(part))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Async engine/session for the bot's own tables (same postgres as the api)."""
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py
|
||||||
|
convention — the ORM never creates tables; this script does, IF NOT EXISTS).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.db_init")
|
||||||
|
|
||||||
|
DDL = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_links (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
chat_id BIGINT UNIQUE NOT NULL,
|
||||||
|
user_id UUID,
|
||||||
|
casdoor_username TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
role VARCHAR(32),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_drafts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
chat_id BIGINT NOT NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
missing JSONB,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||||
|
source VARCHAR(16),
|
||||||
|
source_message_id BIGINT,
|
||||||
|
review_message_id BIGINT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)",
|
||||||
|
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ",
|
||||||
|
"ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS source_message_id BIGINT",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_review_msg ON bot_drafts (review_message_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_drafts_source_msg ON bot_drafts (source_message_id)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
chat_id BIGINT NOT NULL,
|
||||||
|
direction VARCHAR(4) NOT NULL,
|
||||||
|
kind VARCHAR(16) NOT NULL DEFAULT 'text',
|
||||||
|
body TEXT,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS bot_meta (
|
||||||
|
key VARCHAR(64) PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bot_tables(session: AsyncSession) -> None:
|
||||||
|
for stmt in DDL:
|
||||||
|
await session.execute(text(stmt))
|
||||||
|
await session.commit()
|
||||||
|
logger.info("bot tables ready")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_schema() -> None:
|
||||||
|
from app.db import SessionLocal
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await ensure_bot_tables(session)
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"""Draft store + payload/missing-field logic.
|
||||||
|
|
||||||
|
A draft's ``payload`` is a talking-friendly JSON the bot keeps refining. When
|
||||||
|
the user approves, ``build_quote_payload`` converts it into the exact
|
||||||
|
``POST /quotes`` request body (temp_ids cross-referencing line items,
|
||||||
|
termsheets and prices).
|
||||||
|
|
||||||
|
Resolution state lives inside the payload:
|
||||||
|
payload["supplier_id"], payload["project_id"],
|
||||||
|
item["material_id"], item["unit_id"], item["unit_explicit"],
|
||||||
|
payload["options"][field] = candidate list shown as pick-buttons.
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import cast, func, select, String, update
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import BotDraft
|
||||||
|
from app.nlu import normalize_text
|
||||||
|
|
||||||
|
KIND_QUOTE = "quote"
|
||||||
|
KIND_SUPPLIER = "supplier"
|
||||||
|
KIND_MATERIAL = "material"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Store
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create(chat_id: int, kind: str, payload: dict, source: str | None = None,
|
||||||
|
source_message_id: int | None = None) -> BotDraft:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
draft = BotDraft(
|
||||||
|
chat_id=chat_id,
|
||||||
|
kind=kind,
|
||||||
|
payload=payload,
|
||||||
|
source=source,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
)
|
||||||
|
session.add(draft)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(draft)
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
|
||||||
|
if kind:
|
||||||
|
stmt = stmt.where(BotDraft.kind == kind)
|
||||||
|
stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1)
|
||||||
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None:
|
||||||
|
"""Find the active draft associated with a given message_id (review card or user source message)."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(BotDraft)
|
||||||
|
.where(
|
||||||
|
BotDraft.chat_id == chat_id,
|
||||||
|
BotDraft.status == "active",
|
||||||
|
(BotDraft.review_message_id == message_id) | (BotDraft.source_message_id == message_id),
|
||||||
|
)
|
||||||
|
.order_by(BotDraft.updated_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None:
|
||||||
|
"""Resolve the short id used in callback_data (first 8 hex of the UUID)."""
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%"))
|
||||||
|
if chat_id is not None:
|
||||||
|
stmt = stmt.where(BotDraft.chat_id == chat_id)
|
||||||
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def save_payload(draft: BotDraft, missing: dict | None = None,
|
||||||
|
review_message_id: int | None = None,
|
||||||
|
source_message_id: int | None = None,
|
||||||
|
mark_bale_synced: bool = False) -> None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
effective_review_msg_id = review_message_id if review_message_id is not None else draft.review_message_id
|
||||||
|
values: dict = {
|
||||||
|
"payload": draft.payload,
|
||||||
|
"missing": missing,
|
||||||
|
"review_message_id": effective_review_msg_id,
|
||||||
|
}
|
||||||
|
if source_message_id is not None:
|
||||||
|
values["source_message_id"] = source_message_id
|
||||||
|
draft.source_message_id = source_message_id
|
||||||
|
if mark_bale_synced:
|
||||||
|
values["last_bale_update_at"] = func.now()
|
||||||
|
await session.execute(
|
||||||
|
update(BotDraft)
|
||||||
|
.where(BotDraft.id == draft.id)
|
||||||
|
.values(**values)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
draft.review_message_id = effective_review_msg_id
|
||||||
|
|
||||||
|
|
||||||
|
async def set_status(draft_id: str, status: str) -> None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_bale_updated_at(draft_id) -> None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
await session.execute(
|
||||||
|
update(BotDraft)
|
||||||
|
.where(BotDraft.id == draft_id)
|
||||||
|
.values(last_bale_update_at=func.now())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pending_bale_updates() -> list[BotDraft]:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(BotDraft)
|
||||||
|
.where(
|
||||||
|
BotDraft.review_message_id.is_not(None),
|
||||||
|
(BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at),
|
||||||
|
)
|
||||||
|
.order_by(BotDraft.updated_at.asc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def expire_stale(ttl_hours: int) -> int:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
update(BotDraft)
|
||||||
|
.where(BotDraft.status == "active", BotDraft.updated_at < cutoff)
|
||||||
|
.values(status="expired")
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Quote payload
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def new_quote_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"supplier_name": None, "supplier_id": None,
|
||||||
|
"project_name": None, "project_id": None, "project_names": [], "project_ids": [],
|
||||||
|
"is_official": True, "notes": None, "expiry_date": None,
|
||||||
|
"items": [], "termsheets": [],
|
||||||
|
"options": {}, "proforma_file": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def new_item(material_name: str | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"material_name": material_name, "material_id": None,
|
||||||
|
"quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False,
|
||||||
|
"unit_price": None, "brand_name": None, "specs": [], "notes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _similarity(a: str | None, b: str | None) -> float:
|
||||||
|
na, nb = normalize_text(a or ""), normalize_text(b or "")
|
||||||
|
if not na or not nb:
|
||||||
|
return 0.0
|
||||||
|
ratio = difflib.SequenceMatcher(None, na, nb).ratio()
|
||||||
|
if na in nb or nb in na:
|
||||||
|
ratio += 0.3
|
||||||
|
return ratio
|
||||||
|
|
||||||
|
|
||||||
|
def merge_quote_patch(payload: dict, patch: dict) -> bool:
|
||||||
|
"""Merge an NLU patch into the payload. Returns True if anything changed."""
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
def set_field(key: str, value) -> None:
|
||||||
|
nonlocal changed
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
if payload.get(key) != value:
|
||||||
|
payload[key] = value
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if patch.get("supplier_name"):
|
||||||
|
payload["supplier_name"] = str(patch["supplier_name"]).strip()
|
||||||
|
payload["supplier_id"] = None # needs re-resolution
|
||||||
|
changed = True
|
||||||
|
if patch.get("project_names"):
|
||||||
|
payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p]
|
||||||
|
if payload["project_names"] and not payload.get("project_name"):
|
||||||
|
payload["project_name"] = payload["project_names"][0]
|
||||||
|
payload["project_id"] = None
|
||||||
|
changed = True
|
||||||
|
elif patch.get("project_name"):
|
||||||
|
payload["project_name"] = str(patch["project_name"]).strip()
|
||||||
|
payload["project_id"] = None
|
||||||
|
if not payload.get("project_names"):
|
||||||
|
payload["project_names"] = [payload["project_name"]]
|
||||||
|
changed = True
|
||||||
|
set_field("is_official", patch.get("is_official"))
|
||||||
|
if patch.get("notes"):
|
||||||
|
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
|
||||||
|
changed = True
|
||||||
|
if patch.get("expiry_date"):
|
||||||
|
set_field("expiry_date", patch["expiry_date"])
|
||||||
|
|
||||||
|
for raw in patch.get("items") or []:
|
||||||
|
name = (raw.get("material_name") or "").strip() or None
|
||||||
|
target = None
|
||||||
|
if name:
|
||||||
|
best, best_ratio = None, 0.0
|
||||||
|
for item in payload["items"]:
|
||||||
|
# If both items have distinct specs, they are different line items
|
||||||
|
if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]):
|
||||||
|
continue
|
||||||
|
# If both items have distinct brands, they are different line items
|
||||||
|
if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]):
|
||||||
|
continue
|
||||||
|
ratio = _similarity(name, item.get("material_name"))
|
||||||
|
if ratio > best_ratio:
|
||||||
|
best, best_ratio = item, ratio
|
||||||
|
if best is not None and best_ratio >= 0.55:
|
||||||
|
target = best
|
||||||
|
if target is None:
|
||||||
|
target = new_item(name)
|
||||||
|
payload["items"].append(target)
|
||||||
|
changed = True
|
||||||
|
for key in ("quantity", "unit_price"):
|
||||||
|
if raw.get(key) is not None:
|
||||||
|
try:
|
||||||
|
value = float(raw[key])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if target.get(key) != value:
|
||||||
|
target[key] = value
|
||||||
|
changed = True
|
||||||
|
if raw.get("specs"):
|
||||||
|
target["specs"] = [str(s).strip() for s in raw["specs"] if s]
|
||||||
|
spec_str = "، ".join(target["specs"])
|
||||||
|
if spec_str and not target.get("notes"):
|
||||||
|
target["notes"] = spec_str
|
||||||
|
changed = True
|
||||||
|
for key in ("unit_name", "brand_name", "notes"):
|
||||||
|
if raw.get(key):
|
||||||
|
val = str(raw[key]).strip()
|
||||||
|
if target.get(key) != val:
|
||||||
|
target[key] = val
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
for raw in patch.get("payment_terms") or []:
|
||||||
|
label = (raw.get("label") or "شرایط پرداخت").strip()
|
||||||
|
milestones = []
|
||||||
|
for m in raw.get("milestones") or []:
|
||||||
|
if m.get("due_date") or m.get("amount"):
|
||||||
|
milestones.append({
|
||||||
|
"due_date": m.get("due_date"),
|
||||||
|
"amount": float(m.get("amount") or 0),
|
||||||
|
"note": m.get("note"),
|
||||||
|
})
|
||||||
|
entry = {
|
||||||
|
"label": label,
|
||||||
|
"discount_type": raw.get("discount_type") or "none",
|
||||||
|
"discount_value": float(raw.get("discount_value") or 0),
|
||||||
|
"milestones": milestones,
|
||||||
|
"description": (raw.get("description") or "").strip() or None,
|
||||||
|
}
|
||||||
|
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
|
||||||
|
payload["termsheets"].append(entry)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def compute_quote_missing(payload: dict) -> list[dict]:
|
||||||
|
"""What is still empty — strictly aligned with DB schema requirements."""
|
||||||
|
out: list[dict] = []
|
||||||
|
|
||||||
|
def add(key: str, label: str, ok: bool, blocking: bool) -> None:
|
||||||
|
out.append({"key": key, "label": label, "ok": ok, "blocking": blocking})
|
||||||
|
|
||||||
|
# In DB: supplier_id is NOT NULL in quotes table.
|
||||||
|
sup_ok = bool(payload.get("supplier_id") or payload.get("supplier_name"))
|
||||||
|
add("supplier", "تأمینکننده", sup_ok, True)
|
||||||
|
|
||||||
|
# In DB: project_id is nullable (optional)
|
||||||
|
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)
|
||||||
|
|
||||||
|
items = payload.get("items") or []
|
||||||
|
add("items", "قلم (کالا/مقدار)", bool(items), True)
|
||||||
|
for i, item in enumerate(items, start=1):
|
||||||
|
name = item.get("material_name") or f"قلم {i}"
|
||||||
|
has_mat = bool(item.get("material_id") or item.get("material_name"))
|
||||||
|
if not has_mat:
|
||||||
|
add(f"item:{i}:material", f"نام کالا برای قلم {i}", False, True)
|
||||||
|
|
||||||
|
if not item.get("quantity"):
|
||||||
|
add(f"item:{i}:qty", f"مقدار «{name}»", False, True)
|
||||||
|
if not item.get("unit_id") and not item.get("unit_name"):
|
||||||
|
add(f"item:{i}:unit", f"واحد کالا برای «{name}»", False, True)
|
||||||
|
if not item.get("unit_price"):
|
||||||
|
add(f"item:{i}:price", f"قیمت واحد برای «{name}»", False, False)
|
||||||
|
|
||||||
|
if items and not payload.get("termsheets"):
|
||||||
|
add("terms", "شرایط پرداخت", False, False)
|
||||||
|
if not payload.get("notes"):
|
||||||
|
add("notes", "توضیحات", False, False)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def quote_ready(payload: dict) -> tuple[bool, list[str]]:
|
||||||
|
"""Blocking check before commit; returns (ok, blocking_labels)."""
|
||||||
|
missing = compute_quote_missing(payload)
|
||||||
|
return all(not (m["blocking"] and not m["ok"]) for m in missing), [
|
||||||
|
m["label"] for m in missing if m["blocking"] and not m["ok"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]:
|
||||||
|
"""Convert the payload into a QuoteCreate body. Returns (body, problems)."""
|
||||||
|
problems: list[str] = []
|
||||||
|
if not payload.get("supplier_id"):
|
||||||
|
return None, ["تأمینکننده مشخص نیست"]
|
||||||
|
|
||||||
|
items = payload.get("items") or []
|
||||||
|
line_items: list[dict] = []
|
||||||
|
for i, item in enumerate(items, start=1):
|
||||||
|
if not item.get("material_id"):
|
||||||
|
if only_resolved_items:
|
||||||
|
continue
|
||||||
|
problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد")
|
||||||
|
continue
|
||||||
|
if not item.get("quantity"):
|
||||||
|
problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
|
||||||
|
continue
|
||||||
|
unit_id = item.get("unit_id")
|
||||||
|
if not unit_id:
|
||||||
|
problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست")
|
||||||
|
continue
|
||||||
|
notes_parts = []
|
||||||
|
if item.get("brand_name"):
|
||||||
|
notes_parts.append(f"برند: {item['brand_name']}")
|
||||||
|
if item.get("specs"):
|
||||||
|
notes_parts.append("، ".join(item["specs"]))
|
||||||
|
if item.get("notes"):
|
||||||
|
if item["notes"] not in notes_parts:
|
||||||
|
notes_parts.append(item["notes"])
|
||||||
|
notes_str = " | ".join(notes_parts) if notes_parts else None
|
||||||
|
|
||||||
|
line_items.append({
|
||||||
|
"temp_id": f"t{i}",
|
||||||
|
"material_id": item["material_id"],
|
||||||
|
"unit_id": int(unit_id),
|
||||||
|
"quantity": item["quantity"],
|
||||||
|
"notes": notes_str,
|
||||||
|
})
|
||||||
|
|
||||||
|
termsheets: list[dict] = []
|
||||||
|
item_termsheets: list[dict] = []
|
||||||
|
priced = [it for it in items if it.get("unit_price") and it.get("material_id")]
|
||||||
|
if priced:
|
||||||
|
raw_sheets = payload.get("termsheets") or []
|
||||||
|
if not raw_sheets:
|
||||||
|
raw_sheets = [{"label": "پیشنهاد قیمت"}]
|
||||||
|
for j, ts in enumerate(raw_sheets, start=1):
|
||||||
|
steps = []
|
||||||
|
for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1):
|
||||||
|
steps.append({
|
||||||
|
"step_no": s_idx,
|
||||||
|
"due_date": m.get("due_date"),
|
||||||
|
"amount": float(m.get("amount") or 0),
|
||||||
|
"note": m.get("note"),
|
||||||
|
})
|
||||||
|
termsheets.append({
|
||||||
|
"temp_id": f"p{j}",
|
||||||
|
"label": ts.get("label") or f"شرایط {j}",
|
||||||
|
"discount_type": ts.get("discount_type") or "none",
|
||||||
|
"discount_value": float(ts.get("discount_value") or 0),
|
||||||
|
"schedule_steps": steps,
|
||||||
|
})
|
||||||
|
first = termsheets[0]["temp_id"]
|
||||||
|
for i, item in enumerate(items, start=1):
|
||||||
|
if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"):
|
||||||
|
item_termsheets.append({
|
||||||
|
"line_item_temp_id": f"t{i}",
|
||||||
|
"termsheet_temp_id": first,
|
||||||
|
"unit_price": item["unit_price"],
|
||||||
|
})
|
||||||
|
|
||||||
|
body: dict = {
|
||||||
|
"supplier_id": payload["supplier_id"],
|
||||||
|
"is_official": bool(payload.get("is_official", True)),
|
||||||
|
"line_items": line_items,
|
||||||
|
"termsheets": termsheets,
|
||||||
|
"item_termsheets": item_termsheets,
|
||||||
|
}
|
||||||
|
if payload.get("project_ids"):
|
||||||
|
body["project_ids"] = payload["project_ids"]
|
||||||
|
elif payload.get("project_id"):
|
||||||
|
body["project_ids"] = [payload["project_id"]]
|
||||||
|
if payload.get("expiry_date"):
|
||||||
|
body["expiry_date"] = payload["expiry_date"]
|
||||||
|
if payload.get("notes"):
|
||||||
|
body["notes"] = payload["notes"]
|
||||||
|
if not line_items and problems:
|
||||||
|
return None, problems
|
||||||
|
return body, problems
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Supplier / material payloads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def new_supplier_payload() -> dict:
|
||||||
|
return {"name": None, "category_id": None, "category_name": None,
|
||||||
|
"phone": None, "location": None, "contact_person": None, "options": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_supplier_patch(payload: dict, patch: dict) -> bool:
|
||||||
|
src = patch.get("supplier") or patch
|
||||||
|
changed = False
|
||||||
|
for key in ("name", "phone", "location", "contact_person"):
|
||||||
|
if src.get(key) and payload.get(key) != str(src[key]).strip():
|
||||||
|
payload[key] = str(src[key]).strip()
|
||||||
|
changed = True
|
||||||
|
if src.get("category_name"):
|
||||||
|
payload["category_name"] = str(src["category_name"]).strip()
|
||||||
|
payload["category_id"] = None
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def supplier_missing(payload: dict) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"key": "name", "label": "نام تأمینکننده", "ok": bool(payload.get("name")), "blocking": True},
|
||||||
|
{"key": "category", "label": "دستهبندی", "ok": bool(payload.get("category_id")), "blocking": True},
|
||||||
|
{"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False},
|
||||||
|
{"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def new_material_payload() -> dict:
|
||||||
|
return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_material_patch(payload: dict, patch: dict) -> bool:
|
||||||
|
src = patch.get("material") or patch
|
||||||
|
changed = False
|
||||||
|
if src.get("name") and payload.get("name") != str(src["name"]).strip():
|
||||||
|
payload["name"] = str(src["name"]).strip()
|
||||||
|
changed = True
|
||||||
|
if src.get("unit_name"):
|
||||||
|
payload["unit_name"] = str(src["unit_name"]).strip()
|
||||||
|
payload["canonical_unit_id"] = None
|
||||||
|
changed = True
|
||||||
|
if src.get("topic") and payload.get("topic") != str(src["topic"]).strip():
|
||||||
|
payload["topic"] = str(src["topic"]).strip()
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def material_missing(payload: dict) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True},
|
||||||
|
{"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True},
|
||||||
|
{"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def short_id() -> str:
|
||||||
|
return uuid.uuid4().hex[:8]
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""Update dispatch — the heart of the bot.
|
||||||
|
|
||||||
|
Pipeline per update:
|
||||||
|
callback_query → callbacks router (link-gated)
|
||||||
|
message text starting "/" → command router
|
||||||
|
voice/audio → download → STT → treated as text (source=voice)
|
||||||
|
text → pending-flow interception (linking / accept-arrive confirmations)
|
||||||
|
→ auth gate (link or admin)
|
||||||
|
→ NLU → intent router
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.config import settings
|
||||||
|
from app.nlu import understand
|
||||||
|
from app.runtime import BotContext, LinkedUser
|
||||||
|
from app.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, queries, decisions
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.handlers")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_update(ctx: BotContext, update: dict) -> None:
|
||||||
|
if update.get("callback_query"):
|
||||||
|
await callbacks.handle_callback(ctx, update["callback_query"])
|
||||||
|
return
|
||||||
|
message = update.get("message") or update.get("edited_message")
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
chat_id = message["chat"]["id"]
|
||||||
|
try:
|
||||||
|
await handle_message(ctx, chat_id, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("message handling failed for chat %s", chat_id)
|
||||||
|
await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||||
|
text = (message.get("text") or message.get("caption") or "").strip()
|
||||||
|
source = "text"
|
||||||
|
voice_transcripts: list[dict] | None = None
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
|
||||||
|
if text.startswith("/"):
|
||||||
|
await commands.handle_command(ctx, chat_id, text)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pending multi-step interactions take the message first.
|
||||||
|
flow = ctx.pending.get(chat_id)
|
||||||
|
if flow:
|
||||||
|
handled = await _route_to_flow(ctx, chat_id, flow, text)
|
||||||
|
if handled:
|
||||||
|
return
|
||||||
|
ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through
|
||||||
|
|
||||||
|
user = await _gate(ctx, chat_id, message)
|
||||||
|
if user is None:
|
||||||
|
return # gate either started linking or is mid-linking
|
||||||
|
|
||||||
|
reply_to = message.get("reply_to_message")
|
||||||
|
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
|
||||||
|
|
||||||
|
def _is_audio(msg: dict) -> bool:
|
||||||
|
if msg.get("voice") or msg.get("audio"):
|
||||||
|
return True
|
||||||
|
doc = msg.get("document")
|
||||||
|
if doc and (doc.get("mime_type") or "").lower().startswith("audio/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _extract_voice() -> list[dict] | None:
|
||||||
|
nonlocal source
|
||||||
|
source = "voice"
|
||||||
|
await ctx.bale.send_chat_action(chat_id, "typing")
|
||||||
|
audio_media = message.get("voice") or message.get("audio") or message.get("document")
|
||||||
|
file_id = audio_media["file_id"]
|
||||||
|
try:
|
||||||
|
audio_bytes = await ctx.bale.download_file(file_id)
|
||||||
|
transcripts = await transcribe_multi(
|
||||||
|
settings,
|
||||||
|
audio_bytes,
|
||||||
|
mime_type=audio_media.get("mime_type"),
|
||||||
|
filename=audio_media.get("file_name"),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
|
||||||
|
await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail)), reply_to=message_id)
|
||||||
|
return None
|
||||||
|
if not transcripts:
|
||||||
|
await ctx.send(chat_id, VOICE_EMPTY, reply_to=message_id)
|
||||||
|
return None
|
||||||
|
return transcripts
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
|
||||||
|
# =========================================================================
|
||||||
|
if reply_to_msg_id is not None:
|
||||||
|
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
|
||||||
|
if target_draft is None:
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
"⚠️ پیشنویس فعال مربوط به این پیام یافت نشد (ممکن است ثبت نهایی، منقضی یا لغو شده باشد).\nبرای ثبت استعلام جدید، پیام خود را بدون ریپلای ارسال فرمایید.",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for proforma attachment (document or photo)
|
||||||
|
proforma_attached = False
|
||||||
|
saved_meta = None
|
||||||
|
doc = message.get("document")
|
||||||
|
photos = message.get("photo")
|
||||||
|
|
||||||
|
if doc and not (doc.get("mime_type") or "").lower().startswith("audio/"):
|
||||||
|
doc_file_id = doc["file_id"]
|
||||||
|
doc_file_name = doc.get("file_name") or f"proforma_{message_id}.pdf"
|
||||||
|
doc_mime = doc.get("mime_type") or "application/pdf"
|
||||||
|
elif photos:
|
||||||
|
largest = photos[-1] if isinstance(photos, list) else photos
|
||||||
|
doc_file_id = largest["file_id"]
|
||||||
|
doc_file_name = f"proforma_{message_id}.jpg"
|
||||||
|
doc_mime = "image/jpeg"
|
||||||
|
else:
|
||||||
|
doc_file_id = None
|
||||||
|
doc_file_name = None
|
||||||
|
doc_mime = None
|
||||||
|
|
||||||
|
if doc_file_id:
|
||||||
|
await ctx.bale.send_chat_action(chat_id, "upload_document")
|
||||||
|
try:
|
||||||
|
content = await ctx.bale.download_file(doc_file_id)
|
||||||
|
saved_meta = await save_proforma_file(
|
||||||
|
target_draft.id,
|
||||||
|
doc_file_name,
|
||||||
|
content,
|
||||||
|
mime_type=doc_mime,
|
||||||
|
storage_dir=settings.ATTACHMENTS_DIR,
|
||||||
|
)
|
||||||
|
payload = dict(target_draft.payload or {})
|
||||||
|
payload["proforma_file"] = saved_meta["file_name"]
|
||||||
|
payload["proforma"] = saved_meta
|
||||||
|
target_draft.payload = payload
|
||||||
|
await draft_store.save_payload(target_draft, mark_bale_synced=True)
|
||||||
|
proforma_attached = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to persist proforma")
|
||||||
|
await ctx.send(chat_id, f"❌ خطا در ذخیره فایل پیشفاکتور: {esc(str(exc)[:100])}", reply_to=message_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_audio(message):
|
||||||
|
voice_transcripts = await _extract_voice()
|
||||||
|
if not voice_transcripts:
|
||||||
|
return
|
||||||
|
text = voice_transcripts[0]["text"]
|
||||||
|
|
||||||
|
if proforma_attached and not text and not voice_transcripts:
|
||||||
|
await quote.render(ctx, chat_id, target_draft, fresh=False)
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
f"📎 پیشفاکتور «{saved_meta['file_name']}» با موفقیت ذخیره و به استعلام ضمیمه شد ✅",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not proforma_attached and not text and not voice_transcripts:
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
"⚠️ برای ویرایش پیشنویس، لطفاً تغییرات را به صورت متن یا پیام صوتی (ویس) ارسال کنید، یا فایل پیشفاکتور را بفرستید.",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BRANCH 2: NEW QUOTE MODE — Messages without reply
|
||||||
|
# =========================================================================
|
||||||
|
else:
|
||||||
|
target_draft = None
|
||||||
|
|
||||||
|
if _is_audio(message):
|
||||||
|
voice_transcripts = await _extract_voice()
|
||||||
|
if not voice_transcripts:
|
||||||
|
return
|
||||||
|
text = voice_transcripts[0]["text"]
|
||||||
|
elif text:
|
||||||
|
source = "text"
|
||||||
|
else:
|
||||||
|
await ctx.send(
|
||||||
|
chat_id,
|
||||||
|
"⚠️ برای ثبت استعلام خرید جدید، لطفاً اقلام و مشخصات را به صورت پیام متنی یا پیام صوتی (ویس) ارسال فرمایید.",
|
||||||
|
reply_to=message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
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)
|
||||||
|
intent, patch = result.get("intent"), result.get("patch") or {}
|
||||||
|
consensus_text = result.get("consensus_text") or text
|
||||||
|
|
||||||
|
if source == "voice":
|
||||||
|
await ctx._log(chat_id, "in", "voice", consensus_text)
|
||||||
|
|
||||||
|
logger.info("chat %s intent=%s conf=%s consensus=%r (is_edit=%s)",
|
||||||
|
chat_id, intent, result.get("confidence"), consensus_text[:40], target_draft is not None)
|
||||||
|
|
||||||
|
if intent in ("register_quote", "update_draft"):
|
||||||
|
await quote.handle_entities(
|
||||||
|
ctx, chat_id, user, patch, source,
|
||||||
|
incoming_msg_id=message_id,
|
||||||
|
target_draft=target_draft,
|
||||||
|
)
|
||||||
|
elif intent == "commit_draft":
|
||||||
|
active = target_draft or await draft_store.get_active(chat_id)
|
||||||
|
if active is None:
|
||||||
|
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
|
||||||
|
elif active.kind == draft_store.KIND_QUOTE:
|
||||||
|
await quote.commit(ctx, chat_id, user, active)
|
||||||
|
elif active.kind == draft_store.KIND_SUPPLIER:
|
||||||
|
await supplier.commit(ctx, chat_id, user, active)
|
||||||
|
elif active.kind == draft_store.KIND_MATERIAL:
|
||||||
|
await material.commit(ctx, chat_id, user, active)
|
||||||
|
elif intent == "discard_draft":
|
||||||
|
active = target_draft or await draft_store.get_active(chat_id)
|
||||||
|
if active:
|
||||||
|
await draft_store.set_status(active.id, "cancelled")
|
||||||
|
if active.review_message_id:
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, active.review_message_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ctx.pending.pop(chat_id, None)
|
||||||
|
await ctx.send(chat_id, CANCELLED, reply_to=message_id)
|
||||||
|
elif intent == "show_draft":
|
||||||
|
active = target_draft or await draft_store.get_active(chat_id)
|
||||||
|
if active is None:
|
||||||
|
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
|
||||||
|
elif active.kind == draft_store.KIND_QUOTE:
|
||||||
|
await quote.render(ctx, chat_id, active, fresh=False)
|
||||||
|
elif active.kind == draft_store.KIND_SUPPLIER:
|
||||||
|
await supplier.render(ctx, chat_id, active)
|
||||||
|
elif active.kind == draft_store.KIND_MATERIAL:
|
||||||
|
await material.render(ctx, chat_id, active)
|
||||||
|
elif intent == "add_supplier":
|
||||||
|
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||||
|
elif intent == "add_material":
|
||||||
|
await material.handle_entities(ctx, chat_id, user, patch, source)
|
||||||
|
elif intent == "status_query":
|
||||||
|
await queries.handle_query(ctx, chat_id, user, patch)
|
||||||
|
elif intent == "accept_quote":
|
||||||
|
await decisions.start_accept(ctx, chat_id, user, patch)
|
||||||
|
elif intent == "arrive_items":
|
||||||
|
await decisions.start_arrive(ctx, chat_id, user, patch)
|
||||||
|
else:
|
||||||
|
await ctx.send(chat_id, UNKNOWN_HINT)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None:
|
||||||
|
"""Link or admin gate. Returns None when the message was consumed by linking."""
|
||||||
|
from . import links
|
||||||
|
|
||||||
|
link = await ctx.links.get(chat_id)
|
||||||
|
if link and link.user_id:
|
||||||
|
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
||||||
|
if chat_id in ctx.admin_chat_ids:
|
||||||
|
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
||||||
|
|
||||||
|
flow = ctx.pending.get(chat_id)
|
||||||
|
if flow and flow.kind == "link_password":
|
||||||
|
await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "")
|
||||||
|
return None
|
||||||
|
if flow and flow.kind == "link_username":
|
||||||
|
await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "")
|
||||||
|
return None
|
||||||
|
await ctx.send(chat_id, LINK_START)
|
||||||
|
ctx.pending.set(chat_id, "link_username", {})
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool:
|
||||||
|
from . import links, decisions
|
||||||
|
|
||||||
|
if flow.kind == "link_username":
|
||||||
|
await links.finish_username_step(ctx, chat_id, flow, text)
|
||||||
|
return True
|
||||||
|
if flow.kind == "link_password":
|
||||||
|
await links.finish_link(ctx, chat_id, flow, text=text, password=text)
|
||||||
|
return True
|
||||||
|
if flow.kind == "quote_pick":
|
||||||
|
await decisions.handle_quote_pick_text(ctx, chat_id, flow, text)
|
||||||
|
return True
|
||||||
|
if flow.kind in ("accept", "arrive_qty", "arrive_confirm"):
|
||||||
|
return await decisions.handle_flow_text(ctx, chat_id, flow, text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_summary(draft) -> str | None:
|
||||||
|
if draft is None:
|
||||||
|
return None
|
||||||
|
import json
|
||||||
|
|
||||||
|
payload = draft.payload or {}
|
||||||
|
if draft.kind == draft_store.KIND_QUOTE:
|
||||||
|
items = ", ".join(
|
||||||
|
f"{it.get('material_name') or '?'}"
|
||||||
|
+ (f" {it.get('quantity')}" if it.get("quantity") else "")
|
||||||
|
+ (f" {it.get('unit_name')}" if it.get("unit_name") else "")
|
||||||
|
for it in payload.get("items") or []
|
||||||
|
)
|
||||||
|
summary = (
|
||||||
|
f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; "
|
||||||
|
f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}"
|
||||||
|
)
|
||||||
|
elif draft.kind == draft_store.KIND_SUPPLIER:
|
||||||
|
summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}"
|
||||||
|
elif draft.kind == draft_store.KIND_MATERIAL:
|
||||||
|
summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}"
|
||||||
|
else:
|
||||||
|
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
||||||
|
return summary
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Inline-button routing (callback_query).
|
||||||
|
|
||||||
|
Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx}
|
||||||
|
/ ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.runtime import LinkedUser
|
||||||
|
from app.texts import CANCELLED, UNLINKED, esc
|
||||||
|
|
||||||
|
from . import quote, supplier, material, decisions
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.callbacks")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_callback(ctx, cq: dict) -> None:
|
||||||
|
chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id")
|
||||||
|
data = cq.get("data") or ""
|
||||||
|
if chat_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await _dispatch(ctx, chat_id, data, cq)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("callback failed: %s", data)
|
||||||
|
await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.")
|
||||||
|
finally:
|
||||||
|
await ctx.bale.answer_callback_query(cq.get("id"), None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _gate(ctx, chat_id: int) -> LinkedUser | None:
|
||||||
|
link = await ctx.links.get(chat_id)
|
||||||
|
if link and link.user_id:
|
||||||
|
return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False)
|
||||||
|
if chat_id in ctx.admin_chat_ids:
|
||||||
|
return LinkedUser(chat_id, None, None, None, is_admin=True)
|
||||||
|
await ctx.send(chat_id, UNLINKED)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||||
|
user = await _gate(ctx, chat_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
parts = data.split(":")
|
||||||
|
|
||||||
|
# ---- pending-flow buttons (accept / arrive / quote pick) ---------------
|
||||||
|
if parts[0] == "qc" and len(parts) == 2:
|
||||||
|
flow = ctx.pending.get(chat_id)
|
||||||
|
if flow and flow.kind == "quote_pick":
|
||||||
|
await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1]))
|
||||||
|
return
|
||||||
|
if parts[0] == "ac":
|
||||||
|
await decisions.handle_accept_callback(ctx, chat_id, user, parts)
|
||||||
|
return
|
||||||
|
if parts[0] == "ar":
|
||||||
|
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- pick a candidate for a draft field -------------------------------
|
||||||
|
if parts[0] == "pk" and len(parts) == 4:
|
||||||
|
draft = await draft_store.get_by_prefix(parts[1], chat_id)
|
||||||
|
if draft is None:
|
||||||
|
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
||||||
|
return
|
||||||
|
await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3]))
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- draft commit / cancel --------------------------------------------
|
||||||
|
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
|
||||||
|
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
|
||||||
|
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
|
||||||
|
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
||||||
|
if draft is None or draft.kind != kind:
|
||||||
|
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
||||||
|
return
|
||||||
|
if arg == "no":
|
||||||
|
await draft_store.set_status(draft.id, "cancelled")
|
||||||
|
ctx.pending.pop(chat_id, None)
|
||||||
|
if draft.review_message_id:
|
||||||
|
try:
|
||||||
|
await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیشنویس لغو شد.", reply_markup=None)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
return
|
||||||
|
if kind == draft_store.KIND_QUOTE:
|
||||||
|
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
|
||||||
|
elif kind == draft_store.KIND_SUPPLIER:
|
||||||
|
await supplier.commit(ctx, chat_id, user, draft)
|
||||||
|
else:
|
||||||
|
await material.commit(ctx, chat_id, user, draft)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
|
||||||
|
options = (draft.payload.get("options") or {}).get(field) or []
|
||||||
|
if idx < 0 or idx >= len(options):
|
||||||
|
await ctx.send(chat_id, "گزینه نامعتبر است.")
|
||||||
|
return
|
||||||
|
choice = options[idx]
|
||||||
|
if draft.kind == draft_store.KIND_QUOTE:
|
||||||
|
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
|
||||||
|
elif draft.kind == draft_store.KIND_SUPPLIER:
|
||||||
|
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
|
||||||
|
elif draft.kind == draft_store.KIND_MATERIAL:
|
||||||
|
await material.apply_pick(ctx, chat_id, user, draft, field, choice)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""/commands — /start /help /drafts /cancel /whoami /unlink."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.runtime import ROLE_FA
|
||||||
|
from app.texts import CANCELLED, DRAFTS_EMPTY, DRAFTS_LIST_HEADER, HELP, UNLINKED, WELCOME, esc
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.commands")
|
||||||
|
|
||||||
|
KIND_FA = {"quote": "استعلام", "supplier": "تأمینکننده", "material": "کالا"}
|
||||||
|
STATUS_FA = {"active": "باز", "committed": "ثبتشده", "cancelled": "لغوشده", "expired": "منقضی"}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_command(ctx, chat_id: int, text: str) -> None:
|
||||||
|
command = text.split()[0].split("@")[0].lower()
|
||||||
|
if command == "/start":
|
||||||
|
await _start(ctx, chat_id)
|
||||||
|
elif command in ("/help", "/راهنما"):
|
||||||
|
await ctx.send(chat_id, HELP)
|
||||||
|
elif command == "/drafts":
|
||||||
|
await _drafts(ctx, chat_id)
|
||||||
|
elif command in ("/cancel", "/لغو"):
|
||||||
|
await _cancel(ctx, chat_id)
|
||||||
|
elif command == "/whoami":
|
||||||
|
await _whoami(ctx, chat_id)
|
||||||
|
elif command == "/unlink":
|
||||||
|
await ctx.links.delete(chat_id)
|
||||||
|
await ctx.send(chat_id, "اتصال قطع شد. برای اتصال دوباره /start بزن.")
|
||||||
|
else:
|
||||||
|
await ctx.send(chat_id, HELP)
|
||||||
|
|
||||||
|
|
||||||
|
async def _start(ctx, chat_id: int) -> None:
|
||||||
|
await ctx.send(chat_id, WELCOME)
|
||||||
|
link = await ctx.links.get(chat_id)
|
||||||
|
if not link or not link.user_id:
|
||||||
|
if chat_id not in ctx.admin_chat_ids:
|
||||||
|
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
|
||||||
|
ctx.pending.set(chat_id, "link_username", {})
|
||||||
|
|
||||||
|
|
||||||
|
async def _drafts(ctx, chat_id: int) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import BotDraft
|
||||||
|
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(BotDraft)
|
||||||
|
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
|
||||||
|
.order_by(BotDraft.updated_at.desc())
|
||||||
|
)).scalars().all()
|
||||||
|
if not rows:
|
||||||
|
await ctx.send(chat_id, DRAFTS_EMPTY)
|
||||||
|
return
|
||||||
|
lines = [DRAFTS_LIST_HEADER]
|
||||||
|
for d in rows:
|
||||||
|
p = d.payload or {}
|
||||||
|
title = p.get("supplier_name") or p.get("name") or "بدون عنوان"
|
||||||
|
lines.append(f"• {KIND_FA.get(d.kind, d.kind)}: {esc(title)}")
|
||||||
|
await ctx.send(chat_id, "\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
async def _cancel(ctx, chat_id: int) -> None:
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
active = await draft_store.get_active(chat_id)
|
||||||
|
if active:
|
||||||
|
await draft_store.set_status(active.id, "cancelled")
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
|
||||||
|
|
||||||
|
async def _whoami(ctx, chat_id: int) -> None:
|
||||||
|
link = await ctx.links.get(chat_id)
|
||||||
|
if chat_id in ctx.admin_chat_ids:
|
||||||
|
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
|
||||||
|
return
|
||||||
|
if not link or not link.user_id:
|
||||||
|
await ctx.send(chat_id, UNLINKED)
|
||||||
|
return
|
||||||
|
role = ROLE_FA.get(link.role or "", link.role or "بدون نقش")
|
||||||
|
await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}")
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
"""Accept / arrive flows — always with an explicit confirmation step."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.keyboards import accept_keyboard, arrive_keyboard, quote_pick_keyboard
|
||||||
|
from app.nlu import normalize_digits
|
||||||
|
from app.texts import (
|
||||||
|
ACCEPT_DONE_TS, ACCEPT_REPORT, ARRIVE_REPORT, CANCELLED, ERR_GENERIC,
|
||||||
|
QUOTE_NOT_FOUND, STATUS_FA, esc,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.decisions")
|
||||||
|
|
||||||
|
EXPAND = "supplier,line_items,termsheets"
|
||||||
|
CONFIRM_WORDS = {"تایید", "تأیید", "ثبت", "ثبت کن", "اوکی", "اوکیه", "بله", "yes", "ok"}
|
||||||
|
CANCEL_WORDS = {"لغو", "بی خیال", "بیخیال", "انصراف", "نه", "cancel"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(text: str) -> str:
|
||||||
|
return (text or "").strip().lower().replace("\u200c", " ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shared: pick a quote by supplier text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def start_accept(ctx, chat_id, user, patch: dict) -> None:
|
||||||
|
await _start_for_status(ctx, chat_id, user, patch, mode="accept", status="offered")
|
||||||
|
|
||||||
|
|
||||||
|
async def start_arrive(ctx, chat_id, user, patch: dict) -> None:
|
||||||
|
await _start_for_status(ctx, chat_id, user, patch, mode="arrive", status="accepted")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_for_status(ctx, chat_id, user, patch: dict, mode: str, status: str) -> None:
|
||||||
|
target = (patch.get("target") or {})
|
||||||
|
q = target.get("supplier_name") or target.get("project_name") or target.get("quote_id")
|
||||||
|
try:
|
||||||
|
quotes, _ = await ctx.api.list_quotes(status=status, q=q, expand=EXPAND, page_size=10)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
if not quotes:
|
||||||
|
await ctx.send(chat_id, QUOTE_NOT_FOUND.format(q=esc(q or "?")))
|
||||||
|
return
|
||||||
|
if len(quotes) == 1:
|
||||||
|
if mode == "accept":
|
||||||
|
await begin_accept(ctx, chat_id, user, quotes[0])
|
||||||
|
else:
|
||||||
|
await begin_arrive(ctx, chat_id, user, quotes[0])
|
||||||
|
return
|
||||||
|
pairs = [(_quote_label(qq), str(qq["id"])) for qq in quotes]
|
||||||
|
flow = ctx.pending.set(chat_id, "quote_pick", {"mode": mode, "ids": [pid for _, pid in pairs]})
|
||||||
|
flow.data["user_id"] = user.actor_id
|
||||||
|
message = await ctx.send(chat_id, "چند مورد پیدا شد؛ کدام را میخواهی؟", reply_markup=quote_pick_keyboard(pairs))
|
||||||
|
flow.data["message_id"] = (message or {}).get("message_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_label(quote: dict) -> str:
|
||||||
|
sup = (quote.get("supplier") or {}).get("name") or "—"
|
||||||
|
return f"#{str(quote.get('id'))[:8]} {sup}"
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_quote_pick(ctx, chat_id, user, flow, idx: int) -> None:
|
||||||
|
ids = flow.data.get("ids") or []
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
if idx < 0 or idx >= len(ids):
|
||||||
|
await ctx.send(chat_id, "گزینه نامعتبر است.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
quote = await ctx.api.get_quote(ids[idx], expand=EXPAND, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
if flow.data.get("mode") == "arrive":
|
||||||
|
await begin_arrive(ctx, chat_id, user, quote)
|
||||||
|
else:
|
||||||
|
await begin_accept(ctx, chat_id, user, quote)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_quote_pick_text(ctx, chat_id, flow, text: str) -> None:
|
||||||
|
if _norm(text) in CANCEL_WORDS:
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
return
|
||||||
|
numbers = re.findall(r"\d+", normalize_digits(text))
|
||||||
|
if numbers:
|
||||||
|
await apply_quote_pick(ctx, chat_id, _flow_user(flow), flow, int(numbers[0]) - 1)
|
||||||
|
else:
|
||||||
|
await ctx.send(chat_id, "شماره مورد را بفرست یا از دکمهها استفاده کن.")
|
||||||
|
|
||||||
|
|
||||||
|
def _flow_user(flow):
|
||||||
|
from app.runtime import LinkedUser
|
||||||
|
return LinkedUser(chat_id=None, user_id=flow.data.get("user_id"), display_name=None,
|
||||||
|
role=None, is_admin=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# accept
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _accept_summary(quote: dict, chosen: int) -> str:
|
||||||
|
lines = [
|
||||||
|
f"✔️ پذیرش استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}",
|
||||||
|
"",
|
||||||
|
"اقلام:",
|
||||||
|
]
|
||||||
|
for li in quote.get("line_items") or []:
|
||||||
|
mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?"
|
||||||
|
unit = ((li.get("unit") or {}).get("name_fa")) or ""
|
||||||
|
price = li.get("unit_price")
|
||||||
|
parts = [f"• {esc(mat)} — مقدار {esc(li.get('quantity'))} {unit}"]
|
||||||
|
if price:
|
||||||
|
parts.append(f"قیمت {esc(price)}")
|
||||||
|
lines.append(" — ".join(parts))
|
||||||
|
lines.append("")
|
||||||
|
lines.append("شرایط پرداخت:")
|
||||||
|
for i, ts in enumerate(quote.get("termsheets") or []):
|
||||||
|
mark = "👉" if i == chosen else " "
|
||||||
|
discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else ""
|
||||||
|
lines.append(f"{mark} [{i + 1}] {esc(ts.get('label'))}{discount} — {len(ts.get('schedule_steps') or [])} مرحله")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("شرایط را انتخاب کن و پذیرش را تأیید کن.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def begin_accept(ctx, chat_id, user, quote: dict) -> None:
|
||||||
|
termsheets = quote.get("termsheets") or []
|
||||||
|
if not termsheets:
|
||||||
|
await ctx.send(chat_id, "این استعلام شرایط پرداخت ندارد؛ اول در برنامه ثبت کن.")
|
||||||
|
return
|
||||||
|
if not quote.get("line_items"):
|
||||||
|
await ctx.send(chat_id, "این استعلام قلم ندارد.")
|
||||||
|
return
|
||||||
|
flow = ctx.pending.set(chat_id, "accept", {
|
||||||
|
"quote_id": str(quote["id"]),
|
||||||
|
"quote_snapshot": {
|
||||||
|
"id": quote.get("id"),
|
||||||
|
"supplier": quote.get("supplier"),
|
||||||
|
"line_items": quote.get("line_items") or [],
|
||||||
|
},
|
||||||
|
"termsheets": termsheets,
|
||||||
|
"chosen": 0,
|
||||||
|
"user_id": user.actor_id,
|
||||||
|
})
|
||||||
|
text = _accept_summary(quote, 0)
|
||||||
|
message = await ctx.send(chat_id, text, reply_markup=accept_keyboard([ts.get("label") or "?" for ts in termsheets]))
|
||||||
|
flow.data["message_id"] = (message or {}).get("message_id")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_accept_callback(ctx, chat_id, user, parts) -> None:
|
||||||
|
flow = ctx.pending.get(chat_id)
|
||||||
|
if not flow or flow.kind != "accept":
|
||||||
|
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
|
||||||
|
return
|
||||||
|
op = parts[1] if len(parts) > 1 else ""
|
||||||
|
if op == "no":
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
return
|
||||||
|
if op == "ts" and len(parts) == 3:
|
||||||
|
idx = int(parts[2])
|
||||||
|
if 0 <= idx < len(flow.data["termsheets"]):
|
||||||
|
flow.data["chosen"] = idx
|
||||||
|
await ctx.edit(chat_id, flow.data.get("message_id"),
|
||||||
|
_accept_summary(flow.data["quote_snapshot"], idx),
|
||||||
|
reply_markup=accept_keyboard([ts.get("label") or "?" for ts in flow.data["termsheets"]]))
|
||||||
|
return
|
||||||
|
if op == "ok":
|
||||||
|
await _commit_accept(ctx, chat_id, user, flow)
|
||||||
|
|
||||||
|
|
||||||
|
async def _commit_accept(ctx, chat_id, user, flow) -> None:
|
||||||
|
data = flow.data
|
||||||
|
quote_id = data["quote_id"]
|
||||||
|
termsheets = data["termsheets"]
|
||||||
|
ts_index = data.get("chosen", 0)
|
||||||
|
if not 0 <= ts_index < len(termsheets):
|
||||||
|
await ctx.send(chat_id, "شرایط پرداخت انتخاب نشده است.")
|
||||||
|
return
|
||||||
|
ts = termsheets[ts_index]
|
||||||
|
steps = ts.get("schedule_steps") or []
|
||||||
|
if not steps:
|
||||||
|
await ctx.send(chat_id, "ترمشیت انتخابی مرحله پرداخت ندارد؛ در برنامه تکمیلش کن.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
quote = await ctx.api.get_quote(quote_id, expand=EXPAND, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
|
||||||
|
decisions_payload = []
|
||||||
|
problems = []
|
||||||
|
for li in quote.get("line_items") or []:
|
||||||
|
decision = li.get("decision") or {}
|
||||||
|
if decision.get("status") == "rejected":
|
||||||
|
continue
|
||||||
|
price = li.get("unit_price")
|
||||||
|
if not price:
|
||||||
|
problems.append(f"قیمت قلم «{(li.get('material') or {}).get('name') or '?'}» ثبت نشده")
|
||||||
|
continue
|
||||||
|
li_projects = li.get("projects") or []
|
||||||
|
project_id = li_projects[0]["id"] if len(li_projects) == 1 else quote.get("project_id")
|
||||||
|
decisions_payload.append({
|
||||||
|
"line_item_id": li["id"],
|
||||||
|
"status": "chosen",
|
||||||
|
"accepted_termsheet_index": 0,
|
||||||
|
"adjusted_unit_price": price,
|
||||||
|
"project_id": project_id,
|
||||||
|
})
|
||||||
|
if problems:
|
||||||
|
await ctx.send(chat_id, "پذیرش ممکن نیست:\n" + "\n".join(f"❌ {esc(p)}" for p in problems))
|
||||||
|
return
|
||||||
|
if not decisions_payload:
|
||||||
|
await ctx.send(chat_id, "قلم قابل پذیرشی نیست (همه رد شدهاند؟).")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"accepted_termsheets": [{
|
||||||
|
"id": ts["id"],
|
||||||
|
"label": ts.get("label"),
|
||||||
|
"discount_type": ts.get("discount_type") or "none",
|
||||||
|
"discount_value": ts.get("discount_value") or 0,
|
||||||
|
"schedule_steps": steps,
|
||||||
|
}],
|
||||||
|
"item_decisions": decisions_payload,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = await ctx.api.accept_quote(quote_id, payload, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
chosen_label = ts.get("label") or ""
|
||||||
|
await ctx.send(chat_id, ACCEPT_REPORT.format(
|
||||||
|
id=esc(str(result.get("id"))[:8]),
|
||||||
|
status=STATUS_FA.get(result.get("status"), result.get("status")),
|
||||||
|
count=len(decisions_payload),
|
||||||
|
) + "\n" + ACCEPT_DONE_TS.format(label=esc(chosen_label)))
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool:
|
||||||
|
if flow.kind == "accept":
|
||||||
|
norm = _norm(text)
|
||||||
|
if norm in CANCEL_WORDS:
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
return True
|
||||||
|
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
|
||||||
|
await _commit_accept(ctx, chat_id, _flow_user(flow), flow)
|
||||||
|
return True
|
||||||
|
await ctx.send(chat_id, "برای پذیرش از دکمهها استفاده کن (یا /cancel).")
|
||||||
|
return True
|
||||||
|
if flow.kind == "arrive_qty":
|
||||||
|
await _handle_arrive_quantities(ctx, chat_id, flow, text)
|
||||||
|
return True
|
||||||
|
if flow.kind == "arrive_confirm":
|
||||||
|
norm = _norm(text)
|
||||||
|
if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")):
|
||||||
|
await _commit_arrive(ctx, chat_id, _flow_user(flow), flow)
|
||||||
|
return True
|
||||||
|
if norm in CANCEL_WORDS:
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
return True
|
||||||
|
await ctx.send(chat_id, "با دکمهها تأیید یا لغو کن (یا /cancel).")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# arrive (partial deliveries)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str:
|
||||||
|
lines = ["🚚 اعلام رسیدن اقلام", ""]
|
||||||
|
for li in items:
|
||||||
|
mat = (li.get("material") or {}).get("name") or "?"
|
||||||
|
ordered = li.get("quantity") or 0
|
||||||
|
received = ((li.get("receipt") or {}).get("received_qty")) or 0
|
||||||
|
unit = ((li.get("unit") or {}).get("name_fa")) or ""
|
||||||
|
got = ""
|
||||||
|
if quantities and li["id"] in quantities:
|
||||||
|
got = f" → الان: {esc(quantities[li['id']])} {unit}"
|
||||||
|
lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def begin_arrive(ctx, chat_id, user, quote: dict) -> None:
|
||||||
|
items = [li for li in quote.get("line_items") or [] if (li.get("decision") or {}).get("status") == "chosen"]
|
||||||
|
if not items:
|
||||||
|
await ctx.send(chat_id, "قلم پذیرفتهشدهای برای تحویل نیست.")
|
||||||
|
return
|
||||||
|
flow = ctx.pending.set(chat_id, "arrive_qty", {
|
||||||
|
"quote_id": str(quote["id"]),
|
||||||
|
"items": [{"id": li["id"], "name": (li.get("material") or {}).get("name") or "?",
|
||||||
|
"ordered": li.get("quantity"), "unit": ((li.get("unit") or {}).get("name_fa")) or "",
|
||||||
|
"received": ((li.get("receipt") or {}).get("received_qty")) or 0} for li in items],
|
||||||
|
"user_id": user.actor_id,
|
||||||
|
})
|
||||||
|
text = (
|
||||||
|
f"استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}\n"
|
||||||
|
+ _arrive_summary(quote.get("line_items") or [])
|
||||||
|
+ "\n\nمقادیر رسیده را به ترتیب اقلام بفرست (مثلاً: «500 و 200»)، یا «تحویل کامل همه» را بزن."
|
||||||
|
)
|
||||||
|
message = await ctx.send(chat_id, text, reply_markup=arrive_keyboard())
|
||||||
|
flow.data["message_id"] = (message or {}).get("message_id")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_arrive_quantities(ctx, chat_id, flow, text: str) -> None:
|
||||||
|
numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", normalize_digits(text))]
|
||||||
|
items = flow.data["items"]
|
||||||
|
if not numbers:
|
||||||
|
await ctx.send(chat_id, "مقادیر را عددی بفرست، مثل «500 و 200»، یا از دکمهها استفاده کن.")
|
||||||
|
return
|
||||||
|
if len(numbers) == 1 and len(items) > 1:
|
||||||
|
await ctx.send(chat_id, f"{len(items)} قلم است؛ برای هر قلم یک عدد بفرست (به ترتیب).")
|
||||||
|
return
|
||||||
|
if len(numbers) != len(items):
|
||||||
|
await ctx.send(chat_id, "تعداد اعداد با تعداد اقلام نمیخواند؛ دوباره بفرست.")
|
||||||
|
return
|
||||||
|
quantities = {item["id"]: qty for item, qty in zip(items, numbers)}
|
||||||
|
flow.kind = "arrive_confirm"
|
||||||
|
flow.data["quantities"] = quantities
|
||||||
|
await ctx.edit(chat_id, flow.data.get("message_id"),
|
||||||
|
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
|
||||||
|
+ "\n\nتأیید میکنی؟",
|
||||||
|
reply_markup=arrive_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
def _items_with_details(items: list[dict]) -> list[dict]:
|
||||||
|
return [{
|
||||||
|
"id": it["id"],
|
||||||
|
"material": {"name": it["name"]},
|
||||||
|
"quantity": it["ordered"],
|
||||||
|
"unit": {"name_fa": it["unit"]},
|
||||||
|
"receipt": {"received_qty": it["received"]},
|
||||||
|
} for it in items]
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_arrive_callback(ctx, chat_id, user, parts) -> None:
|
||||||
|
flow = ctx.pending.get(chat_id)
|
||||||
|
if not flow or not flow.kind.startswith("arrive"):
|
||||||
|
await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.")
|
||||||
|
return
|
||||||
|
op = parts[1] if len(parts) > 1 else ""
|
||||||
|
if op == "no":
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
await ctx.send(chat_id, CANCELLED)
|
||||||
|
return
|
||||||
|
if op == "full":
|
||||||
|
quantities = {item["id"]: max(float(item["ordered"] or 0) - float(item["received"] or 0), 0) for item in flow.data["items"]}
|
||||||
|
if any(v <= 0 for v in quantities.values()):
|
||||||
|
await ctx.send(chat_id, "برای بعضی اقلام چیزی برای تحویل نمانده؛ مقادیر را دستی بفرست.")
|
||||||
|
return
|
||||||
|
flow.kind = "arrive_confirm"
|
||||||
|
flow.data["quantities"] = quantities
|
||||||
|
await ctx.edit(chat_id, flow.data.get("message_id"),
|
||||||
|
_arrive_summary(_items_with_details(flow.data["items"]), quantities)
|
||||||
|
+ "\n\nتأیید میکنی؟",
|
||||||
|
reply_markup=arrive_keyboard())
|
||||||
|
return
|
||||||
|
if op == "ok":
|
||||||
|
if flow.kind != "arrive_confirm" or not flow.data.get("quantities"):
|
||||||
|
await ctx.send(chat_id, "اول مقادیر رسیده را بفرست یا «تحویل کامل همه» را بزن.")
|
||||||
|
return
|
||||||
|
await _commit_arrive(ctx, chat_id, user, flow)
|
||||||
|
|
||||||
|
|
||||||
|
async def _commit_arrive(ctx, chat_id, user, flow) -> None:
|
||||||
|
quantities: dict = flow.data["quantities"]
|
||||||
|
payload = {"items": [{"line_item_id": li_id, "received_qty": qty} for li_id, qty in quantities.items()]}
|
||||||
|
try:
|
||||||
|
result = await ctx.api.arrive_items(flow.data["quote_id"], payload, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
lines = []
|
||||||
|
for item in result.get("items") or []:
|
||||||
|
lines.append(f"• قلم {esc(str(item.get('line_item_id'))[:8])} — {esc(item.get('received_qty'))} ثبت شد")
|
||||||
|
await ctx.send(chat_id, ARRIVE_REPORT.format(
|
||||||
|
id=esc(str(result.get("id"))[:8]),
|
||||||
|
status=STATUS_FA.get(result.get("status"), result.get("status")),
|
||||||
|
lines="\n".join(lines) or "—",
|
||||||
|
))
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Casdoor linking: chat ↔ ERP user (validated through the real login endpoint)."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.runtime import ROLE_FA
|
||||||
|
from app.texts import LINK_ASK_PASSWORD, LINK_FAIL, LINK_OK, esc
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.links")
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_username_step(ctx, chat_id: int, flow, text: str) -> None:
|
||||||
|
username = (text or "").strip()
|
||||||
|
if not username or username.startswith("/"):
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
return
|
||||||
|
flow.kind = "link_password"
|
||||||
|
flow.data["username"] = username
|
||||||
|
await ctx.send(chat_id, LINK_ASK_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_link(ctx, chat_id: int, flow, text: str | None, password: str) -> None:
|
||||||
|
username = flow.data.get("username") or ""
|
||||||
|
ctx.pending.pop(chat_id)
|
||||||
|
if not password or password.startswith("/"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = await ctx.api.login(username, password)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
except Exception as exc: # network etc.
|
||||||
|
logger.exception("login failed")
|
||||||
|
await ctx.send(chat_id, LINK_FAIL.format(detail=esc(str(exc)[:200])))
|
||||||
|
return
|
||||||
|
|
||||||
|
user = data.get("user") or {}
|
||||||
|
display = " ".join(x for x in [user.get("first_name"), user.get("last_name")] if x) or username
|
||||||
|
await ctx.links.upsert(
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user.get("id"),
|
||||||
|
username=username,
|
||||||
|
display_name=display,
|
||||||
|
role=user.get("role"),
|
||||||
|
)
|
||||||
|
role_fa = ROLE_FA.get(user.get("role") or "", user.get("role") or "بدون نقش")
|
||||||
|
await ctx.send(chat_id, LINK_OK.format(name=esc(display), role=role_fa))
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Add-material flow: name + canonical unit (required)."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.drafts import (
|
||||||
|
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
|
||||||
|
)
|
||||||
|
from app.keyboards import pick_buttons, simple_review_keyboard
|
||||||
|
from app.resolve import safe_resolve
|
||||||
|
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.material")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||||
|
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
|
||||||
|
if draft is not None:
|
||||||
|
payload = draft.payload
|
||||||
|
merge_material_patch(payload, patch)
|
||||||
|
else:
|
||||||
|
payload = new_material_payload()
|
||||||
|
merge_material_patch(payload, patch)
|
||||||
|
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
|
||||||
|
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_payload(ctx, payload: dict) -> None:
|
||||||
|
options = payload.setdefault("options", {})
|
||||||
|
if not payload.get("canonical_unit_id") and payload.get("unit_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
|
||||||
|
if res.resolved:
|
||||||
|
payload["canonical_unit_id"] = res.candidates[0].id
|
||||||
|
payload["unit_name"] = res.candidates[0].name
|
||||||
|
options.pop("unit", None)
|
||||||
|
elif res.candidates:
|
||||||
|
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||||
|
else:
|
||||||
|
options.pop("unit", None)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||||
|
if field == "unit":
|
||||||
|
draft.payload["canonical_unit_id"] = choice["id"]
|
||||||
|
draft.payload["unit_name"] = choice["name"]
|
||||||
|
draft.payload.setdefault("options", {}).pop("unit", None)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)})
|
||||||
|
await render(ctx, chat_id, draft, fresh=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
options = (payload.get("options") or {}).get("unit") or []
|
||||||
|
if options:
|
||||||
|
rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]])
|
||||||
|
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
missing = material_missing(payload)
|
||||||
|
text = material_review_text(payload, missing)
|
||||||
|
keyboard = _keyboard(payload, draft.id)
|
||||||
|
if fresh:
|
||||||
|
message = await ctx.send(chat_id, text, reply_markup=keyboard)
|
||||||
|
draft.review_message_id = (message or {}).get("message_id")
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": missing},
|
||||||
|
review_message_id=draft.review_message_id)
|
||||||
|
else:
|
||||||
|
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||||
|
|
||||||
|
|
||||||
|
async def commit(ctx, chat_id, user, draft) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
|
||||||
|
problems = [label for key, label, ok in [
|
||||||
|
("name", "نام کالا", missing.get("name")),
|
||||||
|
("unit", "واحد اصلی", missing.get("unit")),
|
||||||
|
] if not ok]
|
||||||
|
if problems:
|
||||||
|
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول اینها را مشخص کن:\n" +
|
||||||
|
"\n".join(f"❌ {esc(p)}" for p in problems))
|
||||||
|
return
|
||||||
|
body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])}
|
||||||
|
if payload.get("topic"):
|
||||||
|
body["topic"] = payload["topic"]
|
||||||
|
try:
|
||||||
|
created = await ctx.api.create_material(body, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
await draft_store.set_status(draft.id, "committed")
|
||||||
|
ctx.resolver.invalidate("materials")
|
||||||
|
await ctx.send(chat_id, material_committed_report(created))
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Status queries: «استعلامهای در انتظار؟»، «کدامها منقضی میشوند؟» …"""
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.texts import ERR_GENERIC, status_report, esc
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.queries")
|
||||||
|
|
||||||
|
EXPAND = "supplier,line_items"
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_query(ctx, chat_id, user, patch: dict) -> None:
|
||||||
|
query = patch.get("query") or {}
|
||||||
|
kind = query.get("kind") or "pending"
|
||||||
|
supplier_name = query.get("supplier_name")
|
||||||
|
q = supplier_name or query.get("project_name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if kind == "expiring":
|
||||||
|
quotes, _ = await ctx.api.list_quotes(status="offered", expand=EXPAND,
|
||||||
|
q=q, sort="-quote_date", page_size=100)
|
||||||
|
deadline = date.today() + timedelta(days=query.get("days") or 7)
|
||||||
|
soon = []
|
||||||
|
for quote in quotes:
|
||||||
|
expiry = (quote.get("expiry_date") or "")[:10]
|
||||||
|
if expiry and expiry <= deadline.isoformat():
|
||||||
|
soon.append(quote)
|
||||||
|
soon.sort(key=lambda x: x.get("expiry_date") or "9999")
|
||||||
|
await ctx.send(chat_id, status_report("expiring", soon[:10]))
|
||||||
|
return
|
||||||
|
|
||||||
|
if kind in ("accepted", "arrived", "pending", "offered"):
|
||||||
|
status = "offered" if kind == "pending" else kind
|
||||||
|
quotes, _ = await ctx.api.list_quotes(status=status, expand=EXPAND, q=q,
|
||||||
|
sort="-quote_date", page_size=10)
|
||||||
|
await ctx.send(chat_id, status_report("pending" if kind == "offered" else kind, quotes))
|
||||||
|
return
|
||||||
|
|
||||||
|
# recent / anything else
|
||||||
|
quotes, meta = await ctx.api.list_quotes(expand=EXPAND, q=q, sort="-quote_date", page_size=5)
|
||||||
|
await ctx.send(chat_id, status_report("recent", quotes))
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
"""Register-quote flow: entities → draft → resolve → review (+missing) → commit."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.drafts import (
|
||||||
|
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
|
||||||
|
new_quote_payload, quote_ready,
|
||||||
|
)
|
||||||
|
from app.keyboards import quote_review_keyboard
|
||||||
|
from app.resolve import safe_resolve
|
||||||
|
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.quote")
|
||||||
|
|
||||||
|
FIELD_FA = {"sup": "تأمینکننده", "prj": "پروژه"}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_entities(
|
||||||
|
ctx, chat_id, user, patch: dict, source: str | None,
|
||||||
|
incoming_msg_id: int | None = None,
|
||||||
|
target_draft = None,
|
||||||
|
) -> None:
|
||||||
|
prev_review_msg_id = None
|
||||||
|
if target_draft is not None:
|
||||||
|
draft = target_draft
|
||||||
|
prev_review_msg_id = draft.review_message_id
|
||||||
|
draft.review_message_id = None
|
||||||
|
payload = draft.payload
|
||||||
|
merge_quote_patch(payload, patch)
|
||||||
|
draft.source_message_id = incoming_msg_id
|
||||||
|
else:
|
||||||
|
payload = new_quote_payload()
|
||||||
|
merge_quote_patch(payload, patch)
|
||||||
|
draft = await draft_store.create(
|
||||||
|
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
|
||||||
|
if prev_review_msg_id:
|
||||||
|
try:
|
||||||
|
await ctx.delete(chat_id, prev_review_msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
|
||||||
|
|
||||||
|
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def resolve_payload(ctx, payload: dict) -> None:
|
||||||
|
options = payload.setdefault("options", {})
|
||||||
|
|
||||||
|
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
|
||||||
|
if res.resolved:
|
||||||
|
c = res.candidates[0]
|
||||||
|
payload["supplier_id"] = c.id
|
||||||
|
payload["supplier_name"] = c.name
|
||||||
|
extra = c.extra or {}
|
||||||
|
for k in ("location", "phone", "contact_person", "category_name"):
|
||||||
|
if extra.get(k):
|
||||||
|
payload[f"supplier_{k}"] = extra[k]
|
||||||
|
options.pop("sup", None)
|
||||||
|
elif res.candidates:
|
||||||
|
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||||
|
else:
|
||||||
|
options.pop("sup", None)
|
||||||
|
|
||||||
|
if not payload.get("project_id") and payload.get("project_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||||
|
if res.resolved:
|
||||||
|
payload["project_id"] = res.candidates[0].id
|
||||||
|
payload["project_name"] = res.candidates[0].name
|
||||||
|
if payload["project_id"] not in payload.get("project_ids", []):
|
||||||
|
payload.setdefault("project_ids", []).append(payload["project_id"])
|
||||||
|
options.pop("prj", None)
|
||||||
|
elif res.candidates:
|
||||||
|
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||||
|
else:
|
||||||
|
options.pop("prj", None)
|
||||||
|
|
||||||
|
for p_name in payload.get("project_names") or []:
|
||||||
|
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||||
|
if res.resolved:
|
||||||
|
p_id = res.candidates[0].id
|
||||||
|
if p_id not in payload.get("project_ids", []):
|
||||||
|
payload.setdefault("project_ids", []).append(p_id)
|
||||||
|
if not payload.get("project_id"):
|
||||||
|
payload["project_id"] = p_id
|
||||||
|
|
||||||
|
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||||
|
if not item.get("material_id") and item.get("material_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
|
||||||
|
if res.resolved:
|
||||||
|
_set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra)
|
||||||
|
options.pop(f"mat{i}", None)
|
||||||
|
elif res.candidates:
|
||||||
|
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||||
|
else:
|
||||||
|
options.pop(f"mat{i}", None)
|
||||||
|
if not item.get("unit_id"):
|
||||||
|
await _resolve_unit(ctx, item)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
|
||||||
|
item["material_id"] = material_id
|
||||||
|
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_unit(ctx, item: dict) -> None:
|
||||||
|
if item.get("unit_id"):
|
||||||
|
return
|
||||||
|
if item.get("unit_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
|
||||||
|
if res.resolved:
|
||||||
|
item["unit_id"] = res.candidates[0].id
|
||||||
|
item["unit_explicit"] = True
|
||||||
|
item["unit_name"] = res.candidates[0].name
|
||||||
|
return
|
||||||
|
canonical = ((item.get("material_row") or {}).get("canonical_unit_id"))
|
||||||
|
if canonical:
|
||||||
|
item["unit_id"] = str(canonical)
|
||||||
|
item["unit_explicit"] = False
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
options = payload.setdefault("options", {})
|
||||||
|
options.pop(field, None)
|
||||||
|
|
||||||
|
if field == "sup":
|
||||||
|
payload["supplier_id"] = choice["id"]
|
||||||
|
payload["supplier_name"] = choice["name"]
|
||||||
|
elif field == "prj":
|
||||||
|
payload["project_id"] = choice["id"]
|
||||||
|
payload["project_name"] = choice["name"]
|
||||||
|
elif field.startswith("mat"):
|
||||||
|
index = int(field[3:]) - 1
|
||||||
|
items = payload.get("items") or []
|
||||||
|
if 0 <= index < len(items):
|
||||||
|
items[index]["material_name"] = choice["name"]
|
||||||
|
_set_material(items[index], choice["id"], choice["name"])
|
||||||
|
await _resolve_unit(ctx, items[index])
|
||||||
|
elif field.startswith("unit"):
|
||||||
|
index = int(field[4:]) - 1
|
||||||
|
items = payload.get("items") or []
|
||||||
|
if 0 <= index < len(items):
|
||||||
|
items[index]["unit_id"] = choice["id"]
|
||||||
|
items[index]["unit_explicit"] = True
|
||||||
|
items[index]["unit_name"] = choice["name"]
|
||||||
|
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
|
||||||
|
await render(ctx, chat_id, draft, fresh=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pick_rows(payload: dict) -> list[list[str]]:
|
||||||
|
"""Field labels shown as pick-button rows (via keyboards.pick_buttons)."""
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
options = payload.get("options") or {}
|
||||||
|
if options.get("sup"):
|
||||||
|
rows.append(["sup"] + [o["name"] for o in options["sup"]])
|
||||||
|
if options.get("prj"):
|
||||||
|
rows.append(["prj"] + [o["name"] for o in options["prj"]])
|
||||||
|
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||||
|
if options.get(f"mat{i}"):
|
||||||
|
rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]])
|
||||||
|
if options.get(f"unit{i}"):
|
||||||
|
rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict:
|
||||||
|
from app.keyboards import pick_buttons
|
||||||
|
|
||||||
|
prefix = str(draft_id)[:8]
|
||||||
|
pick_rows: list[list[dict]] = []
|
||||||
|
for row in _pick_rows(payload):
|
||||||
|
field, names = row[0], row[1:]
|
||||||
|
pick_rows += pick_buttons(prefix, field, names[:6])
|
||||||
|
ready_items = sum(
|
||||||
|
1 for it in payload.get("items") or []
|
||||||
|
if it.get("material_id") and it.get("quantity") and it.get("unit_id")
|
||||||
|
)
|
||||||
|
has_partial = 0 < ready_items < len(payload.get("items") or [])
|
||||||
|
return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url)
|
||||||
|
|
||||||
|
|
||||||
|
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
|
||||||
|
reply_to: int | None = None) -> None:
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
from app.security import create_draft_token
|
||||||
|
|
||||||
|
payload = draft.payload
|
||||||
|
missing = compute_quote_missing(payload)
|
||||||
|
token = create_draft_token(
|
||||||
|
draft.id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
secret=settings.DRAFT_TOKEN_SECRET,
|
||||||
|
expires_in=settings.DRAFT_TOKEN_EXPIRE_SECONDS,
|
||||||
|
)
|
||||||
|
wizard_url = f"{settings.FRONTEND_URL.rstrip('/')}/review?token={token}"
|
||||||
|
text = quote_review_text(payload, missing, status_note=note)
|
||||||
|
text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}"
|
||||||
|
keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url)
|
||||||
|
if fresh:
|
||||||
|
message = await ctx.send(chat_id, text, reply_markup=keyboard, reply_to=reply_to)
|
||||||
|
new_review_id = (message or {}).get("message_id")
|
||||||
|
if new_review_id:
|
||||||
|
draft.review_message_id = new_review_id
|
||||||
|
await draft_store.save_payload(
|
||||||
|
draft,
|
||||||
|
missing={"fields": missing},
|
||||||
|
review_message_id=new_review_id,
|
||||||
|
source_message_id=reply_to or draft.source_message_id,
|
||||||
|
mark_bale_synced=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await draft_store.save_payload(
|
||||||
|
draft,
|
||||||
|
missing={"fields": missing},
|
||||||
|
source_message_id=reply_to or draft.source_message_id,
|
||||||
|
mark_bale_synced=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not draft.review_message_id:
|
||||||
|
return
|
||||||
|
success = await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||||
|
if success:
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": missing}, mark_bale_synced=True)
|
||||||
|
else:
|
||||||
|
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# commit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
|
||||||
|
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||||
|
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||||
|
cat_id = payload.get("supplier_category_id")
|
||||||
|
if not cat_id:
|
||||||
|
cats = await ctx.resolver.categories()
|
||||||
|
if cats:
|
||||||
|
cat_id = cats[0].id
|
||||||
|
if cat_id:
|
||||||
|
try:
|
||||||
|
sup_body = {
|
||||||
|
"name": payload["supplier_name"],
|
||||||
|
"category_id": cat_id,
|
||||||
|
}
|
||||||
|
if payload.get("supplier_phone"):
|
||||||
|
sup_body["phone"] = payload["supplier_phone"]
|
||||||
|
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||||
|
payload["supplier_id"] = created_sup["id"]
|
||||||
|
ctx.resolver.invalidate("suppliers")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not auto-create supplier: %s", e)
|
||||||
|
|
||||||
|
# 2. Sequential pre-creation for new materials
|
||||||
|
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||||
|
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||||
|
try:
|
||||||
|
created_mat = await ctx.api.create_material({
|
||||||
|
"name": item["material_name"],
|
||||||
|
"canonical_unit_id": int(item["unit_id"]),
|
||||||
|
}, actor_id=user.actor_id)
|
||||||
|
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||||
|
ctx.resolver.invalidate("materials")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not auto-create material: %s", e)
|
||||||
|
|
||||||
|
ready, blockers = quote_ready(payload)
|
||||||
|
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
||||||
|
if body is None:
|
||||||
|
lines = ["ثبت ممکن نیست؛ اینها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems]
|
||||||
|
await ctx.send(chat_id, "\n".join(lines))
|
||||||
|
return
|
||||||
|
if not ready and not only_resolved:
|
||||||
|
lines = ["برای ثبت کامل، اول اینها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers]
|
||||||
|
lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."]
|
||||||
|
await ctx.send(chat_id, "\n".join(lines))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await ctx.api.create_quote(body, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception("create_quote failed")
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه"))
|
||||||
|
return
|
||||||
|
|
||||||
|
await draft_store.set_status(draft.id, "committed")
|
||||||
|
ctx.pending.pop(chat_id, None)
|
||||||
|
ctx.resolver.invalidate()
|
||||||
|
|
||||||
|
quote_id = (created or {}).get("id") or ""
|
||||||
|
# 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"):
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
p_path = Path(proforma_info["saved_path"])
|
||||||
|
if p_path.exists():
|
||||||
|
file_bytes = p_path.read_bytes()
|
||||||
|
await ctx.api.upload_quote_file(
|
||||||
|
quote_id,
|
||||||
|
"proforma",
|
||||||
|
proforma_info.get("file_name") or "proforma.pdf",
|
||||||
|
file_bytes,
|
||||||
|
mime_type=proforma_info.get("mime_type") or "application/pdf",
|
||||||
|
actor_id=user.actor_id,
|
||||||
|
)
|
||||||
|
logger.info("Successfully uploaded proforma to quote %s", quote_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not upload draft proforma to quote %s: %s", quote_id, e)
|
||||||
|
|
||||||
|
if draft.review_message_id:
|
||||||
|
try:
|
||||||
|
msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}"
|
||||||
|
await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
remaining = [
|
||||||
|
m["label"] for m in compute_quote_missing(payload)
|
||||||
|
if not m["blocking"] and not m["ok"]
|
||||||
|
]
|
||||||
|
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
||||||
|
if dropped > 0:
|
||||||
|
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
||||||
|
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Add-supplier flow: name + one of the 8 fixed Persian categories (required)."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.api.client import ApiError
|
||||||
|
from app.drafts import (
|
||||||
|
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
|
||||||
|
)
|
||||||
|
from app.keyboards import pick_buttons, simple_review_keyboard
|
||||||
|
from app.resolve import safe_resolve
|
||||||
|
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.supplier")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||||
|
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
|
||||||
|
if draft is not None:
|
||||||
|
payload = draft.payload
|
||||||
|
merge_supplier_patch(payload, patch)
|
||||||
|
else:
|
||||||
|
payload = new_supplier_payload()
|
||||||
|
merge_supplier_patch(payload, patch)
|
||||||
|
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
|
||||||
|
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_payload(ctx, payload: dict) -> None:
|
||||||
|
options = payload.setdefault("options", {})
|
||||||
|
if not payload.get("category_id") and payload.get("category_name"):
|
||||||
|
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
|
||||||
|
if res.resolved:
|
||||||
|
payload["category_id"] = res.candidates[0].id
|
||||||
|
payload["category_name"] = res.candidates[0].name
|
||||||
|
options.pop("cat", None)
|
||||||
|
elif res.candidates:
|
||||||
|
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||||
|
else:
|
||||||
|
options.pop("cat", None)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
||||||
|
if field == "cat":
|
||||||
|
draft.payload["category_id"] = choice["id"]
|
||||||
|
draft.payload["category_name"] = choice["name"]
|
||||||
|
draft.payload.setdefault("options", {}).pop("cat", None)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": supplier_missing(draft.payload)})
|
||||||
|
await render(ctx, chat_id, draft, fresh=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||||
|
rows: list[list[dict]] = []
|
||||||
|
options = (payload.get("options") or {}).get("cat") or []
|
||||||
|
if options:
|
||||||
|
rows += pick_buttons(draft_id, "cat", [o["name"] for o in options[:6]])
|
||||||
|
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
missing = supplier_missing(payload)
|
||||||
|
text = supplier_review_text(payload, missing)
|
||||||
|
keyboard = _keyboard(payload, draft.id)
|
||||||
|
if fresh:
|
||||||
|
message = await ctx.send(chat_id, text, reply_markup=keyboard)
|
||||||
|
draft.review_message_id = (message or {}).get("message_id")
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": missing},
|
||||||
|
review_message_id=draft.review_message_id)
|
||||||
|
else:
|
||||||
|
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||||
|
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||||
|
|
||||||
|
|
||||||
|
async def commit(ctx, chat_id, user, draft) -> None:
|
||||||
|
payload = draft.payload
|
||||||
|
await resolve_payload(ctx, payload)
|
||||||
|
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
|
||||||
|
problems = [label for key, label, ok in [
|
||||||
|
("name", "نام تأمینکننده", missing.get("name")),
|
||||||
|
("category", "دستهبندی", missing.get("category")),
|
||||||
|
] if not ok]
|
||||||
|
if problems:
|
||||||
|
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول اینها را مشخص کن:\n" +
|
||||||
|
"\n".join(f"❌ {esc(p)}" for p in problems))
|
||||||
|
return
|
||||||
|
body = {
|
||||||
|
"name": payload["name"],
|
||||||
|
"category_id": payload["category_id"],
|
||||||
|
"phone": payload.get("phone"),
|
||||||
|
"location": payload.get("location"),
|
||||||
|
"contact_person": payload.get("contact_person"),
|
||||||
|
}
|
||||||
|
body = {k: v for k, v in body.items() if v}
|
||||||
|
try:
|
||||||
|
created = await ctx.api.create_supplier(body, actor_id=user.actor_id)
|
||||||
|
except ApiError as exc:
|
||||||
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||||
|
return
|
||||||
|
await draft_store.set_status(draft.id, "committed")
|
||||||
|
ctx.resolver.invalidate("suppliers")
|
||||||
|
await ctx.send(chat_id, supplier_committed_report(created))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Inline keyboard builders.
|
||||||
|
|
||||||
|
Bale keeps Telegram's 64-byte ``callback_data`` cap, so every callback is a
|
||||||
|
short op-code plus the 8-char draft prefix; the full state lives in bot_drafts
|
||||||
|
(or the in-memory pending-flow map) and is looked up on tap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _kb(rows: list[list[dict]]) -> dict:
|
||||||
|
return {"inline_keyboard": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _btn(text: str, data: str) -> dict:
|
||||||
|
return {"text": text, "callback_data": data}
|
||||||
|
|
||||||
|
|
||||||
|
# callback scheme (≤64 bytes):
|
||||||
|
# q:ok:{d8} / q:no:{d8} / q:ok2:{d8} quote commit / cancel / commit-resolved-only
|
||||||
|
# s:ok:{d8} / s:no:{d8} supplier
|
||||||
|
# m:ok:{d8} / m:no:{d8} material
|
||||||
|
# pk:{d8}:{field}:{idx} pick candidate for a draft field
|
||||||
|
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
|
||||||
|
|
||||||
|
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
|
||||||
|
op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q")
|
||||||
|
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
|
||||||
|
if has_partial_option:
|
||||||
|
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def pick_buttons(prefix: str, field: str, names: list[str]) -> list[list[dict]]:
|
||||||
|
return [[_btn(name[:60], f"pk:{prefix}:{field}:{i}")] for i, name in enumerate(names)]
|
||||||
|
|
||||||
|
|
||||||
|
def quote_review_keyboard(
|
||||||
|
prefix: str,
|
||||||
|
pick_rows: list[list[dict]],
|
||||||
|
has_partial_option: bool = False,
|
||||||
|
wizard_url: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
rows = list(pick_rows)
|
||||||
|
if wizard_url:
|
||||||
|
rows.append([{"text": "🖥 بازبینی و تکمیل در ویزارد", "url": wizard_url}])
|
||||||
|
rows += draft_actions(prefix, "quote", has_partial_option=has_partial_option)
|
||||||
|
return _kb(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def simple_review_keyboard(prefix: str, kind: str, pick_rows: list[list[dict]]) -> dict:
|
||||||
|
rows = list(pick_rows)
|
||||||
|
rows += draft_actions(prefix, kind)
|
||||||
|
return _kb(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def quote_pick_keyboard(pairs: list[tuple[str, str]]) -> dict:
|
||||||
|
"""Pick among candidate quotes: pairs of (label, quote_id)."""
|
||||||
|
return _kb([[_btn(label[:60], f"qc:{i}")] for i, (label, _) in enumerate(pairs)])
|
||||||
|
|
||||||
|
|
||||||
|
def accept_keyboard(termsheet_labels: list[str]) -> dict:
|
||||||
|
rows = [[_btn(f"شرایط: {label[:40]}", f"ac:ts:{i}")] for i, label in enumerate(termsheet_labels)]
|
||||||
|
rows.append([_btn("✅ تأیید پذیرش", "ac:ok"), _btn("❌ انصراف", "ac:no")])
|
||||||
|
return _kb(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def arrive_keyboard() -> dict:
|
||||||
|
return _kb([
|
||||||
|
[_btn("📦 تحویل کامل همه اقلام", "ar:full")],
|
||||||
|
[_btn("✅ ثبت مقادیر اعلامشده", "ar:ok"), _btn("❌ انصراف", "ar:no")],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_keyboard() -> dict:
|
||||||
|
return _kb([[_btn("❌ انصراف", "ac:no")]])
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Bot entrypoint: ensure tables → sweeper task → Bale long-poll loop.
|
||||||
|
|
||||||
|
Mirrors the api's pattern of a plain asyncio background loop (no Celery/Redis).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.bale.client import BaleClient, poll_loop
|
||||||
|
from app.config import settings
|
||||||
|
from app.db_init import ensure_schema
|
||||||
|
from app.drafts import expire_stale
|
||||||
|
from app.handlers import handle_update
|
||||||
|
from app.runtime import BotContext
|
||||||
|
from app.api.client import ErpClient
|
||||||
|
from app.sync import sync_drafts_loop
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("bot.main")
|
||||||
|
|
||||||
|
|
||||||
|
async def sweeper_loop() -> None:
|
||||||
|
"""Expire stale drafts + sweep dead pending flows every 10 minutes."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
expired = await expire_stale(settings.DRAFT_TTL_HOURS)
|
||||||
|
if expired:
|
||||||
|
logger.info("expired %s stale drafts", expired)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("sweeper failed")
|
||||||
|
await asyncio.sleep(600)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
if not settings.BOT_TOKEN:
|
||||||
|
raise SystemExit("PROCUREMENT_BOT_BOT_TOKEN is not set — get one from Bale's BotFather")
|
||||||
|
logger.info("starting bale procurement bot (api=%s, ai=%s)",
|
||||||
|
settings.API_BASE_URL, "configured" if settings.AI_BASE_URL else "NOT configured")
|
||||||
|
await ensure_schema()
|
||||||
|
|
||||||
|
bale = BaleClient(settings.BALE_API_BASE, settings.BOT_TOKEN)
|
||||||
|
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
|
||||||
|
ctx = BotContext(bale, api)
|
||||||
|
|
||||||
|
sweeper = asyncio.create_task(sweeper_loop())
|
||||||
|
sync_task = asyncio.create_task(sync_drafts_loop(ctx))
|
||||||
|
try:
|
||||||
|
await poll_loop(bale, lambda update: handle_update(ctx, update))
|
||||||
|
finally:
|
||||||
|
sweeper.cancel()
|
||||||
|
sync_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Bot-owned tables (bot_* prefix, idempotent DDL in db_init.py).
|
||||||
|
|
||||||
|
These are separate from the ERP schema on purpose: the bot is an autonomous
|
||||||
|
container and owns its own state (links, drafts, message audit log).
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid_pk() -> Mapped[str]:
|
||||||
|
return mapped_column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid())
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamptz(**kwargs) -> Mapped[datetime]:
|
||||||
|
return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class BotLink(Base):
|
||||||
|
"""Bale chat ↔ ERP user mapping (established via Casdoor login)."""
|
||||||
|
|
||||||
|
__tablename__ = "bot_links"
|
||||||
|
|
||||||
|
id: Mapped[str] = _uuid_pk()
|
||||||
|
chat_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True)
|
||||||
|
user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) # users.id
|
||||||
|
casdoor_username: Mapped[str | None] = mapped_column(Text)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(Text)
|
||||||
|
role: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BotDraft(Base):
|
||||||
|
"""A prepared-but-not-committed ERP action (quote / supplier / material)."""
|
||||||
|
|
||||||
|
__tablename__ = "bot_drafts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_bot_drafts_chat_status", "chat_id", "status"),
|
||||||
|
Index("ix_bot_drafts_review_msg", "review_message_id"),
|
||||||
|
Index("ix_bot_drafts_source_msg", "source_message_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = _uuid_pk()
|
||||||
|
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material
|
||||||
|
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
missing: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed
|
||||||
|
source: Mapped[str | None] = mapped_column(String(16)) # text|voice
|
||||||
|
source_message_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
review_message_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class BotMessage(Base):
|
||||||
|
"""Audit log of every inbound/outbound message (who asked for what)."""
|
||||||
|
|
||||||
|
__tablename__ = "bot_messages"
|
||||||
|
|
||||||
|
id: Mapped[str] = _uuid_pk()
|
||||||
|
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||||
|
direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error
|
||||||
|
body: Mapped[str | None] = mapped_column(Text)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
|
||||||
|
class BotMeta(Base):
|
||||||
|
"""Tiny KV store (e.g. last processed Bale update_id across restarts)."""
|
||||||
|
|
||||||
|
__tablename__ = "bot_meta"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
value: Mapped[str | None] = mapped_column(Text)
|
||||||
|
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
"""Natural-language understanding: Persian free text → typed intent + entities.
|
||||||
|
|
||||||
|
One chat/completions call per message with JSON-mode output. The prompt embeds
|
||||||
|
the current draft summary so the model can decide between "start a new
|
||||||
|
activity" and "refine the open draft" (update_draft with a partial patch).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.nlu")
|
||||||
|
|
||||||
|
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_digits(text: str) -> str:
|
||||||
|
return (text or "").translate(PERSIAN_DIGITS)
|
||||||
|
|
||||||
|
|
||||||
|
ZWNJ = "\u200c"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(text: str) -> str:
|
||||||
|
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
|
||||||
|
t = normalize_digits(text or "")
|
||||||
|
t = t.replace(ZWNJ, " ").replace("ي", "ی").replace("ك", "ک")
|
||||||
|
t = re.sub(r"\s+", " ", t).strip().lower()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
INTENTS = [
|
||||||
|
"register_quote", # ثبت استعلام/پیشنهاد جدید از یک تأمینکننده
|
||||||
|
"update_draft", # تکمیل/اصلاح پیشنویس باز (patch روی آن)
|
||||||
|
"commit_draft", # «ثبت کن» / «تأیید» — commit the open draft
|
||||||
|
"discard_draft", # «بیخیال» / «لغو»
|
||||||
|
"show_draft", # «وضعیت پیشنویس؟»
|
||||||
|
"add_supplier",
|
||||||
|
"add_material",
|
||||||
|
"status_query", # پرسوجو: استعلامهای در انتظار / بهزودی منقضی / ...
|
||||||
|
"accept_quote", # پذیرش یک استعلام
|
||||||
|
"arrive_items", # اعلام رسیدن اقلام
|
||||||
|
"help",
|
||||||
|
"unknown",
|
||||||
|
]
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
|
||||||
|
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
|
||||||
|
|
||||||
|
{
|
||||||
|
"consensus_text": "متن تجمیعشده و اصلاحشده کاربر (فارسی روان و بدون غلط شنیداری)",
|
||||||
|
"intent": یکی از {intents},
|
||||||
|
"patch": {شکل partial هر فعالیتی که کاربر داده},
|
||||||
|
"confidence": 0.0..1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
قوانین:
|
||||||
|
- اگر ورودی شامل چند نسخه تبدیل گفتار به متن (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 مقادیر.
|
||||||
|
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
|
||||||
|
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
|
||||||
|
|
||||||
|
شکل patch برای intent="register_quote"/"update_draft":
|
||||||
|
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
|
||||||
|
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
|
||||||
|
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
|
||||||
|
|
||||||
|
شکل patch برای intent="add_supplier":
|
||||||
|
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
|
||||||
|
|
||||||
|
شکل patch برای intent="add_material":
|
||||||
|
{"material": {"name": str, "unit_name": str, "topic": str}}
|
||||||
|
|
||||||
|
شکل patch برای intent="status_query":
|
||||||
|
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
|
||||||
|
|
||||||
|
شکل patch برای intent="accept_quote"/"arrive_items":
|
||||||
|
{"target": {"supplier_name": str, "project_name": str, "quote_id": str},
|
||||||
|
"arrivals": [{"material_name": str, "quantity": number, "quality_note": str}]}
|
||||||
|
|
||||||
|
{draft_block}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_system_prompt(draft_summary: str | None) -> str:
|
||||||
|
intents = ", ".join(INTENTS)
|
||||||
|
prompt = SYSTEM_PROMPT.replace("{intents}", intents).replace("{draft_block}", "")
|
||||||
|
if draft_summary:
|
||||||
|
prompt += (
|
||||||
|
"\n\nپیشنویسِ باز فعلی کاربر (اگر مرتبط است به آن تکیه کن و فیلدهای تکراری را بازنویسی نکن):\n"
|
||||||
|
+ draft_summary
|
||||||
|
)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(raw: str) -> dict | None:
|
||||||
|
raw = (raw or "").strip()
|
||||||
|
if raw.startswith("```"):
|
||||||
|
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw).rstrip("`").strip()
|
||||||
|
start = raw.find("{")
|
||||||
|
end = raw.rfind("}")
|
||||||
|
if start == -1 or end <= start:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict:
|
||||||
|
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
|
||||||
|
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
|
||||||
|
|
||||||
|
if isinstance(text, list):
|
||||||
|
first_text = text[0].get("text") if text else ""
|
||||||
|
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیادهسازی شده است:"]
|
||||||
|
for idx, item in enumerate(text, start=1):
|
||||||
|
name = item.get("name") or f"مدل {idx}"
|
||||||
|
t = normalize_digits(item.get("text") or "")
|
||||||
|
lines.append(f"- نسخه {idx} ({name}): «{t}»")
|
||||||
|
lines.append("\nبا تلفیق این نسخهها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
|
||||||
|
user_content = "\n".join(lines)
|
||||||
|
else:
|
||||||
|
user_content = normalize_digits(str(text))
|
||||||
|
first_text = str(text)
|
||||||
|
|
||||||
|
fallback["consensus_text"] = first_text
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
|
||||||
|
json={
|
||||||
|
"model": settings.LLM_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": _build_system_prompt(draft_summary)},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
],
|
||||||
|
"temperature": 0.1,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
|
||||||
|
return fallback
|
||||||
|
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||||
|
parsed = _extract_json(content)
|
||||||
|
if not parsed or parsed.get("intent") not in INTENTS:
|
||||||
|
logger.warning("unparseable LLM output: %r", content[:200])
|
||||||
|
return fallback
|
||||||
|
parsed.setdefault("patch", {})
|
||||||
|
if not parsed.get("consensus_text"):
|
||||||
|
parsed["consensus_text"] = first_text
|
||||||
|
return parsed
|
||||||
|
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
logger.exception("NLU failure")
|
||||||
|
return fallback
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches.
|
||||||
|
|
||||||
|
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
|
||||||
|
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
|
||||||
|
"""
|
||||||
|
import difflib
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.api.client import ApiError, ErpClient
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.nlu import normalize_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Candidate:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Resolution:
|
||||||
|
status: str # "one" | "many" | "none"
|
||||||
|
candidates: list[Candidate]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved(self) -> bool:
|
||||||
|
return self.status == "one"
|
||||||
|
|
||||||
|
|
||||||
|
def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]:
|
||||||
|
q = normalize_text(query)
|
||||||
|
if not q:
|
||||||
|
return items[:limit]
|
||||||
|
scored = []
|
||||||
|
for item in items:
|
||||||
|
name = normalize_text(item.name)
|
||||||
|
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||||
|
if q == name:
|
||||||
|
ratio = 1.0
|
||||||
|
elif q in name:
|
||||||
|
ratio += 0.35
|
||||||
|
scored.append((ratio, item))
|
||||||
|
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||||
|
return [item for ratio, item in scored[:limit] if ratio >= 0.40]
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_units() -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(u["id"]),
|
||||||
|
name=u.get("name_fa") or u.get("code") or str(u["id"]),
|
||||||
|
extra={"code": u.get("code")},
|
||||||
|
)
|
||||||
|
for u in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_categories() -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
rows = (await s.execute(text("SELECT id, name, slug FROM material_categories"))).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(c["id"]),
|
||||||
|
name=c.get("name") or c.get("slug") or str(c["id"]),
|
||||||
|
)
|
||||||
|
for c in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_projects() -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
rows = (await s.execute(text("SELECT id, name FROM projects"))).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(p["id"]),
|
||||||
|
name=p.get("name") or str(p["id"]),
|
||||||
|
)
|
||||||
|
for p in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_suppliers(q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
if q:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
|
||||||
|
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id "
|
||||||
|
"WHERE s.name ILIKE :q LIMIT 20"),
|
||||||
|
{"q": f"%{q}%"}
|
||||||
|
)).mappings().all()
|
||||||
|
else:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name "
|
||||||
|
"FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id LIMIT 20")
|
||||||
|
)).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(r["id"]),
|
||||||
|
name=r.get("name") or str(r["id"]),
|
||||||
|
extra={
|
||||||
|
"location": r.get("location"),
|
||||||
|
"phone": r.get("phone"),
|
||||||
|
"contact_person": r.get("contact_person"),
|
||||||
|
"category_name": r.get("category_name"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_materials(q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as s:
|
||||||
|
if q:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT id, name, topic, canonical_unit_id, status FROM materials WHERE name ILIKE :q LIMIT 20"),
|
||||||
|
{"q": f"%{q}%"}
|
||||||
|
)).mappings().all()
|
||||||
|
else:
|
||||||
|
rows = (await s.execute(
|
||||||
|
text("SELECT id, name, topic, canonical_unit_id, status FROM materials LIMIT 20")
|
||||||
|
)).mappings().all()
|
||||||
|
return [
|
||||||
|
Candidate(
|
||||||
|
id=str(r["id"]),
|
||||||
|
name=r.get("name") or str(r["id"]),
|
||||||
|
extra=dict(r),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class Resolver:
|
||||||
|
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
|
||||||
|
|
||||||
|
def __init__(self, api: ErpClient, ttl_seconds: float = 1800):
|
||||||
|
self._api = api
|
||||||
|
self._ttl = ttl_seconds
|
||||||
|
self._cache: dict[str, tuple[float, list[Candidate]]] = {}
|
||||||
|
|
||||||
|
async def _cached(self, key: str, fetch) -> list[Candidate]:
|
||||||
|
entry = self._cache.get(key)
|
||||||
|
now = time.monotonic()
|
||||||
|
if entry and now - entry[0] < self._ttl:
|
||||||
|
return entry[1]
|
||||||
|
items = await fetch()
|
||||||
|
self._cache[key] = (now, items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def invalidate(self, key: str | None = None) -> None:
|
||||||
|
if key:
|
||||||
|
self._cache.pop(key, None)
|
||||||
|
else:
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- sources
|
||||||
|
|
||||||
|
async def units(self) -> list[Candidate]:
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_units()
|
||||||
|
return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]),
|
||||||
|
extra={"code": u.get("code")}) for u in rows]
|
||||||
|
except Exception:
|
||||||
|
return await _db_units()
|
||||||
|
return await self._cached("units", fetch)
|
||||||
|
|
||||||
|
async def categories(self) -> list[Candidate]:
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_categories()
|
||||||
|
return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows]
|
||||||
|
except Exception:
|
||||||
|
return await _db_categories()
|
||||||
|
return await self._cached("categories", fetch)
|
||||||
|
|
||||||
|
async def projects(self) -> list[Candidate]:
|
||||||
|
async def fetch():
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_projects()
|
||||||
|
return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows]
|
||||||
|
except Exception:
|
||||||
|
return await _db_projects()
|
||||||
|
return await self._cached("projects", fetch)
|
||||||
|
|
||||||
|
async def suppliers(self, q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_suppliers(q=q, page_size=20)
|
||||||
|
items = [
|
||||||
|
Candidate(
|
||||||
|
id=str(s["id"]),
|
||||||
|
name=s.get("name") or str(s["id"]),
|
||||||
|
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 None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for s in rows
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
items = await _db_suppliers(q=q)
|
||||||
|
return _rank(q, items) if q else items
|
||||||
|
|
||||||
|
async def materials(self, q: str | None = None) -> list[Candidate]:
|
||||||
|
try:
|
||||||
|
rows = await self._api.list_materials(q=q, page_size=20)
|
||||||
|
extra = {str(m["id"]): m for m in rows}
|
||||||
|
items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {}))
|
||||||
|
for m in rows]
|
||||||
|
except Exception:
|
||||||
|
items = await _db_materials(q=q)
|
||||||
|
return _rank(q, items) if q else items
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ resolutions
|
||||||
|
|
||||||
|
async def resolve(self, kind: str, name: str | None) -> Resolution:
|
||||||
|
"""Resolve by name; exact hit → "one", several close → "many", none → "none"."""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return Resolution("none", [])
|
||||||
|
if kind == "supplier":
|
||||||
|
candidates = await self.suppliers(name)
|
||||||
|
elif kind == "material":
|
||||||
|
candidates = await self.materials(name)
|
||||||
|
elif kind == "project":
|
||||||
|
candidates = _rank(name, await self.projects())
|
||||||
|
elif kind == "unit":
|
||||||
|
candidates = _rank(name, await self.units())
|
||||||
|
elif kind == "category":
|
||||||
|
candidates = _rank(name, await self.categories())
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown kind: {kind}")
|
||||||
|
if not candidates:
|
||||||
|
return Resolution("none", [])
|
||||||
|
norm_name = normalize_text(name)
|
||||||
|
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
|
||||||
|
if len(exact) == 1:
|
||||||
|
return Resolution("one", exact)
|
||||||
|
top = candidates[0]
|
||||||
|
top_norm = normalize_text(top.name)
|
||||||
|
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
|
||||||
|
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
|
||||||
|
return Resolution("one", [top])
|
||||||
|
return Resolution("many", candidates)
|
||||||
|
|
||||||
|
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
|
||||||
|
"""Return (unit_id, explicitly_resolved). Falls back to the material's
|
||||||
|
canonical unit when the spoken unit isn't found."""
|
||||||
|
if unit_name:
|
||||||
|
res = await self.resolve("unit", unit_name)
|
||||||
|
if res.resolved:
|
||||||
|
return res.candidates[0].id, True
|
||||||
|
if material and material.get("canonical_unit_id"):
|
||||||
|
return str(material["canonical_unit_id"]), False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution:
|
||||||
|
try:
|
||||||
|
return await resolver.resolve(kind, name)
|
||||||
|
except Exception:
|
||||||
|
return Resolution("none", [])
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Shared runtime: BotContext wiring + per-chat pending interaction flows.
|
||||||
|
|
||||||
|
Pending flows (Casdoor linking, quote picking, accept/arrive confirmations)
|
||||||
|
are short-lived and single-instance, so they live in memory; anything that
|
||||||
|
must survive restarts (drafts, links) lives in postgres.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from app.api.client import ErpClient
|
||||||
|
from app.bale.client import BaleClient
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import BotLink, BotMessage
|
||||||
|
from app.resolve import Resolver
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.runtime")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Links (postgres)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LinkStore:
|
||||||
|
async def get(self, chat_id: int) -> BotLink | None:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
return (await session.execute(
|
||||||
|
select(BotLink).where(BotLink.chat_id == chat_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def upsert(self, chat_id: int, user_id: str | None, username: str | None,
|
||||||
|
display_name: str | None, role: str | None) -> BotLink:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
link = (await session.execute(select(BotLink).where(BotLink.chat_id == chat_id))).scalar_one_or_none()
|
||||||
|
if link is None:
|
||||||
|
link = BotLink(chat_id=chat_id)
|
||||||
|
session.add(link)
|
||||||
|
link.user_id = user_id
|
||||||
|
link.casdoor_username = username
|
||||||
|
link.display_name = display_name
|
||||||
|
link.role = role
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(link)
|
||||||
|
return link
|
||||||
|
|
||||||
|
async def delete(self, chat_id: int) -> bool:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
update(BotLink).where(BotLink.chat_id == chat_id).values(user_id=None, role=None,
|
||||||
|
casdoor_username=None)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return (result.rowcount or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
ROLE_FA = {"pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pending flows (memory) — one per chat
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingFlow:
|
||||||
|
kind: str # link_username | link_password | quote_pick | accept | arrive_qty | arrive_confirm
|
||||||
|
data: dict = field(default_factory=dict)
|
||||||
|
created: float = field(default_factory=time.monotonic)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def age_seconds(self) -> float:
|
||||||
|
return time.monotonic() - self.created
|
||||||
|
|
||||||
|
|
||||||
|
class PendingFlows:
|
||||||
|
TTL_SECONDS = 1800
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._flows: dict[int, PendingFlow] = {}
|
||||||
|
|
||||||
|
def set(self, chat_id: int, kind: str, data: dict | None = None) -> PendingFlow:
|
||||||
|
flow = PendingFlow(kind=kind, data=data or {})
|
||||||
|
self._flows[chat_id] = flow
|
||||||
|
return flow
|
||||||
|
|
||||||
|
def get(self, chat_id: int) -> PendingFlow | None:
|
||||||
|
flow = self._flows.get(chat_id)
|
||||||
|
if flow and flow.age_seconds > self.TTL_SECONDS:
|
||||||
|
self._flows.pop(chat_id, None)
|
||||||
|
return None
|
||||||
|
return flow
|
||||||
|
|
||||||
|
def pop(self, chat_id: int, default=None):
|
||||||
|
return self._flows.pop(chat_id, default)
|
||||||
|
|
||||||
|
def sweep(self) -> None:
|
||||||
|
for chat_id, flow in list(self._flows.items()):
|
||||||
|
if flow.age_seconds > self.TTL_SECONDS:
|
||||||
|
self._flows.pop(chat_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BotContext:
|
||||||
|
def __init__(self, bale: BaleClient, api: ErpClient):
|
||||||
|
self.bale = bale
|
||||||
|
self.api = api
|
||||||
|
self.resolver = Resolver(api)
|
||||||
|
self.links = LinkStore()
|
||||||
|
self.pending = PendingFlows()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_chat_ids(self) -> set[int]:
|
||||||
|
return settings.admin_chat_ids
|
||||||
|
|
||||||
|
async def send(self, chat_id: int, text: str, reply_markup: dict | None = None,
|
||||||
|
reply_to: int | None = None) -> dict:
|
||||||
|
"""Send + audit-log every outbound message."""
|
||||||
|
try:
|
||||||
|
message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup, reply_to=reply_to)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to send to chat %s", chat_id)
|
||||||
|
message = {}
|
||||||
|
await self._log(chat_id, "out", "text", text)
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def edit(self, chat_id: int, message_id: int | None, text: str,
|
||||||
|
reply_markup: dict | None = None) -> bool:
|
||||||
|
if not message_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("failed to edit message %s in chat %s: %s", message_id, chat_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def delete(self, chat_id: int, message_id: int | None) -> bool:
|
||||||
|
if not message_id:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return await self.bale.delete_message(chat_id, message_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to delete message %s in chat %s", message_id, chat_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _log(self, chat_id: int, direction: str, kind: str, body: str | None = None) -> None:
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
session.add(BotMessage(chat_id=chat_id, direction=direction, kind=kind, body=body))
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to log bot message")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LinkedUser:
|
||||||
|
chat_id: int
|
||||||
|
user_id: str | None
|
||||||
|
display_name: str | None
|
||||||
|
role: str | None
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor_id(self) -> str | None:
|
||||||
|
return self.user_id
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Security and token generation for standalone draft review."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - (len(s) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft_token(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||||
|
expires_in: int = 7200,
|
||||||
|
) -> str:
|
||||||
|
"""Generate an HMAC-SHA256 signed token for secure draft access."""
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"draft_id": str(draft_id),
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + expires_in,
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
payload_b64 = _b64url_encode(payload_bytes)
|
||||||
|
|
||||||
|
sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
sig_b64 = _b64url_encode(sig)
|
||||||
|
|
||||||
|
return f"{payload_b64}.{sig_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_draft_token(
|
||||||
|
token: str,
|
||||||
|
secret: str = "procurement-draft-secret-change-in-prod-v7",
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload_b64, sig_b64 = token.split(".", 1)
|
||||||
|
expected_sig = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
actual_sig = _b64url_decode(sig_b64)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_bytes = _b64url_decode(payload_b64)
|
||||||
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None and int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Persistent storage for draft attachments (proforma files)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.storage")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
|
||||||
|
raw = (filename or "proforma.pdf").strip()
|
||||||
|
name = Path(raw).name
|
||||||
|
# Remove control characters and path traversal
|
||||||
|
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
|
||||||
|
clean = clean.strip(". ")
|
||||||
|
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
async def save_proforma_file(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
file_name: str,
|
||||||
|
content: bytes,
|
||||||
|
mime_type: str | None = None,
|
||||||
|
storage_dir: str = "data/attachments/proformas",
|
||||||
|
) -> dict:
|
||||||
|
"""Save proforma bytes permanently to disk.
|
||||||
|
|
||||||
|
Returns dict metadata for persisting in draft payload.
|
||||||
|
"""
|
||||||
|
clean_name = sanitize_filename(file_name)
|
||||||
|
target_dir = Path(storage_dir) / str(draft_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
unique_prefix = uuid4().hex[:8]
|
||||||
|
saved_filename = f"{unique_prefix}_{clean_name}"
|
||||||
|
target_path = target_dir / saved_filename
|
||||||
|
|
||||||
|
# Write bytes to disk
|
||||||
|
target_path.write_bytes(content)
|
||||||
|
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_name": clean_name,
|
||||||
|
"saved_filename": saved_filename,
|
||||||
|
"saved_path": str(target_path.resolve()),
|
||||||
|
"relative_path": str(target_path.as_posix()),
|
||||||
|
"size_bytes": len(content),
|
||||||
|
"mime_type": mime_type or "application/octet-stream",
|
||||||
|
"saved_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
|
||||||
|
|
||||||
|
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
|
||||||
|
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
|
||||||
|
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
|
||||||
|
self-hosted whisper servers exposing the same contract).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.stt")
|
||||||
|
|
||||||
|
_CONVERT_TIMEOUT = 60
|
||||||
|
_STT_TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mp3(raw: bytes, suffix: str) -> bytes:
|
||||||
|
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
src = Path(tmp) / f"in{suffix or '.ogg'}"
|
||||||
|
dst = Path(tmp) / "out.mp3"
|
||||||
|
src.write_bytes(raw)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=_CONVERT_TIMEOUT,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0 or not dst.exists():
|
||||||
|
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
|
||||||
|
return dst.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
async def _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 ""
|
||||||
|
model = provider.get("model") or "whisper-1"
|
||||||
|
language = provider.get("language") or "fa"
|
||||||
|
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
|
||||||
|
|
||||||
|
url = f"{base_url}/audio/transcriptions"
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
files={"file": ("voice.mp3", converted_mp3, "audio/mpeg")},
|
||||||
|
data={"model": model, "language": language},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
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, "text": txt, "model": model}
|
||||||
|
else:
|
||||||
|
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("STT provider %s failed: %s", name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
providers = settings.stt_providers
|
||||||
|
if not providers:
|
||||||
|
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
|
||||||
|
|
||||||
|
suffix = Path(filename or "").suffix
|
||||||
|
if not suffix and mime_type:
|
||||||
|
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
|
||||||
|
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
|
||||||
|
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
|
||||||
|
|
||||||
|
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 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) -> 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)
|
||||||
|
return transcripts[0]["text"] if transcripts else ""
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Background sync loop: watches bot_drafts for updates from Web Wizard or external API.
|
||||||
|
|
||||||
|
If a draft was updated in the Wizard:
|
||||||
|
- If status == 'active': re-renders review text & keyboard in Bale so chat reflects edits.
|
||||||
|
- If status == 'committed': edits Bale message to show it was committed and removes buttons.
|
||||||
|
- If status == 'cancelled': edits Bale message to show it was cancelled and removes buttons.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from app import drafts as draft_store
|
||||||
|
from app.handlers import quote
|
||||||
|
|
||||||
|
logger = logging.getLogger("bot.sync")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_drafts_loop(ctx) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
pending = await draft_store.get_pending_bale_updates()
|
||||||
|
for draft in pending:
|
||||||
|
try:
|
||||||
|
if not draft.review_message_id:
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if draft.status == "committed":
|
||||||
|
payload = draft.payload or {}
|
||||||
|
quote_id = payload.get("committed_quote_id") or ""
|
||||||
|
msg = "✅ این پیشنهاد با موفقیت در سیستم ثبت نهایی شد."
|
||||||
|
if quote_id:
|
||||||
|
msg += f"\nشناسه پیشنهاد: {quote_id}"
|
||||||
|
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
logger.info("Synced committed draft %s to Bale message %s", draft.id, draft.review_message_id)
|
||||||
|
|
||||||
|
elif draft.status == "cancelled":
|
||||||
|
msg = "❌ این پیشنویس لغو شد."
|
||||||
|
await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id)
|
||||||
|
|
||||||
|
elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE:
|
||||||
|
await quote.render(ctx, draft.chat_id, draft, fresh=False)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to sync draft %s to Bale: %s", draft.id, exc)
|
||||||
|
await draft_store.set_bale_updated_at(draft.id)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error in sync_drafts_loop")
|
||||||
|
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"""Persian message templates (the ERP's user-facing layer is Persian)."""
|
||||||
|
import html
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
STATUS_FA = {
|
||||||
|
"offered": "🔵 در انتظار بررسی",
|
||||||
|
"accepted": "🟢 پذیرفتهشده",
|
||||||
|
"arrived": "✅ رسیده",
|
||||||
|
"rejected": "🔴 ردشده",
|
||||||
|
"expired": "⚪️ منقضی",
|
||||||
|
}
|
||||||
|
|
||||||
|
MISSING_MARKS = {True: "✅", False: "❌"}
|
||||||
|
WARNING_MARK = "⚠️"
|
||||||
|
|
||||||
|
|
||||||
|
def esc(value) -> str:
|
||||||
|
return str(value if value is not None and value != "" else "—")
|
||||||
|
|
||||||
|
|
||||||
|
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 fa_num(val, use_fa_digits: bool = True) -> str:
|
||||||
|
"""Format integer/float with thousands separator and Persian digits."""
|
||||||
|
if val is None or val == "":
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
num = float(val)
|
||||||
|
if num.is_integer():
|
||||||
|
s = f"{int(num):,}"
|
||||||
|
else:
|
||||||
|
s = f"{num:,.2f}".rstrip("0").rstrip(".")
|
||||||
|
if use_fa_digits:
|
||||||
|
trans = str.maketrans("0123456789,", "۰۱۲۳۴۵۶۷۸۹٬")
|
||||||
|
return s.translate(trans)
|
||||||
|
return s
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
def fa_digits(val) -> str:
|
||||||
|
"""Convert ASCII digits to Persian digits without thousands separators."""
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
|
return str(val).translate(trans)
|
||||||
|
|
||||||
|
|
||||||
|
def fa_date(value, use_fa_digits: bool = True) -> str:
|
||||||
|
if not value:
|
||||||
|
return "—"
|
||||||
|
s = str(value)[:10]
|
||||||
|
try:
|
||||||
|
parts = [int(p) for p in s.replace("/", "-").split("-")]
|
||||||
|
if len(parts) == 3 and parts[0] > 1900:
|
||||||
|
jy, jm, jd = g2j(parts[0], parts[1], parts[2])
|
||||||
|
res = f"{jy:04d}/{jm:02d}/{jd:02d}"
|
||||||
|
if use_fa_digits:
|
||||||
|
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
|
return res.translate(trans)
|
||||||
|
return res
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if use_fa_digits:
|
||||||
|
trans = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
|
return s.translate(trans)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
WELCOME = (
|
||||||
|
"سلام! 👋 من دستیار تدارکات هستم.\n\n"
|
||||||
|
"میتوانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n"
|
||||||
|
"«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n"
|
||||||
|
"من هر چه را فهمیدم بهصورت پیشنویس آماده میکنم، چیزی که فهمیدم و چیزهای خالی را نشان "
|
||||||
|
"میدهم و فقط با تأیید تو در سیستم ثبت میکنم.\n\n"
|
||||||
|
"دستورها:\n"
|
||||||
|
"/drafts — پیشنویسهای باز\n"
|
||||||
|
"/cancel — لغو پیشنویس/عملیات جاری\n"
|
||||||
|
"/whoami — اتصال من\n"
|
||||||
|
"/unlink — قطع اتصال\n"
|
||||||
|
"/help — راهنما"
|
||||||
|
)
|
||||||
|
|
||||||
|
HELP = (
|
||||||
|
"چه کارهایی میتوانم بکنم؟\n\n"
|
||||||
|
"🧾 ثبت استعلام — تأمینکننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمیآورم.\n"
|
||||||
|
"🏢 افزودن تأمینکننده — «تأمینکننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n"
|
||||||
|
"📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n"
|
||||||
|
"📊 پرسوجو — «استعلامهای در انتظار؟»، «کدامها بهزودی منقضی میشوند؟»\n"
|
||||||
|
"✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n"
|
||||||
|
"🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n"
|
||||||
|
"هر ثبت فقط با دکمه ✅ انجام میشود. با /cancel هر چیز نیمهکاره را لغو کن."
|
||||||
|
)
|
||||||
|
|
||||||
|
LINK_START = (
|
||||||
|
"برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست "
|
||||||
|
"(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel"
|
||||||
|
)
|
||||||
|
LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمیشود). /cancel برای لغو"
|
||||||
|
LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا میتوانی فعالیتها را بفرستی. /help را ببین."
|
||||||
|
LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست."
|
||||||
|
ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبتها بدون انتساب کاربر انجام میشود."
|
||||||
|
|
||||||
|
DRAFTS_LIST_HEADER = "📋 پیشنویسهای باز:"
|
||||||
|
DRAFTS_EMPTY = "پیشنویس بازی نداری. یک فعالیت را با متن یا وویس بفرست."
|
||||||
|
NOTHING_TO_COMMIT = "پیشنویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست."
|
||||||
|
CANCELLED = "لغو شد. ✔️"
|
||||||
|
UNKNOWN_HINT = (
|
||||||
|
"متوجه منظورت نشد 🤔\n"
|
||||||
|
"میتوانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی."
|
||||||
|
)
|
||||||
|
UNLINKED = "ابتدا حساب خود را متصل کن: /start"
|
||||||
|
AI_NOT_CONFIGURED = (
|
||||||
|
"پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). "
|
||||||
|
"با مدیر سیستم تماس بگیر."
|
||||||
|
)
|
||||||
|
STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}"
|
||||||
|
VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست."
|
||||||
|
ERR_GENERIC = "خطا: {detail}"
|
||||||
|
PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را میخواهی؟"
|
||||||
|
QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم."
|
||||||
|
ACCEPT_REPORT = (
|
||||||
|
"✅ پذیرش انجام شد\n"
|
||||||
|
"استعلام #{id}\n"
|
||||||
|
"وضعیت: {status}\n"
|
||||||
|
"اقلام تصمیمگیریشده: {count}"
|
||||||
|
)
|
||||||
|
ARRIVE_REPORT = (
|
||||||
|
"🚚 ثبت رسید انجام شد\n"
|
||||||
|
"استعلام #{id} — وضعیت: {status}\n{lines}"
|
||||||
|
)
|
||||||
|
ACCEPT_DONE_TS = "شرایط انتخابشده: {label}"
|
||||||
|
|
||||||
|
|
||||||
|
def missing_block(missing: list[dict]) -> str:
|
||||||
|
"""Checklist of missing vs. captured fields."""
|
||||||
|
if not missing:
|
||||||
|
return "همه فیلدها کامل است ✅"
|
||||||
|
lines = []
|
||||||
|
for m in missing:
|
||||||
|
if m.get("ok"):
|
||||||
|
mark = "✅"
|
||||||
|
elif m.get("blocking"):
|
||||||
|
mark = "❌"
|
||||||
|
else:
|
||||||
|
mark = "⚠️"
|
||||||
|
lines.append(f"{mark} {m.get('label')}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str:
|
||||||
|
lines = ["📋 پیشنویس استعلام"]
|
||||||
|
|
||||||
|
has_blocking = any(m.get("blocking") and not m.get("ok") for m in missing)
|
||||||
|
has_warnings = any(not m.get("blocking") and not m.get("ok") for m in missing)
|
||||||
|
|
||||||
|
if not has_blocking and not has_warnings:
|
||||||
|
lines.append("✅ همهی اطلاعات کامل است، آماده ثبت.")
|
||||||
|
elif has_blocking:
|
||||||
|
lines.append("⛔️ به دلیل وارد نکردن موارد اجباری (❌) امکان ثبت نهایی وجود ندارد.")
|
||||||
|
else:
|
||||||
|
lines.append("⚠️ پیشنویس ناقص است (موارد اختیاری خالی است)، اما امکان ثبت وجود دارد.")
|
||||||
|
|
||||||
|
if status_note:
|
||||||
|
lines.append(status_note)
|
||||||
|
|
||||||
|
lines.append("━━━━━━━━━━━━━━\n")
|
||||||
|
|
||||||
|
# 🏢 تأمینکننده
|
||||||
|
lines.append("🏢 تأمینکننده")
|
||||||
|
sup_name = payload.get("supplier_name")
|
||||||
|
is_official = payload.get("is_official", True)
|
||||||
|
official_badge = " (رسمی)" if is_official else " (غیررسمی)"
|
||||||
|
|
||||||
|
if payload.get("supplier_id"):
|
||||||
|
lines.append(f"{sup_name}{official_badge} ✅")
|
||||||
|
if payload.get("supplier_location"):
|
||||||
|
lines.append(f"📍 {payload['supplier_location']}")
|
||||||
|
contact_parts = []
|
||||||
|
if payload.get("supplier_phone"):
|
||||||
|
contact_parts.append(fa_digits(payload["supplier_phone"]))
|
||||||
|
if payload.get("supplier_contact_person"):
|
||||||
|
contact_parts.append(str(payload["supplier_contact_person"]))
|
||||||
|
if contact_parts:
|
||||||
|
lines.append(f"📞 {' — '.join(contact_parts)}")
|
||||||
|
elif sup_name:
|
||||||
|
lines.append(f"{sup_name}{official_badge} ✨ (تأمینکننده جدید)")
|
||||||
|
else:
|
||||||
|
lines.append("— (مشخص نشده)")
|
||||||
|
|
||||||
|
cat_name = payload.get("supplier_category_name")
|
||||||
|
if cat_name:
|
||||||
|
lines.append(f"\n🏷️ موضوع: {cat_name}")
|
||||||
|
|
||||||
|
# 📦 اقلام
|
||||||
|
items = payload.get("items") or []
|
||||||
|
total_amount = 0.0
|
||||||
|
has_any_price = False
|
||||||
|
|
||||||
|
if items:
|
||||||
|
lines.append(f"\n📦 اقلام ({fa_num(len(items))})")
|
||||||
|
default_proj = payload.get("project_name")
|
||||||
|
for i, it in enumerate(items, start=1):
|
||||||
|
name = it.get("material_name") or f"قلم {i}"
|
||||||
|
title_parts = [str(name)]
|
||||||
|
if it.get("brand_name"):
|
||||||
|
title_parts.append(str(it["brand_name"]))
|
||||||
|
if it.get("specs"):
|
||||||
|
title_parts.append("، ".join(str(s) for s in it["specs"]))
|
||||||
|
if not it.get("material_id"):
|
||||||
|
title_parts.append("✨ (کالای جدید)")
|
||||||
|
lines.append(f"{fa_num(i)}. {' — '.join(title_parts)}")
|
||||||
|
|
||||||
|
# Project
|
||||||
|
item_proj = it.get("project_name") or default_proj
|
||||||
|
if item_proj:
|
||||||
|
lines.append(f" 🏗️ {item_proj}")
|
||||||
|
else:
|
||||||
|
lines.append(" 🏗️ پروژه مشخص نشده")
|
||||||
|
|
||||||
|
# Qty, Unit, Price, Total
|
||||||
|
qty = it.get("quantity")
|
||||||
|
unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None)
|
||||||
|
unit_price = it.get("unit_price")
|
||||||
|
|
||||||
|
calc_line = " "
|
||||||
|
if qty is not None:
|
||||||
|
calc_line += f"{fa_num(qty)} {unit or ''}".strip()
|
||||||
|
else:
|
||||||
|
calc_line += "مقدار نامشخص"
|
||||||
|
|
||||||
|
if unit_price:
|
||||||
|
has_any_price = True
|
||||||
|
calc_line += f" × {fa_num(unit_price)}"
|
||||||
|
if qty is not None:
|
||||||
|
try:
|
||||||
|
line_sum = float(qty) * float(unit_price)
|
||||||
|
total_amount += line_sum
|
||||||
|
calc_line += f" = {fa_num(line_sum)} تومان"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
calc_line += " تومان"
|
||||||
|
else:
|
||||||
|
calc_line += " تومان"
|
||||||
|
else:
|
||||||
|
calc_line += " × — = — تومان"
|
||||||
|
|
||||||
|
lines.append(calc_line)
|
||||||
|
|
||||||
|
if has_any_price and total_amount > 0:
|
||||||
|
lines.append(f"\n💰 مجموع مبلغ: {fa_num(total_amount)} تومان")
|
||||||
|
elif not has_any_price:
|
||||||
|
lines.append("\n💰 مجموع مبلغ: محاسبهنشده (قیمت اقلام وارد نشده)")
|
||||||
|
else:
|
||||||
|
lines.append("\n📦 اقلام: هنوز چیزی ثبت نشده ❌")
|
||||||
|
|
||||||
|
# 💳 شرایط پرداخت
|
||||||
|
sheets = payload.get("termsheets") or []
|
||||||
|
if sheets:
|
||||||
|
lines.append(f"\n💳 شرایط پرداخت ({fa_num(len(sheets))})")
|
||||||
|
for s_idx, t in enumerate(sheets, start=1):
|
||||||
|
lbl = t.get("label") or f"گزینه {s_idx}"
|
||||||
|
milestones = t.get("milestones") or t.get("schedule_steps") or []
|
||||||
|
if milestones:
|
||||||
|
lines.append(f"▫️ {lbl} — {fa_num(len(milestones))} مرحله")
|
||||||
|
step_sum = 0.0
|
||||||
|
for m in milestones:
|
||||||
|
due = fa_date(m.get("due_date"))
|
||||||
|
amt = m.get("amount")
|
||||||
|
amt_str = f"{fa_num(amt)} تومان" if amt else "مبلغ توافقی"
|
||||||
|
if amt:
|
||||||
|
try:
|
||||||
|
step_sum += float(amt)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
note = f" ({m['note']})" if m.get("note") else ""
|
||||||
|
lines.append(f" • {due} — {amt_str}{note}")
|
||||||
|
|
||||||
|
if total_amount > 0 and step_sum > 0:
|
||||||
|
diff = abs(total_amount - step_sum)
|
||||||
|
if diff < 1.0:
|
||||||
|
lines.append(" ✅ جمع مراحل با مجموع مبلغ برابر است")
|
||||||
|
else:
|
||||||
|
lines.append(f" ⚠️ اختلاف جمع مراحل با مجموع فاکتور: {fa_num(diff)} تومان")
|
||||||
|
if len(items) > 0:
|
||||||
|
lines.append(f" شامل: هر {fa_num(len(items))} قلم")
|
||||||
|
else:
|
||||||
|
disc = ""
|
||||||
|
if t.get("discount_value"):
|
||||||
|
disc = f" (تخفیف {fa_num(t['discount_value'])})"
|
||||||
|
lines.append(f"▫️ {lbl}{disc}")
|
||||||
|
elif items:
|
||||||
|
lines.append("\n💳 شرایط پرداخت: نقدی / توافقی (پیشفرض)")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
meta_lines = []
|
||||||
|
if payload.get("quote_date"):
|
||||||
|
meta_lines.append(f"🗓️ تاریخ: {fa_date(payload['quote_date'])}")
|
||||||
|
if payload.get("expiry_date"):
|
||||||
|
meta_lines.append(f"⏳ اعتبار: {fa_date(payload['expiry_date'])}")
|
||||||
|
if payload.get("proforma_file"):
|
||||||
|
meta_lines.append(f"📎 پیشفاکتور: {payload['proforma_file']}")
|
||||||
|
if payload.get("notes"):
|
||||||
|
meta_lines.append(f"📝 توضیحات: {payload['notes']}")
|
||||||
|
|
||||||
|
if meta_lines:
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(meta_lines)
|
||||||
|
|
||||||
|
# 📋 وضعیت فیلدها (اگر نقصی وجود داشته باشد)
|
||||||
|
if has_blocking or has_warnings:
|
||||||
|
lines.append("\n📋 وضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
lines.append("\n━━━━━━━━━━━━━━")
|
||||||
|
if has_blocking:
|
||||||
|
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||||
|
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||||
|
elif has_warnings:
|
||||||
|
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||||
|
lines.append("میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||||
|
else:
|
||||||
|
lines.append("برای تغییر، پیام بفرست (مثلاً «قیمت قلم ۲ شد ۴۶٬۰۰۰»).")
|
||||||
|
lines.append("با پیام بعدی میتوانی چیزهای خالی را تکمیل کنی، یا با دکمهها ثبت/لغو کنی.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def supplier_review_text(payload: dict, missing: list[dict]) -> str:
|
||||||
|
lines = ["🏢 پیشنویس تأمینکننده جدید", f"نام: {esc(payload.get('name'))}"]
|
||||||
|
lines.append(f"دسته: {esc(payload.get('category_name'))}")
|
||||||
|
if payload.get("phone"):
|
||||||
|
lines.append(f"تلفن: {esc(payload['phone'])}")
|
||||||
|
if payload.get("location"):
|
||||||
|
lines.append(f"موقعیت: {esc(payload['location'])}")
|
||||||
|
if payload.get("contact_person"):
|
||||||
|
lines.append(f"رابط: {esc(payload['contact_person'])}")
|
||||||
|
lines.append("\nوضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def material_review_text(payload: dict, missing: list[dict]) -> str:
|
||||||
|
lines = ["📦 پیشنویس کالای جدید", f"نام: {esc(payload.get('name'))}"]
|
||||||
|
lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}")
|
||||||
|
if payload.get("topic"):
|
||||||
|
lines.append(f"تاپیک: {esc(payload['topic'])}")
|
||||||
|
lines.append("\nوضعیت فیلدها:")
|
||||||
|
lines.append(missing_block(missing))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
lines.append(f"تأمینکننده: {esc(sup)}")
|
||||||
|
items = created.get("line_items") or []
|
||||||
|
if items:
|
||||||
|
lines.append(f"اقلام: {len(items)}")
|
||||||
|
sheets = created.get("termsheets") or []
|
||||||
|
if sheets:
|
||||||
|
lines.append(f"شرایط پرداخت: {len(sheets)}")
|
||||||
|
if payload.get("project_id"):
|
||||||
|
lines.append("پروژه: پیوست شد")
|
||||||
|
if remaining:
|
||||||
|
lines.append("\nخالی ماندهها (بعداً میتوانی در برنامه تکمیل کنی):")
|
||||||
|
lines += [f"⚠️ {esc(r)}" for r in remaining]
|
||||||
|
lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def supplier_committed_report(created: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"✅ تأمینکننده ثبت شد — {esc(created.get('name'))}\n"
|
||||||
|
f"دسته: {esc((created.get('category') or {}).get('name'))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def material_committed_report(created: dict) -> str:
|
||||||
|
return f"✅ کالا ثبت شد — {esc(created.get('name'))}"
|
||||||
|
|
||||||
|
|
||||||
|
def quote_line(quote: dict) -> str:
|
||||||
|
sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—"
|
||||||
|
items = quote.get("line_items") or []
|
||||||
|
item_names = []
|
||||||
|
for it in items:
|
||||||
|
mat = (it.get("material") or {}).get("name")
|
||||||
|
if mat:
|
||||||
|
qty = it.get("quantity")
|
||||||
|
item_names.append(f"{mat} ({qty})" if qty else mat)
|
||||||
|
tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else ""
|
||||||
|
return (
|
||||||
|
f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — "
|
||||||
|
f"{fa_date(quote.get('quote_date'))}{tail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str:
|
||||||
|
titles = {
|
||||||
|
"pending": "🔵 استعلامهای در انتظار بررسی",
|
||||||
|
"accepted": "🟢 پذیرفتهشده (در انتظار تحویل)",
|
||||||
|
"arrived": "✅ رسیده",
|
||||||
|
"expiring": "⏳ بهزودی منقضی میشوند (۷ روز آینده)",
|
||||||
|
"recent": "🕘 آخرین استعلامها",
|
||||||
|
}
|
||||||
|
if not quotes:
|
||||||
|
return f"{titles.get(kind, 'نتیجهای')}\nموردی نیست."
|
||||||
|
lines = [titles.get(kind, "استعلامها")]
|
||||||
|
if extra_note:
|
||||||
|
lines.append(extra_note)
|
||||||
|
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Setup & Operations Guide
|
||||||
|
|
||||||
|
A Bale messenger bot that turns Persian **chat messages and voice notes** into
|
||||||
|
procurement actions in the ERP. It works **draft-first**: it captures what it
|
||||||
|
understood, tells you exactly **what it did** and **what is still empty**, and
|
||||||
|
**commits only after you approve** with a ✅ button.
|
||||||
|
|
||||||
|
Bale's Bot API is a Telegram-compatible clone hosted at `tapi.bale.ai`, so the
|
||||||
|
bot uses the familiar `getUpdates` long-polling — **no public port, no webhook,
|
||||||
|
no TLS setup**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it can do (v1)
|
||||||
|
|
||||||
|
| Activity | Example (voice or text) | Committed via |
|
||||||
|
|---|---|---|
|
||||||
|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` (nested create) |
|
||||||
|
| Add a supplier | «تأمینکننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
|
||||||
|
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
|
||||||
|
| Status queries | «استعلامهای در انتظار؟» / «کدامها بهزودی منقضی میشوند؟» | `GET /quotes?…` |
|
||||||
|
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
|
||||||
|
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
|
||||||
|
|
||||||
|
### The draft → approve loop (the core idea)
|
||||||
|
|
||||||
|
1. You send a message (or voice note). The bot converts voice to text
|
||||||
|
(STT) and extracts an **intent + entities** (LLM).
|
||||||
|
2. Names are fuzzy-matched against the ERP (supplier / material / unit /
|
||||||
|
project). Multiple candidates → inline **pick buttons**; no match → it is
|
||||||
|
listed as missing.
|
||||||
|
3. The bot replies with a **review card**: everything it captured, plus a
|
||||||
|
✅/⚠️/❌ **list of what is still empty** («یادآوری چیزهای خالی»).
|
||||||
|
4. You keep chatting to refine («قیمت میلگرد ۴۷ شد», «پروژه برج آسمان») — the
|
||||||
|
open draft is updated and re-rendered.
|
||||||
|
5. **Nothing is written to the ERP until you tap ✅ ثبت نهایی.** Partial
|
||||||
|
commits are possible («ثبت فقط اقلام مشخص») because the API deliberately
|
||||||
|
tolerates incomplete quotes. After commit you get the created quote id and
|
||||||
|
the list of fields that were left empty.
|
||||||
|
|
||||||
|
### Team access & attribution
|
||||||
|
|
||||||
|
- Every user links their Bale chat to their ERP account once: `/start` →
|
||||||
|
Casdoor **username** → **password** (validated through the real
|
||||||
|
`POST /auth/login`; the password is used once and never stored).
|
||||||
|
- Mutating API calls carry `X-Actor-Id: <users.id>` so `quote_events.actor`
|
||||||
|
records the **human** who asked, not the bot. (The bot's container talks to
|
||||||
|
the api in-network: `http://api:8000/api/v1`.)
|
||||||
|
- `BALE_ADMIN_CHAT_IDS` may list chat ids that bypass linking.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
`/start` · `/help` · `/drafts` (open drafts) · `/cancel` (abort draft/flow) ·
|
||||||
|
`/whoami` · `/unlink`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Create the bot** in Bale's BotFather and copy the token.
|
||||||
|
2. **Configure an AI provider** — any OpenAI-compatible endpoint works for
|
||||||
|
both STT (`POST {base}/audio/transcriptions`, Persian) and the LLM
|
||||||
|
(`POST {base}/chat/completions`, JSON mode). Examples: OpenAI `whisper-1`
|
||||||
|
+ `gpt-4o-mini`, or any proxy/provider you already use.
|
||||||
|
3. **Compose** (dev):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# env passed at compose level (or a .env next to docker-compose.yml)
|
||||||
|
$env:BALE_BOT_TOKEN = "<token>"
|
||||||
|
$env:AI_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
$env:AI_API_KEY = "<key>"
|
||||||
|
|
||||||
|
docker compose up -d --build bot
|
||||||
|
docker compose logs -f bot
|
||||||
|
```
|
||||||
|
|
||||||
|
For Coolify (`docker-compose.coolify.yml`) set the same variables as
|
||||||
|
service-level env vars; the bot container needs no domain/labels.
|
||||||
|
|
||||||
|
### Environment variables (prefix `PROCUREMENT_BOT_`)
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `BOT_TOKEN` | — (required) | token from Bale's BotFather |
|
||||||
|
| `BALE_API_BASE` | `https://tapi.bale.ai` | Bot API base (overridable for tests) |
|
||||||
|
| `API_BASE_URL` | `http://localhost:8000/api/v1` | ERP api (in Docker: `http://api:8000/api/v1`) |
|
||||||
|
| `DATABASE_URL` | same postgres as the api | bot's own tables (`bot_*`) |
|
||||||
|
| `AI_BASE_URL` / `AI_API_KEY` | — | OpenAI-compatible STT+LLM provider |
|
||||||
|
| `STT_MODEL` / `LLM_MODEL` | `whisper-1` / `gpt-4o-mini` | model names at the provider |
|
||||||
|
| `ADMIN_CHAT_IDS` | — | comma-separated Bale chat ids allowed without linking |
|
||||||
|
| `SERVICE_TOKEN` | — | optional bearer for the day procurement endpoints enforce auth |
|
||||||
|
| `DRAFT_TTL_HOURS` | `24` | drafts older than this are marked expired |
|
||||||
|
|
||||||
|
In compose these are fed from `BALE_BOT_TOKEN`, `BALE_API_BASE`, `AI_BASE_URL`,
|
||||||
|
`AI_API_KEY`, `AI_STT_MODEL`, `AI_LLM_MODEL`, `BALE_ADMIN_CHAT_IDS`,
|
||||||
|
`BALE_SERVICE_TOKEN`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it's built
|
||||||
|
|
||||||
|
```
|
||||||
|
bot/
|
||||||
|
├── Dockerfile python:3.13-slim + ffmpeg (voice conversion)
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py ensure tables → sweeper task → poll loop
|
||||||
|
│ ├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
|
||||||
|
│ ├── bale/client.py getUpdates/sendMessage/edit/callbacks/getFile+download
|
||||||
|
│ ├── api/client.py ERP /api/v1 client (X-Actor-Id attribution)
|
||||||
|
│ ├── stt.py ffmpeg (OGG/Opus → MP3) → /audio/transcriptions
|
||||||
|
│ ├── nlu.py Persian intent+entity extraction (JSON mode)
|
||||||
|
│ ├── resolve.py fuzzy name→id matching (difflib + ilike search)
|
||||||
|
│ ├── drafts.py draft store + QuoteCreate payload builder + missing-field logic
|
||||||
|
│ ├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
|
||||||
|
│ ├── texts.py Persian message templates
|
||||||
|
│ ├── runtime.py context, link store, in-memory pending flows
|
||||||
|
│ ├── models.py / db_init.py bot_links / bot_drafts / bot_messages / bot_meta (idempotent DDL)
|
||||||
|
│ └── handlers/ dispatch, commands, callbacks, links,
|
||||||
|
│ quote, supplier, material, queries, decisions
|
||||||
|
└── tools/smoke_test.py offline end-to-end harness (fake Bale/ERP/AI, in-memory store)
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
|
||||||
|
- **Draft state lives in postgres** (`bot_drafts.payload` JSONB in the shape of
|
||||||
|
the eventual API request), so drafts survive bot restarts; short-lived
|
||||||
|
interactions (linking, quote picking, accept/arrive confirmations) are
|
||||||
|
in-memory with a 30-minute TTL.
|
||||||
|
- **Callback grammar** fits Bale's 64-byte `callback_data`:
|
||||||
|
`q:ok:{draft8}` (commit), `pk:{draft8}:{field}:{idx}` (pick candidate),
|
||||||
|
`ac:ts:{i}` (choose payment terms), `ar:full` / `ar:ok` (delivery)…
|
||||||
|
- **Persian handling**: Persian/Arabic digits are normalized before parsing;
|
||||||
|
fuzzy matching folds ZWNJ and Arabic ya/ka; all user-facing text is Persian.
|
||||||
|
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3
|
||||||
|
before hitting the STT endpoint (accepted by essentially all providers).
|
||||||
|
- The **accept flow echoes the termsheet's existing `schedule_steps`** back to
|
||||||
|
`PATCH /quotes/{id}/accept` (the API replaces steps with what you send, so
|
||||||
|
the bot must send the current ones).
|
||||||
|
|
||||||
|
### Attribution tweak in the api (backward compatible)
|
||||||
|
|
||||||
|
`backend/app/utils/security.py::get_actor` now prefers an `X-Actor-Id` header
|
||||||
|
over the raw bearer token when attributing `quote_events.actor`. Requests
|
||||||
|
without that header behave exactly as before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Offline harness (no Bale token, no DB, no AI key — stubs everything and drives
|
||||||
|
the real handler pipeline with fake updates):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose run --rm bot python tools/smoke_test.py
|
||||||
|
# or, with the requirements installed locally:
|
||||||
|
cd bot && python tools/smoke_test.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers: linking, voice→draft, ambiguous-material pick buttons, unit fallback,
|
||||||
|
draft refinement, commit payload shape (temp_id cross-references), supplier /
|
||||||
|
material commits, status digest, accept payload (steps echoed, per-item price),
|
||||||
|
arrival quantities («۵۰۰ و ۲۰۰»), cancel and unknown-intent handling.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Likely cause |
|
||||||
|
|---|---|
|
||||||
|
| Bot silent | wrong `BALE_BOT_TOKEN`; check `docker compose logs bot` |
|
||||||
|
| «پردازش هوشمند تنظیم نشده است» | `AI_BASE_URL` / `AI_API_KEY` missing |
|
||||||
|
| Voice → «نتوانستم پیام صوتی را تبدیل کنم» | provider rejected the model name (`STT_MODEL`) or key |
|
||||||
|
| «ERP API unreachable» | api container not up / wrong `API_BASE_URL` |
|
||||||
|
| Draft disappears | TTL expiry (`DRAFT_TTL_HOURS`) or someone pressed ❌ / `/cancel` |
|
||||||
Binary file not shown.
@@ -0,0 +1,44 @@
|
|||||||
|
# ERP integration pack
|
||||||
|
|
||||||
|
The agent is a **standalone container** — it talks to the ERP exclusively over
|
||||||
|
HTTP and keeps its own state in its own `bot_*` tables. Integrating it into the
|
||||||
|
[ERP stack](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) needs exactly
|
||||||
|
two things:
|
||||||
|
|
||||||
|
## 1. Compose service
|
||||||
|
|
||||||
|
Add [`erp-compose-service.yml`](erp-compose-service.yml) to the ERP's
|
||||||
|
`docker-compose.yml` (and the Coolify variant). The bot long-polls Bale, so it
|
||||||
|
needs **no ports, no webhook, no TLS** — it joins the compose `default` network
|
||||||
|
and reaches `http://api:8000/api/v1` in-network.
|
||||||
|
|
||||||
|
Credentials/env are passed at compose level from a `.env` next to the compose
|
||||||
|
file: `BALE_BOT_TOKEN`, `AI_BASE_URL`, `AI_API_KEY`, `AI_LLM_MODEL`
|
||||||
|
(see [`../.env.example`](../.env.example) for the full list).
|
||||||
|
|
||||||
|
> If your stack is started with multiple `-f` files (e.g. a prod_v2 checkout +
|
||||||
|
> port-remap override), keep using the same chain — the `bot` service merges in
|
||||||
|
> like any other service and the override's host-port remaps don't affect it.
|
||||||
|
|
||||||
|
## 2. Per-user attribution in the API
|
||||||
|
|
||||||
|
Apply [`x-actor-id.patch`](x-actor-id.patch) — a ~6-line, fully
|
||||||
|
backward-compatible change to `backend/app/utils/security.py`:
|
||||||
|
|
||||||
|
- before: `get_actor` returns the raw `Authorization: Bearer …` string and
|
||||||
|
`quote_events.actor` stores the bot's token;
|
||||||
|
- after: an `X-Actor-Id: <users.id>` header (sent by the bot for every mutating
|
||||||
|
call, carrying the **linked ERP user's id**) takes precedence, so the audit
|
||||||
|
trail records the human who asked, not the bot.
|
||||||
|
|
||||||
|
Requests without the header behave exactly as before.
|
||||||
|
|
||||||
|
## What the agent does NOT touch
|
||||||
|
|
||||||
|
- No ERP code is imported by the bot (HTTP-only, own postgres tables
|
||||||
|
`bot_links` / `bot_drafts` / `bot_messages` / `bot_meta`, created idempotently
|
||||||
|
at startup with `CREATE TABLE IF NOT EXISTS`).
|
||||||
|
- No Alembic migration needed — the bot owns its schema.
|
||||||
|
- The ERP's data model stays untouched; committed drafts flow through the
|
||||||
|
ordinary public endpoints (`POST /quotes`, `PATCH /quotes/{id}/accept`,
|
||||||
|
`PATCH /quotes/{id}/arrive-items`, `POST /suppliers`, `POST /materials`).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# ==========================================================================
|
||||||
|
# Add this service to the ERP stack's docker-compose.yml (and mirror it in
|
||||||
|
# docker-compose.coolify.yml with `container_name: procurement-bot`).
|
||||||
|
#
|
||||||
|
# It expects THIS repository checked out (or vendored) next to the ERP repo
|
||||||
|
# as `./bot` — either `git clone … bot` inside the ERP checkout, or change
|
||||||
|
# `context:` to an absolute/relative path to this repo.
|
||||||
|
#
|
||||||
|
# In-container URLs are unaffected by host port remaps (postgres/api are
|
||||||
|
# reached over the compose network, not host ports).
|
||||||
|
# ==========================================================================
|
||||||
|
services:
|
||||||
|
bot:
|
||||||
|
build:
|
||||||
|
context: ./bot # ← path to this repository
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: procurement-bot
|
||||||
|
|
||||||
|
environment:
|
||||||
|
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-}
|
||||||
|
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
|
||||||
|
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
|
||||||
|
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||||
|
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-}
|
||||||
|
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-}
|
||||||
|
PROCUREMENT_BOT_STT_MODEL: ${AI_STT_MODEL:-whisper-1}
|
||||||
|
PROCUREMENT_BOT_STT_BASE_URL: ${AI_STT_BASE_URL:-}
|
||||||
|
PROCUREMENT_BOT_STT_API_KEY: ${AI_STT_API_KEY:-}
|
||||||
|
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-gpt-4o-mini}
|
||||||
|
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-}
|
||||||
|
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py
|
||||||
|
index a78c1cb..75e7349 100644
|
||||||
|
--- a/backend/app/utils/security.py
|
||||||
|
+++ b/backend/app/utils/security.py
|
||||||
|
@@ -7,7 +7,13 @@ def get_actor(request: Request) -> str | None:
|
||||||
|
|
||||||
|
This is the reserved placeholder from the contract — no authentication
|
||||||
|
or authorization is enforced yet.
|
||||||
|
+
|
||||||
|
+ Services acting on behalf of a user (the Bale bot) pass ``X-Actor-Id``
|
||||||
|
+ (users.id); it wins over the raw token so the audit trail shows the human.
|
||||||
|
"""
|
||||||
|
+ actor_id = request.headers.get("X-Actor-Id")
|
||||||
|
+ if actor_id:
|
||||||
|
+ return actor_id.strip()
|
||||||
|
auth = request.headers.get("Authorization")
|
||||||
|
if auth and auth.lower().startswith("bearer "):
|
||||||
|
return auth[7:].strip() or None
|
||||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
httpx>=0.27.0
|
||||||
|
sqlalchemy[asyncio]>=2.0.30
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
pydantic>=2.7.0
|
||||||
|
pydantic-settings>=2.2.0
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""Offline smoke test for the Bale bot — no Bale network, no postgres, no AI keys.
|
||||||
|
|
||||||
|
It stubs the Bale client, the ERP API client, STT and the NLU module, replaces
|
||||||
|
the draft store with an in-memory fake, then drives the real handler pipeline
|
||||||
|
end-to-end through fake updates (text, voice, inline-button callbacks).
|
||||||
|
|
||||||
|
Run inside the bot container (or anywhere the requirements are installed):
|
||||||
|
|
||||||
|
python tools/smoke_test.py
|
||||||
|
|
||||||
|
Exit code 0 = all checks passed.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
# asyncpg is only needed when a real connection opens; stub it so app.db imports.
|
||||||
|
try:
|
||||||
|
import asyncpg # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
|
||||||
|
|
||||||
|
CHAT = 100200300
|
||||||
|
|
||||||
|
_passed = _failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||||
|
global _passed, _failed
|
||||||
|
ok = bool(ok)
|
||||||
|
mark = "PASS" if ok else "FAIL"
|
||||||
|
print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else ""))
|
||||||
|
_passed += ok
|
||||||
|
_failed += (not ok)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fakes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FakeBale:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent: list[dict] = []
|
||||||
|
self.edited: list[dict] = []
|
||||||
|
self.answered: list[str] = []
|
||||||
|
self._mid = 0
|
||||||
|
|
||||||
|
async def send_message(self, chat_id, text, reply_markup=None, reply_to=None):
|
||||||
|
self._mid += 1
|
||||||
|
self.sent.append({"chat_id": chat_id, "text": text, "reply_markup": reply_markup})
|
||||||
|
return {"message_id": self._mid, "chat": {"id": chat_id}}
|
||||||
|
|
||||||
|
async def edit_message_text(self, chat_id, message_id, text, reply_markup=None):
|
||||||
|
self.edited.append({"chat_id": chat_id, "message_id": message_id, "text": text,
|
||||||
|
"reply_markup": reply_markup})
|
||||||
|
return {"message_id": message_id}
|
||||||
|
|
||||||
|
async def answer_callback_query(self, callback_query_id, text=None):
|
||||||
|
self.answered.append(callback_query_id)
|
||||||
|
|
||||||
|
async def send_chat_action(self, chat_id, action="typing"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def download_file(self, file_id):
|
||||||
|
return b"fake-ogg-bytes"
|
||||||
|
|
||||||
|
def last_text(self) -> str:
|
||||||
|
return self.sent[-1]["text"] if self.sent else ""
|
||||||
|
|
||||||
|
def last_markup(self) -> dict | None:
|
||||||
|
return self.sent[-1]["reply_markup"] if self.sent else None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApi:
|
||||||
|
"""Canned ERP responses + captured mutation payloads."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.created_quotes: list[dict] = []
|
||||||
|
self.created_suppliers: list[dict] = []
|
||||||
|
self.created_materials: list[dict] = []
|
||||||
|
self.accept_payloads: list[dict] = []
|
||||||
|
self.arrive_payloads: list[dict] = []
|
||||||
|
self.login_calls: list[dict] = []
|
||||||
|
|
||||||
|
async def login(self, username, password):
|
||||||
|
self.login_calls.append({"username": username, "password": password})
|
||||||
|
if username == "mohaghegh" and password == "secret":
|
||||||
|
return {"access_token": "jwt", "user": {"id": "user-1", "first_name": "علی",
|
||||||
|
"last_name": "محقق", "role": "procurement_manager"}}
|
||||||
|
raise RuntimeError("invalid credentials")
|
||||||
|
|
||||||
|
async def list_suppliers(self, q=None, page_size=20):
|
||||||
|
return [{"id": "sup-1", "name": "سازان نو", "category_id": "cat-2"}]
|
||||||
|
|
||||||
|
async def create_supplier(self, payload, actor_id=None):
|
||||||
|
self.created_suppliers.append(payload)
|
||||||
|
return {"id": "sup-new", "name": payload["name"],
|
||||||
|
"category": {"id": payload["category_id"], "name": "لوله و اتصالات"}}
|
||||||
|
|
||||||
|
async def list_materials(self, q=None, page_size=20):
|
||||||
|
return [
|
||||||
|
{"id": "mat-1", "name": "میلگرد A3 سایز ۱۴", "canonical_unit_id": 3},
|
||||||
|
{"id": "mat-2", "name": "میلگرد A3 سایز ۱۶", "canonical_unit_id": 3},
|
||||||
|
{"id": "mat-3", "name": "شیر فلکه چهارطرفه", "canonical_unit_id": 2},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_material(self, payload, actor_id=None):
|
||||||
|
self.created_materials.append(payload)
|
||||||
|
return {"id": "mat-new", "name": payload["name"]}
|
||||||
|
|
||||||
|
async def list_units(self, q=None, page_size=100):
|
||||||
|
return [{"id": 2, "code": "adad", "name_fa": "عدد"},
|
||||||
|
{"id": 3, "code": "kg", "name_fa": "کیلوگرم"}]
|
||||||
|
|
||||||
|
async def list_projects(self, page_size=100):
|
||||||
|
return [{"id": "prj-1", "name": "برج آسمان"}]
|
||||||
|
|
||||||
|
async def list_categories(self, page_size=100):
|
||||||
|
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
||||||
|
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
||||||
|
|
||||||
|
def _expanded(self, quote_id, status):
|
||||||
|
"""Realistic GET /quotes?expand=line_items,termsheets,supplier shape."""
|
||||||
|
return {
|
||||||
|
"id": quote_id, "supplier": {"name": "سازان نو"}, "project_id": None, "status": status,
|
||||||
|
"quote_date": "2026-09-10T00:00:00Z", "expiry_date": "2026-09-20",
|
||||||
|
"line_items": [
|
||||||
|
{"id": "li-1", "material": {"name": "میلگرد A3 سایز ۱۴"}, "unit": {"name_fa": "کیلوگرم"},
|
||||||
|
"quantity": 500, "unit_price": 45000, "projects": [],
|
||||||
|
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
|
||||||
|
{"id": "li-2", "material": {"name": "شیر فلکه چهارطرفه"}, "unit": {"name_fa": "عدد"},
|
||||||
|
"quantity": 200, "unit_price": 90000, "projects": [],
|
||||||
|
"decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None},
|
||||||
|
],
|
||||||
|
"termsheets": [{"id": "ts-1", "label": "۳ فاز", "discount_type": "none", "discount_value": 0,
|
||||||
|
"schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 1000}]}],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_quotes(self, *, status=None, q=None, expand=None, sort=None, page=1, page_size=10):
|
||||||
|
if status == "offered":
|
||||||
|
return [self._expanded("q-aaaa1111", "offered")], {"total": 1}
|
||||||
|
if status == "accepted":
|
||||||
|
return [self._expanded("q-bbbb2222", "accepted")], {"total": 1}
|
||||||
|
return [], {"total": 0}
|
||||||
|
|
||||||
|
async def get_quote(self, quote_id, expand=None, actor_id=None):
|
||||||
|
return self._expanded(quote_id, "accepted" if quote_id.startswith("q-bbbb") else "offered")
|
||||||
|
|
||||||
|
async def create_quote(self, payload, actor_id=None):
|
||||||
|
self.created_quotes.append(payload)
|
||||||
|
return {"id": "q-new", "supplier": {"name": "سازان نو"},
|
||||||
|
"line_items": payload.get("line_items") or [],
|
||||||
|
"termsheets": payload.get("termsheets") or []}
|
||||||
|
|
||||||
|
async def accept_quote(self, quote_id, payload, actor_id=None):
|
||||||
|
self.accept_payloads.append({"quote_id": quote_id, "payload": payload})
|
||||||
|
return {"id": quote_id, "status": "accepted", "line_items": [{"id": "li-1", "decision": {}}]}
|
||||||
|
|
||||||
|
async def arrive_items(self, quote_id, payload, actor_id=None):
|
||||||
|
self.arrive_payloads.append({"quote_id": quote_id, "payload": payload})
|
||||||
|
return {"id": quote_id, "status": "arrived",
|
||||||
|
"items": [{"line_item_id": it["line_item_id"], "received_qty": float(it["received_qty"])}
|
||||||
|
for it in payload["items"]]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# in-memory draft store + link store
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MEM: dict = {"drafts": {}, "links": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_stores() -> None:
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
import app.drafts as drafts
|
||||||
|
import app.runtime as runtime
|
||||||
|
|
||||||
|
async def create(chat_id, kind, payload, source=None):
|
||||||
|
draft_id = _uuid.uuid4()
|
||||||
|
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
||||||
|
missing=None, status="active", source=source,
|
||||||
|
review_message_id=None)
|
||||||
|
MEM["drafts"][str(draft_id)] = draft
|
||||||
|
return draft
|
||||||
|
|
||||||
|
async def get_active(chat_id, kind=None):
|
||||||
|
found = [d for d in MEM["drafts"].values()
|
||||||
|
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
|
||||||
|
return max(found, key=lambda d: d.id) if found else None
|
||||||
|
|
||||||
|
async def get_by_prefix(prefix, chat_id=None):
|
||||||
|
for draft in MEM["drafts"].values():
|
||||||
|
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
||||||
|
return draft
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def save_payload(draft, missing=None, review_message_id=None):
|
||||||
|
draft.missing = missing
|
||||||
|
if review_message_id:
|
||||||
|
draft.review_message_id = review_message_id
|
||||||
|
|
||||||
|
async def set_status(draft_id, status):
|
||||||
|
if draft_id in MEM["drafts"]:
|
||||||
|
MEM["drafts"][draft_id].status = status
|
||||||
|
|
||||||
|
async def expire_stale(ttl_hours):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
|
||||||
|
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
|
||||||
|
|
||||||
|
async def link_get(self, chat_id):
|
||||||
|
return MEM["links"].get(chat_id)
|
||||||
|
|
||||||
|
async def link_upsert(self, chat_id, user_id, username, display_name, role):
|
||||||
|
MEM["links"][chat_id] = types.SimpleNamespace(chat_id=chat_id, user_id=user_id,
|
||||||
|
casdoor_username=username,
|
||||||
|
display_name=display_name, role=role)
|
||||||
|
return MEM["links"][chat_id]
|
||||||
|
|
||||||
|
async def link_delete(self, chat_id):
|
||||||
|
return MEM["links"].pop(chat_id, None) is not None
|
||||||
|
|
||||||
|
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_log(self, chat_id, direction, kind, body=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
NLU_QUEUE: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_understand(settings, text, draft_summary=None):
|
||||||
|
return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0}
|
||||||
|
|
||||||
|
|
||||||
|
async def fake_transcribe(settings, audio, mime_type=None, filename=None):
|
||||||
|
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
|
||||||
|
"پرداخت سه فاز")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# driver
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_ctx():
|
||||||
|
import app.handlers as H
|
||||||
|
from app.resolve import Resolver
|
||||||
|
from app.runtime import BotContext
|
||||||
|
|
||||||
|
bale, api = FakeBale(), FakeApi()
|
||||||
|
ctx = BotContext(bale, api)
|
||||||
|
ctx.bale = bale
|
||||||
|
ctx.api = api
|
||||||
|
ctx.resolver = Resolver(api)
|
||||||
|
ctx._log = types.MethodType(fake_log, ctx)
|
||||||
|
H.understand = fake_understand
|
||||||
|
H.transcribe = fake_transcribe
|
||||||
|
return ctx, bale, api
|
||||||
|
|
||||||
|
|
||||||
|
def text_update(update_id, text):
|
||||||
|
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
|
||||||
|
|
||||||
|
|
||||||
|
def voice_update(update_id):
|
||||||
|
return {"update_id": update_id,
|
||||||
|
"message": {"chat": {"id": CHAT}, "voice": {"file_id": "v1", "mime_type": "audio/ogg", "duration": 6}}}
|
||||||
|
|
||||||
|
|
||||||
|
def callback_update(update_id, data):
|
||||||
|
return {"update_id": update_id, "callback_query": {"id": f"cb{update_id}", "data": data,
|
||||||
|
"message": {"chat": {"id": CHAT}}}}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
_install_fake_stores()
|
||||||
|
from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload
|
||||||
|
import app.handlers as H
|
||||||
|
from app.nlu import _extract_json, normalize_digits
|
||||||
|
|
||||||
|
# ---- pure logic -------------------------------------------------------
|
||||||
|
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
|
||||||
|
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
|
||||||
|
|
||||||
|
payload = new_quote_payload()
|
||||||
|
merge_quote_patch(payload, {
|
||||||
|
"supplier_name": "سازان نو",
|
||||||
|
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||||
|
"payment_terms": [{"label": "۳ فاز"}],
|
||||||
|
})
|
||||||
|
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]})
|
||||||
|
check("merge: no dup item", len(payload["items"]) == 1)
|
||||||
|
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
|
||||||
|
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
||||||
|
missing = compute_quote_missing(payload)
|
||||||
|
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||||
|
body, problems = build_quote_request(payload)
|
||||||
|
check("build: blocked without supplier", body is None)
|
||||||
|
|
||||||
|
# ---- pipeline: linking gate -------------------------------------------
|
||||||
|
ctx, bale, api = build_ctx()
|
||||||
|
await H.handle_update(ctx, text_update(1, "سلام"))
|
||||||
|
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
|
||||||
|
|
||||||
|
await H.handle_update(ctx, text_update(2, "mohaghegh"))
|
||||||
|
await H.handle_update(ctx, text_update(3, "secret"))
|
||||||
|
check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh")
|
||||||
|
check("link: linked greeting", "اتصال انجام شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- voice → draft quote ----------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
|
||||||
|
"supplier_name": "سازان نو", "project_name": "برج آسمان",
|
||||||
|
"items": [{"material_name": "میلگرد", "quantity": 500, "unit_name": "کیلوگرم",
|
||||||
|
"unit_price": 45000}],
|
||||||
|
"payment_terms": [{"label": "۳ فاز", "description": "سه قسط"}],
|
||||||
|
}})
|
||||||
|
await H.handle_update(ctx, voice_update(4))
|
||||||
|
draft = await _active("quote")
|
||||||
|
check("voice: draft created", draft is not None)
|
||||||
|
check("voice: supplier resolved", draft.payload.get("supplier_id") == "sup-1")
|
||||||
|
check("voice: project resolved", draft.payload.get("project_id") == "prj-1")
|
||||||
|
item = (draft.payload.get("items") or [{}])[0]
|
||||||
|
check("voice: material ambiguous → pick buttons", "mat1" in (draft.payload.get("options") or {}))
|
||||||
|
check("voice: review shows missing list", "وضعیت فیلدها" in bale.last_text())
|
||||||
|
check("voice: review mentions unit", "کیلوگرم" in bale.last_text())
|
||||||
|
|
||||||
|
# pick the material via inline button
|
||||||
|
markup = bale.last_markup() or {}
|
||||||
|
pick_data = _first_callback(markup, "pk:")
|
||||||
|
check("voice: pick callback present", pick_data is not None and ":mat1:0" in pick_data)
|
||||||
|
await H.handle_update(ctx, callback_update(5, pick_data))
|
||||||
|
item = (draft.payload.get("items") or [{}])[0]
|
||||||
|
check("pick: material resolved", item.get("material_id") == "mat-1")
|
||||||
|
check("pick: unit resolved from spoken name", str(item.get("unit_id")) == "3" and item.get("unit_explicit") is True)
|
||||||
|
|
||||||
|
# refine price via follow-up text
|
||||||
|
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
||||||
|
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
||||||
|
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد"))
|
||||||
|
draft = await _active("quote")
|
||||||
|
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
||||||
|
|
||||||
|
# commit via ✅
|
||||||
|
await H.handle_update(ctx, callback_update(7, f"q:ok:{draft.id[:8]}"))
|
||||||
|
check("commit: quote posted", len(api.created_quotes) == 1)
|
||||||
|
body = api.created_quotes[0]
|
||||||
|
check("commit: supplier id", body.get("supplier_id") == "sup-1")
|
||||||
|
check("commit: line item temp/refs", body["line_items"][0]["temp_id"] == "t1"
|
||||||
|
and body["item_termsheets"][0]["line_item_temp_id"] == "t1")
|
||||||
|
check("commit: price linked", body["item_termsheets"][0]["unit_price"] == 47000)
|
||||||
|
check("commit: termsheet carried", body["termsheets"][0]["label"] == "۳ فاز")
|
||||||
|
check("commit: report sent", "ثبت شد" in bale.last_text())
|
||||||
|
check("commit: draft committed", (await _active("quote")) is None)
|
||||||
|
|
||||||
|
# ---- add supplier ------------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "add_supplier", "confidence": 0.9, "patch": {
|
||||||
|
"supplier": {"name": "پروفیل بر", "category_name": "لوله و اتصالات", "phone": "0912"}}})
|
||||||
|
await H.handle_update(ctx, text_update(8, "تأمینکننده جدید پروفیل بر دسته لوله و اتصالات تلفن 0912"))
|
||||||
|
draft = await _active("supplier")
|
||||||
|
check("supplier: draft created", draft is not None)
|
||||||
|
check("supplier: category resolved", draft.payload.get("category_id") == "cat-2")
|
||||||
|
await H.handle_update(ctx, callback_update(9, f"s:ok:{draft.id[:8]}"))
|
||||||
|
check("supplier: posted", api.created_suppliers and api.created_suppliers[0]["name"] == "پروفیل بر")
|
||||||
|
check("supplier: report", "تأمینکننده ثبت شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- status query ------------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "status_query", "confidence": 0.9,
|
||||||
|
"patch": {"query": {"kind": "pending"}}})
|
||||||
|
await H.handle_update(ctx, text_update(10, "استعلامهای در انتظار چیه؟"))
|
||||||
|
check("query: pending digest", "در انتظار بررسی" in bale.last_text() and "#q-aaaa11" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- accept flow -------------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "accept_quote", "confidence": 0.9,
|
||||||
|
"patch": {"target": {"supplier_name": "سازان نو"}}})
|
||||||
|
await H.handle_update(ctx, text_update(11, "استعلام سازان نو را قبول کن"))
|
||||||
|
check("accept: summary shown", "پذیرش استعلام" in bale.last_text())
|
||||||
|
flow = ctx.pending.get(CHAT)
|
||||||
|
check("accept: pending flow", flow is not None and flow.kind == "accept")
|
||||||
|
await H.handle_update(ctx, callback_update(12, "ac:ok"))
|
||||||
|
check("accept: payload posted", len(api.accept_payloads) == 1)
|
||||||
|
payload = api.accept_payloads[0]["payload"]
|
||||||
|
check("accept: termsheet echoed with steps",
|
||||||
|
payload["accepted_termsheets"][0]["id"] == "ts-1" and payload["accepted_termsheets"][0]["schedule_steps"])
|
||||||
|
check("accept: decision price", payload["item_decisions"][0]["adjusted_unit_price"] == 45000)
|
||||||
|
check("accept: decision index", payload["item_decisions"][0]["accepted_termsheet_index"] == 0)
|
||||||
|
check("accept: report", "پذیرش انجام شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- arrive flow -------------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "arrive_items", "confidence": 0.9,
|
||||||
|
"patch": {"target": {"supplier_name": "سازان نو"}}})
|
||||||
|
await H.handle_update(ctx, text_update(13, "اقلام سازان نو رسید"))
|
||||||
|
check("arrive: items shown", "اعلام رسیدن" in bale.last_text())
|
||||||
|
await H.handle_update(ctx, text_update(14, "۵۰۰ و ۲۰۰"))
|
||||||
|
flow = ctx.pending.get(CHAT)
|
||||||
|
check("arrive: quantities parsed", flow is not None and flow.kind == "arrive_confirm"
|
||||||
|
and flow.data["quantities"] == {"li-1": 500.0, "li-2": 200.0})
|
||||||
|
await H.handle_update(ctx, callback_update(15, "ar:ok"))
|
||||||
|
check("arrive: payload posted", len(api.arrive_payloads) == 1
|
||||||
|
and api.arrive_payloads[0]["payload"]["items"][0]["received_qty"] == 500)
|
||||||
|
check("arrive: report", "ثبت رسید انجام شد" in bale.last_text())
|
||||||
|
|
||||||
|
# ---- add material ------------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "add_material", "confidence": 0.9,
|
||||||
|
"patch": {"material": {"name": "میلگرد A3 سایز ۱۸", "unit_name": "کیلوگرم"}}})
|
||||||
|
await H.handle_update(ctx, text_update(16, "کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم"))
|
||||||
|
draft = await _active("material")
|
||||||
|
check("material: draft created", draft is not None)
|
||||||
|
check("material: unit resolved", str(draft.payload.get("canonical_unit_id")) == "3")
|
||||||
|
await H.handle_update(ctx, callback_update(17, f"m:ok:{draft.id[:8]}"))
|
||||||
|
check("material: posted", api.created_materials
|
||||||
|
and api.created_materials[0]["canonical_unit_id"] == 3)
|
||||||
|
|
||||||
|
# ---- cancel / unknown --------------------------------------------------
|
||||||
|
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
||||||
|
await H.handle_update(ctx, text_update(18, "استعلام از سازان نو"))
|
||||||
|
await H.handle_update(ctx, text_update(19, "/cancel"))
|
||||||
|
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
||||||
|
|
||||||
|
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
||||||
|
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز"))
|
||||||
|
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
||||||
|
|
||||||
|
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
||||||
|
return 1 if _failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _active(kind):
|
||||||
|
from app import drafts as draft_store
|
||||||
|
return await draft_store.get_active(CHAT, kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_callback(markup: dict | None, prefix: str) -> str | None:
|
||||||
|
for row in (markup or {}).get("inline_keyboard") or []:
|
||||||
|
for btn in row:
|
||||||
|
if btn.get("callback_data", "").startswith(prefix):
|
||||||
|
return btn["callback_data"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
+113
-1
@@ -11,7 +11,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5455:5432"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
@@ -208,6 +208,7 @@ services:
|
|||||||
mem_limit: 768m
|
mem_limit: 768m
|
||||||
|
|
||||||
api:
|
api:
|
||||||
|
image: v15-api:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -248,6 +249,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./backend/app:/srv/backend/app
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- traefik_network
|
- traefik_network
|
||||||
- default
|
- default
|
||||||
@@ -276,6 +280,7 @@ services:
|
|||||||
mem_limit: 768m
|
mem_limit: 768m
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: v15-frontend:latest
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
@@ -311,6 +316,112 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 128m
|
mem_limit: 128m
|
||||||
|
|
||||||
|
bot:
|
||||||
|
image: v15-bot:latest
|
||||||
|
build:
|
||||||
|
context: ./bot
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: procurement-bot
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./bot/app:/srv/bot/app
|
||||||
|
|
||||||
|
environment:
|
||||||
|
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
|
||||||
|
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
|
||||||
|
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
|
||||||
|
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
|
||||||
|
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-https://api.deepseek.com}
|
||||||
|
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
|
||||||
|
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-deepseek-flash}
|
||||||
|
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-1461166742,1839839922}
|
||||||
|
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
|
||||||
|
PROCUREMENT_BOT_FRONTEND_URL: ${APP_HOST:-http://localhost}
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
|
||||||
|
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
|
||||||
|
# created additively at startup) and reads ERP users/user_panels for SSO.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pm-api:
|
||||||
|
image: v15-pm-api:latest
|
||||||
|
build:
|
||||||
|
context: ./pm/backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pm-api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- pm_files:/data/files
|
||||||
|
- ./pm/backend/app:/srv/pm/app
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Same database as the ERP — PM tables are additive, created at startup.
|
||||||
|
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
|
||||||
|
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
|
||||||
|
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
|
||||||
|
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
||||||
|
PM_FILES_DIR: /data/files
|
||||||
|
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
||||||
|
# ERP integration — in-network service URLs.
|
||||||
|
PM_ERP_BASE_URL: http://api:8000
|
||||||
|
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||||
|
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
|
||||||
|
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
||||||
|
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
||||||
|
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
||||||
|
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 512m
|
||||||
|
|
||||||
|
pm-frontend:
|
||||||
|
image: v15-pm-frontend:latest
|
||||||
|
build:
|
||||||
|
context: ./pm/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: pm-frontend
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
- pm-api
|
||||||
|
|
||||||
|
# PM platform under /pm on the same domain and the same Traefik router.
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
|
||||||
|
- traefik.http.routers.pm.entrypoints=web
|
||||||
|
# Higher priority than the ERP frontend Host router — otherwise /pm/api
|
||||||
|
# would reach the ERP SPA (POST → 405).
|
||||||
|
- traefik.http.routers.pm.priority=100
|
||||||
|
- traefik.http.services.pm.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- traefik_network
|
||||||
|
- default
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 128m
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||||
@@ -327,3 +438,4 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
minio_data:
|
minio_data:
|
||||||
traefik_certs:
|
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"
|
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
|
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
|
||||||
"short_name": "دفترچه تأمین",
|
"short_name": "مدیریت پروژه و تأمین",
|
||||||
"description": "سامانه تدارکات و مدیریت انبار پروژههای ساختمانی",
|
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
|
||||||
"dir": "rtl",
|
"dir": "rtl",
|
||||||
"lang": "fa",
|
"lang": "fa",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
|||||||
+291
-8
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { useAuthStore } from "./store/auth";
|
import { useAuthStore } from "./store/auth";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { RequirePmProjects } from "./components/RequirePmProjects";
|
import { RequirePmProjects } from "./components/RequirePmProjects";
|
||||||
@@ -38,11 +38,14 @@ const WarehousesPage = lazy(() =>
|
|||||||
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
|
||||||
);
|
);
|
||||||
const OrganizationPage = lazy(() =>
|
const OrganizationPage = lazy(() =>
|
||||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||||
);
|
);
|
||||||
const AdminPanelPage = lazy(() =>
|
const AdminPanelPage = lazy(() =>
|
||||||
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
||||||
);
|
);
|
||||||
|
const RequesterHome = lazy(() =>
|
||||||
|
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
|
||||||
|
);
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@@ -52,25 +55,274 @@ function Loading() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
|
// همهی صفحات پشت ورود هستند — به جز پیشنویسهای ویزارد از ربات یا بدون نشست که به صفحهی ورود میروند.
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
const location = useLocation();
|
||||||
|
const pathname = location.pathname;
|
||||||
|
|
||||||
|
// در صورت داشتن شناسه پیشنویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const hasDraft = searchParams.has("draft") || searchParams.has("draft_id");
|
||||||
|
if (location.pathname === "/wizard" && hasDraft) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// صفحهی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
|
if (!user) {
|
||||||
|
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() {
|
function HomeRoute() {
|
||||||
const user = useAuthStore((s) => s.user);
|
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 />;
|
return <DashboardPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۲. اگر پنل انبار دارد:
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
|
||||||
|
// ۳. اگر پنل PM دارد:
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
|
||||||
|
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
|
||||||
|
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنلهای سامانه</h3>
|
||||||
|
<p className="mt-2 text-xs text-muted leading-6">
|
||||||
|
حساب کاربری شما فعال است اما در حال حاضر به هیچیک از پنلهای سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای تدارکات و پنلهای تدارکات (full یا limited)
|
||||||
|
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const panels = user?.panels ?? {};
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager" || user?.role === "pm");
|
||||||
|
|
||||||
|
if (hasProcurement) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||||
|
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
||||||
|
function PmPanelPage() {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src="/pm/"
|
||||||
|
title="مدیریت پروژه (PM)"
|
||||||
|
className="h-dvh w-full border-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای pm/supervisor
|
||||||
|
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const panels = user?.panels ?? {};
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
if (!hasPm) {
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager");
|
||||||
|
if (hasProcurement) {
|
||||||
|
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
}
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
||||||
|
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
|
||||||
|
if (!allowed) {
|
||||||
|
const panels = user?.panels ?? {};
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
|
||||||
|
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||||
|
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
|
||||||
|
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||||
|
return <Navigate to="/panel/pm" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
|
||||||
|
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
|
||||||
|
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const allowed =
|
||||||
|
user?.role === "admin" ||
|
||||||
|
user?.role === "supervisor" ||
|
||||||
|
user?.panels?.inventory === "limited";
|
||||||
|
if (!allowed) {
|
||||||
|
return <Navigate to="/inventory" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را میبیند)
|
||||||
|
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
|
||||||
|
if (!allowed) {
|
||||||
|
return <Navigate to="/inventory" replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/review" element={<WizardPage isStandalone />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
@@ -84,10 +336,13 @@ export default function App() {
|
|||||||
path="/wizard"
|
path="/wizard"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
|
<RequireProcurementWrite>
|
||||||
<WizardPage />
|
<WizardPage />
|
||||||
|
</RequireProcurementWrite>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="/suppliers"
|
path="/suppliers"
|
||||||
element={
|
element={
|
||||||
@@ -164,7 +419,9 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequirePmProjects>
|
||||||
|
<RequireInventoryChange>
|
||||||
<ChangeInventoryPage />
|
<ChangeInventoryPage />
|
||||||
|
</RequireInventoryChange>
|
||||||
</RequirePmProjects>
|
</RequirePmProjects>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
@@ -184,18 +441,24 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequirePmProjects>
|
||||||
|
<RequireWarehouseManage>
|
||||||
<WarehousesPage />
|
<WarehousesPage />
|
||||||
|
</RequireWarehouseManage>
|
||||||
</RequirePmProjects>
|
</RequirePmProjects>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/inventory/org"
|
path="/inventory/org"
|
||||||
|
element={<Navigate to="/organization" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/organization"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequirePmProjects>
|
<RequireOrganization>
|
||||||
<OrganizationPage />
|
<OrganizationPage />
|
||||||
</RequirePmProjects>
|
</RequireOrganization>
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -207,6 +470,26 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
|
||||||
|
<Route
|
||||||
|
path="/requester"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequesterHome />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
|
||||||
|
<Route
|
||||||
|
path="/panel/pm"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<RequirePmPanel>
|
||||||
|
<PmPanelPage />
|
||||||
|
</RequirePmPanel>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,48 +1,84 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
||||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
|
||||||
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
|
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
|
||||||
// دقیقاً مثل منوی دفتر: عنوان «پنلها»، ماژول فعلی با استایل فعال (پر)
|
// ۱. مدیریت انبار (/inventory)
|
||||||
// + «✓ فعلی»، ماژول دیگر بهعنوان سوییچر، و در پایان جداکننده + خروج.
|
// ۲. مدیریت تدارکات (/)
|
||||||
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز میشود که همهی
|
// ۳. مدیریت پروژه (/panel/pm)
|
||||||
// پروژههای تخصیصیافته را نشان میدهد؛ پروژهی فعال با «✓ فعلی» مشخص است و
|
// ۴. سازماندهی (/organization)
|
||||||
// انتخاب هر پروژه فقط «پروژهی فعال» را عوض میکند (نشست/توکن دستنخورده).
|
|
||||||
export function AppSwitcherMenu({
|
export function AppSwitcherMenu({
|
||||||
activeModule,
|
activeModule,
|
||||||
canSwitchToProcurement = true,
|
canSwitchToProcurement = true,
|
||||||
}: {
|
}: {
|
||||||
activeModule: "warehouse" | "procurement";
|
activeModule: "warehouse" | "procurement" | "organization";
|
||||||
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
|
canSwitchToProcurement?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const { pathname } = useLocation();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setActiveProject = useAuthStore((s) => s.setActiveProject);
|
const panels = user?.panels ?? {};
|
||||||
|
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||||
|
|
||||||
|
const hasProcurement =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.procurement)
|
||||||
|
: user?.role === "procurement_manager");
|
||||||
|
|
||||||
|
const hasInventory =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.inventory)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
const hasPm =
|
||||||
|
isAdmin ||
|
||||||
|
(hasPanelsConfig
|
||||||
|
? Boolean(panels.pm)
|
||||||
|
: user?.role === "pm" || user?.role === "supervisor");
|
||||||
|
|
||||||
|
// فقط نقشهای ادمین، مدیر پروژه و مدیر تدارکات به پنل سازماندهی دسترسی دارند
|
||||||
|
const canManageOrg =
|
||||||
|
isAdmin ||
|
||||||
|
user?.role === "procurement_manager" ||
|
||||||
|
user?.role === "pm";
|
||||||
|
|
||||||
const items: MenuItem[] = [];
|
const items: MenuItem[] = [];
|
||||||
|
|
||||||
const isPm = user?.role === "pm";
|
if (hasInventory) {
|
||||||
const pmProjects = isPm ? user?.projects ?? [] : [];
|
items.push({
|
||||||
const projectChildren: MenuItem[] | undefined =
|
label: "انبار",
|
||||||
activeModule === "warehouse" && isPm
|
to: "/inventory",
|
||||||
? pmProjects.map((p) => ({
|
active: activeModule === "warehouse",
|
||||||
label: p.name,
|
});
|
||||||
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض میشود
|
}
|
||||||
active: p.id === user?.project_id,
|
|
||||||
onClick: () => setActiveProject(p.id),
|
|
||||||
}))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (activeModule === "procurement") {
|
if (hasProcurement && canSwitchToProcurement) {
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
items.push({
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
label: "تدارکات",
|
||||||
} else {
|
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
|
active: activeModule === "procurement",
|
||||||
if (canSwitchToProcurement) {
|
});
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasPm) {
|
||||||
|
items.push({
|
||||||
|
label: "مدیریت پروژه",
|
||||||
|
to: "/panel/pm",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canManageOrg) {
|
||||||
|
items.push({
|
||||||
|
label: "سازماندهی",
|
||||||
|
to: "/organization",
|
||||||
|
active: activeModule === "organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
items.push({ label: "خروج از حساب", onClick: logout });
|
items.push({ label: "خروج از حساب", onClick: logout });
|
||||||
return <PanelMenu title="پنلها" items={items} />;
|
return <PanelMenu title="پنلها" items={items} />;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||||
import { normalize } from "../lib/normalize";
|
import { normalize } from "../lib/normalize";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
@@ -17,6 +17,7 @@ export interface ComboboxProps {
|
|||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -25,6 +26,8 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||||
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
openOnFocus?: boolean;
|
openOnFocus?: boolean;
|
||||||
@@ -44,6 +47,7 @@ export function Combobox({
|
|||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
@@ -52,6 +56,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
inputRef,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
@@ -125,6 +130,7 @@ export function Combobox({
|
|||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
@@ -182,9 +188,15 @@ export function Combobox({
|
|||||||
onMouseEnter={() => setHighlight(filtered.length)}
|
onMouseEnter={() => setHighlight(filtered.length)}
|
||||||
>
|
>
|
||||||
<span className="font-bold">+</span>
|
<span className="font-bold">+</span>
|
||||||
|
{addPrefix ? (
|
||||||
|
<span>
|
||||||
|
{addPrefix} «<b>{text.trim()}</b>»
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span>
|
<span>
|
||||||
«<b>{text.trim()}</b>» {addSuffix}
|
«<b>{text.trim()}</b>» {addSuffix}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
|
|||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
label?: string;
|
label?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز میشود
|
||||||
|
href?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
divider?: boolean;
|
divider?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : it.href ? (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={it.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||||
|
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
<span aria-hidden className="text-[10px] opacity-60">↗</span>
|
||||||
|
</a>
|
||||||
) : it.to ? (
|
) : it.to ? (
|
||||||
<Link
|
<Link
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
|||||||
const setProjects = useAuthStore((s) => s.setProjects);
|
const setProjects = useAuthStore((s) => s.setProjects);
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
|
||||||
const isPm = user?.role === "pm";
|
// پنل انبار limited هم مثل سرپرست: پروژههای فعالش از عضویتها اعتبارسنجی میشود
|
||||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||||
|
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role !== "pm") {
|
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
|
||||||
|
if (!user || !scoped) {
|
||||||
setState("ok");
|
setState("ok");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
|
|||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user?.id, user?.role, isDev]);
|
}, [user?.id, user?.role, user?.panels, isDev]);
|
||||||
|
|
||||||
if (state === "loading") {
|
if (state === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
|
|||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ChevronRight, ClipboardList } from "lucide-react";
|
import { ChevronRight, ClipboardList } from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { useAuthStore, type AuthUser } from "../../store/auth";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
admin: "ادمین",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function userSubtitle(user: AuthUser | null | undefined): string {
|
||||||
|
if (!user) return "";
|
||||||
|
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
|
||||||
|
const username = user.username || user.casdoor_id || "-";
|
||||||
|
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
|
||||||
|
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
|
||||||
|
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,6 +35,10 @@ interface HeaderProps {
|
|||||||
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const userSub = userSubtitle(user);
|
||||||
|
const displaySub = sub !== undefined ? sub : userSub;
|
||||||
|
|
||||||
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمیگردیم؛
|
||||||
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
// اگر مستقیم باز شده (بدون سابقهی داخلی)، به backTo میرویم.
|
||||||
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
|||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
||||||
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
{displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
PackageSearch,
|
PackageSearch,
|
||||||
Scale,
|
Scale,
|
||||||
Truck,
|
Truck,
|
||||||
UsersRound,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
|
|||||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||||
];
|
];
|
||||||
|
|
||||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات (آینهی ناوبری بالای دسکتاپ)
|
// تبهای پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینهی ناوبری بالای دسکتاپ)
|
||||||
const ADMIN_INV_TABS: TabDef[] = [
|
const ADMIN_INV_TABS: TabDef[] = [
|
||||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||||
|
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
|
||||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||||
{ to: "/inventory/org", label: "سازماندهی", icon: UsersRound },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
||||||
@@ -81,15 +80,25 @@ export function MobileTabBar() {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
if (pathname === "/login" || pathname === "/admin") return null;
|
if (pathname === "/login" || pathname === "/admin") return null;
|
||||||
|
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تببار خودش را دارد
|
||||||
|
if (pathname === "/requester" || pathname === "/panel/pm") return null;
|
||||||
|
// کاربر فقطِ تدارکات-limited: تبهای انبار/دفتر ندارد
|
||||||
|
const panels = useAuthStore.getState().user?.panels ?? {};
|
||||||
|
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
|
||||||
|
// پنل مدیریت پروژه: iframe تمامصفحهی PM است و خودش تببار داخلی دارد
|
||||||
|
if (pathname === "/panel/pm") return null;
|
||||||
const isPM = role === "pm";
|
const isPM = role === "pm";
|
||||||
|
const isSupervisor = role === "supervisor";
|
||||||
const isOffice = role === "admin" || role === "procurement_manager";
|
const isOffice = role === "admin" || role === "procurement_manager";
|
||||||
|
|
||||||
if (pathname.startsWith("/inventory")) {
|
if (pathname.startsWith("/inventory")) {
|
||||||
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
||||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (pathname === "/wizard") return null;
|
// سرپرست پروژه به بخشهای تدارکات/دفتر دسترسی ندارد
|
||||||
|
if (isSupervisor) return null;
|
||||||
|
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
||||||
const onOfficePage =
|
const onOfficePage =
|
||||||
pathname === "/" ||
|
pathname === "/" ||
|
||||||
pathname === "/projects" ||
|
pathname === "/projects" ||
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
|
|||||||
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
|
||||||
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
|
||||||
export { useMaterials, useAddMaterial } from "./useMaterials";
|
export { useMaterials, useAddMaterial } from "./useMaterials";
|
||||||
export { useProjects, useAddProject } from "./useProjects";
|
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
|
||||||
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
|
||||||
|
|||||||
@@ -21,3 +21,15 @@ export function useAddProject() {
|
|||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeleteProject() {
|
||||||
|
const deleteProject = useAppStore((s) => s.deleteProject);
|
||||||
|
const invalidate = useInvalidateStore();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.projects.delete(id),
|
||||||
|
onMutate: (id) => deleteProject(id),
|
||||||
|
onSettled: () => invalidate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+175
-10
@@ -91,7 +91,7 @@ function handle401(path: string, res: Response): void {
|
|||||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||||
function activeProjectId(): string | null {
|
function activeProjectId(): string | null {
|
||||||
const { user } = useAuthStore.getState();
|
const { user } = useAuthStore.getState();
|
||||||
return user?.role === "pm" ? user.project_id : null;
|
return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectQuery(projectId?: string | null): string {
|
function projectQuery(projectId?: string | null): string {
|
||||||
@@ -263,6 +263,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
|
|||||||
}
|
}
|
||||||
return res as T;
|
return res as T;
|
||||||
}
|
}
|
||||||
|
async function putData<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
async function deleteData<T>(path: string): Promise<T> {
|
async function deleteData<T>(path: string): Promise<T> {
|
||||||
const res = await request<any>(path, { method: "DELETE" });
|
const res = await request<any>(path, { method: "DELETE" });
|
||||||
if (res && typeof res === "object" && "data" in res) {
|
if (res && typeof res === "object" && "data" in res) {
|
||||||
@@ -339,6 +343,12 @@ interface RawSupplier {
|
|||||||
interface RawProject {
|
interface RawProject {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
location?: string | null;
|
||||||
|
project_manager?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
progress?: number | null;
|
||||||
}
|
}
|
||||||
interface RawSpec {
|
interface RawSpec {
|
||||||
attribute_key: string;
|
attribute_key: string;
|
||||||
@@ -474,6 +484,39 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
|
|||||||
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
|
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// تأمینکنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers
|
||||||
|
// Raw shape mirrors the backend dict exactly: clean supplier objects produced
|
||||||
|
// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name
|
||||||
|
// always comes from the suppliers table, never from the material row.
|
||||||
|
interface RawMaterialSupplier {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
|
contact_person: string | null;
|
||||||
|
location: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean UI-facing type for one supplier linked to a material. */
|
||||||
|
export interface MaterialSupplier {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
|
contact_person: string | null;
|
||||||
|
location: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
// Bound straight from the supplier object in the API response — there is
|
||||||
|
// no fallback, so a supplier row can never render the material's name.
|
||||||
|
name: raw.name,
|
||||||
|
phone: raw.phone,
|
||||||
|
contact_person: raw.contact_person,
|
||||||
|
location: raw.location,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// فایل پیوست — پاسخ بکاند (metadata؛ بایتها از endpoint دانلود میآیند)
|
// فایل پیوست — پاسخ بکاند (metadata؛ بایتها از endpoint دانلود میآیند)
|
||||||
interface RawAttachment {
|
interface RawAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1053,6 +1096,73 @@ export const api = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
drafts: {
|
||||||
|
get: async (
|
||||||
|
id: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
chat_id: number;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
payload: Record<string, any>;
|
||||||
|
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
|
||||||
|
source?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}> => {
|
||||||
|
return getData(`/drafts/${id}`);
|
||||||
|
},
|
||||||
|
update: async (
|
||||||
|
id: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
): Promise<void> => {
|
||||||
|
await patchData(`/drafts/${id}`, { payload });
|
||||||
|
},
|
||||||
|
commit: async (
|
||||||
|
id: string,
|
||||||
|
quoteId?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await postData(`/drafts/${id}/commit`, { quote_id: quoteId });
|
||||||
|
},
|
||||||
|
cancel: async (
|
||||||
|
id: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await postData(`/drafts/${id}/cancel`, {});
|
||||||
|
},
|
||||||
|
verifyToken: async (
|
||||||
|
token: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
chat_id: number;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
payload: Record<string, any>;
|
||||||
|
missing?: { fields?: { ok: boolean; key: string; label: string; blocking: boolean }[] } | null;
|
||||||
|
source?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}> => {
|
||||||
|
return getData(`/drafts/verify?token=${encodeURIComponent(token)}`);
|
||||||
|
},
|
||||||
|
updateByToken: async (
|
||||||
|
token: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
): Promise<void> => {
|
||||||
|
await patchData(`/drafts/by-token?token=${encodeURIComponent(token)}`, { payload });
|
||||||
|
},
|
||||||
|
commitByToken: async (
|
||||||
|
token: string,
|
||||||
|
quoteId?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await postData(`/drafts/by-token/commit?token=${encodeURIComponent(token)}`, { quote_id: quoteId });
|
||||||
|
},
|
||||||
|
cancelByToken: async (
|
||||||
|
token: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await postData(`/drafts/by-token/cancel?token=${encodeURIComponent(token)}`, {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
suppliers: {
|
suppliers: {
|
||||||
list: async (): Promise<Supplier[]> => {
|
list: async (): Promise<Supplier[]> => {
|
||||||
const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`);
|
const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`);
|
||||||
@@ -1159,16 +1269,51 @@ export const api = {
|
|||||||
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
|
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
|
||||||
await patchData(`/materials/${id}`, { canonical_unit_id });
|
await patchData(`/materials/${id}`, { canonical_unit_id });
|
||||||
},
|
},
|
||||||
|
// فهرست تأمینکنندههای متصل به یک متریال (GET /materials/{id}/suppliers)
|
||||||
|
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
|
||||||
|
// The backend returns { data: [...clean supplier objects] } from an
|
||||||
|
// atomic JOIN; getData<T> unwraps the envelope. No fallback data —
|
||||||
|
// an empty array means the material genuinely has no suppliers.
|
||||||
|
const raw = await getData<RawMaterialSupplier[]>(
|
||||||
|
`/materials/${materialId}/suppliers`,
|
||||||
|
);
|
||||||
|
return raw.map(adaptMaterialSupplier);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: {
|
projects: {
|
||||||
list: async (): Promise<Project[]> => {
|
list: async (): Promise<Project[]> => {
|
||||||
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
|
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
|
||||||
return raw.map((p) => ({ id: p.id, name: p.name }));
|
return raw.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
location: p.location || "",
|
||||||
|
project_manager: p.project_manager || "",
|
||||||
|
description: p.description || "",
|
||||||
|
start_date: p.start_date,
|
||||||
|
status: p.status || "ACTIVE",
|
||||||
|
progress: p.progress != null ? Number(p.progress) : 0,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
create: async (p: Project): Promise<Project> => {
|
create: async (p: Project): Promise<Project> => {
|
||||||
const raw = await postData<RawProject>("/projects", { name: p.name });
|
const raw = await postData<RawProject>("/projects", {
|
||||||
return { id: raw.id, name: raw.name };
|
name: p.name,
|
||||||
|
location: p.location || "",
|
||||||
|
project_manager: p.project_manager || "",
|
||||||
|
description: p.description || "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
name: raw.name,
|
||||||
|
location: raw.location || "",
|
||||||
|
project_manager: raw.project_manager || "",
|
||||||
|
description: raw.description || "",
|
||||||
|
status: raw.status || "ACTIVE",
|
||||||
|
progress: raw.progress != null ? Number(raw.progress) : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
delete: async (id: string): Promise<void> => {
|
||||||
|
await deleteData(`/projects/${id}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1558,11 +1703,16 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
|
|||||||
export interface UserRow {
|
export interface UserRow {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string | null;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
|
// لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', ...}
|
||||||
|
panels?: Record<string, string>;
|
||||||
|
// عضویت M:N پروژهای — محدوده داده سطح limited
|
||||||
|
member_project_ids?: string[];
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
// 1:N — every project this PM manages (oldest first).
|
// 1:N — every project this PM manages (oldest first).
|
||||||
@@ -1586,10 +1736,11 @@ export const apiV26 = {
|
|||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
casdoor_id: string;
|
casdoor_id: string;
|
||||||
|
username?: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||||
role_label: string | null;
|
role_label: string | null;
|
||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
project_name: string | null;
|
project_name: string | null;
|
||||||
@@ -1603,6 +1754,7 @@ export const apiV26 = {
|
|||||||
expires_in: res.data.expires_in,
|
expires_in: res.data.expires_in,
|
||||||
user: {
|
user: {
|
||||||
...u,
|
...u,
|
||||||
|
username: u.username || u.casdoor_id,
|
||||||
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1622,14 +1774,15 @@ export const apiV26 = {
|
|||||||
users: {
|
users: {
|
||||||
me: async (): Promise<import("../store/auth").AuthUser> => {
|
me: async (): Promise<import("../store/auth").AuthUser> => {
|
||||||
const data = await getData<{
|
const data = await getData<{
|
||||||
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
|
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
|
||||||
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
|
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
|
||||||
project_id: string | null; project_name: string | null;
|
project_id: string | null; project_name: string | null;
|
||||||
projects?: { id: string; name: string }[];
|
projects?: { id: string; name: string }[];
|
||||||
}>("/users/me");
|
}>("/users/me");
|
||||||
return {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
casdoor_id: data.casdoor_id,
|
casdoor_id: data.casdoor_id,
|
||||||
|
username: data.username || data.casdoor_id,
|
||||||
first_name: data.first_name,
|
first_name: data.first_name,
|
||||||
last_name: data.last_name,
|
last_name: data.last_name,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
@@ -1653,8 +1806,13 @@ export const apiV26 = {
|
|||||||
first_name: string; last_name?: string; phone?: string | null;
|
first_name: string; last_name?: string; phone?: string | null;
|
||||||
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
|
||||||
}): Promise<UserRow> => postData<UserRow>("/users", body),
|
}): Promise<UserRow> => postData<UserRow>("/users", body),
|
||||||
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
|
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
|
||||||
patchData<UserRow>(`/users/${id}`, { role }),
|
patchData<UserRow>(`/users/${id}`, { role }),
|
||||||
|
// لایه پنلها — تخصیص پنل + سطح دسترسی و عضویت پروژهای (فقط ادمین)
|
||||||
|
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
|
||||||
|
putData<UserRow>(`/users/${id}/panels`, { panels }),
|
||||||
|
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
|
||||||
|
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
||||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||||
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||||
@@ -1702,11 +1860,18 @@ export const apiV26 = {
|
|||||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||||
return data.map(rawInventory);
|
return data.map(rawInventory);
|
||||||
},
|
},
|
||||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||||
},
|
},
|
||||||
|
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||||
|
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||||
|
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||||
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||||
|
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||||
|
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||||
|
},
|
||||||
createTransaction: async (body: {
|
createTransaction: async (body: {
|
||||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export interface Material {
|
|||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
project_manager?: string;
|
||||||
|
description?: string;
|
||||||
|
start_date?: string | null;
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مرحلهی پرداخت
|
// مرحلهی پرداخت
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
pm: "مدیر پروژه",
|
pm: "مدیر پروژه",
|
||||||
procurement_manager: "مدیر تدارکات",
|
procurement_manager: "مدیر تدارکات",
|
||||||
admin: "ادمین",
|
admin: "ادمین",
|
||||||
|
supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
||||||
@@ -80,6 +81,26 @@ export function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
|
||||||
|
const toggleSupervisor = async (u: UserRow) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
const target = u.role === "supervisor" ? null : "supervisor";
|
||||||
|
try {
|
||||||
|
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
|
||||||
|
toast.success(
|
||||||
|
target === "supervisor"
|
||||||
|
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
|
||||||
|
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
refreshProcurementStore();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
@@ -129,12 +150,13 @@ export function AdminPanel() {
|
|||||||
<th className="py-2 pe-3">نام کامل</th>
|
<th className="py-2 pe-3">نام کامل</th>
|
||||||
<th className="py-2 pe-3">شناسه Casdoor</th>
|
<th className="py-2 pe-3">شناسه Casdoor</th>
|
||||||
<th className="py-2 pe-3">نقش فعلی</th>
|
<th className="py-2 pe-3">نقش فعلی</th>
|
||||||
<th className="py-2">مدیریت مدیر تدارکات</th>
|
<th className="py-2">مدیریت نقشها</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const isPMgr = u.role === "procurement_manager";
|
const isPMgr = u.role === "procurement_manager";
|
||||||
|
const isSup = u.role === "supervisor";
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="border-b border-line/50">
|
<tr key={u.id} className="border-b border-line/50">
|
||||||
<td className="py-2 pe-3 font-semibold">
|
<td className="py-2 pe-3 font-semibold">
|
||||||
@@ -147,6 +169,7 @@ export function AdminPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
disabled={busyId === u.id}
|
disabled={busyId === u.id}
|
||||||
onClick={() => void toggleProcurementManager(u)}
|
onClick={() => void toggleProcurementManager(u)}
|
||||||
@@ -158,6 +181,18 @@ export function AdminPanel() {
|
|||||||
>
|
>
|
||||||
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onClick={() => void toggleSupervisor(u)}
|
||||||
|
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||||
|
isSup
|
||||||
|
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
|
||||||
|
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
@@ -33,6 +33,8 @@ export function LoginPage() {
|
|||||||
const setUser = useAuthStore((s) => s.setUser);
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
const setDevUser = useAuthStore((s) => s.setDevUser);
|
const setDevUser = useAuthStore((s) => s.setDevUser);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const returnUrl = params.get("returnUrl");
|
||||||
|
|
||||||
// ---- فرم اصلی (Casdoor) ----
|
// ---- فرم اصلی (Casdoor) ----
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -77,14 +79,40 @@ export function LoginPage() {
|
|||||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const hasProjects =
|
||||||
|
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||||
|
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||||
|
if (user.role === "supervisor" && !hasProjects) {
|
||||||
|
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
||||||
track("login_success", { role: user.role });
|
track("login_success", { role: user.role });
|
||||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
if (returnUrl) {
|
||||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
navigate(decodeURIComponent(returnUrl));
|
||||||
|
} else {
|
||||||
|
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||||
|
const panels = user.panels ?? {};
|
||||||
|
if (panels.procurement === "full") {
|
||||||
|
navigate("/");
|
||||||
|
} else if (panels.procurement === "limited") {
|
||||||
|
navigate("/requester");
|
||||||
|
} else if (panels.inventory) {
|
||||||
|
navigate("/inventory");
|
||||||
|
} else if (panels.pm) {
|
||||||
|
navigate("/panel/pm");
|
||||||
|
} else if (user.role === "supervisor") {
|
||||||
|
navigate("/panel/pm");
|
||||||
|
} else if (user.role === "pm") {
|
||||||
|
navigate("/inventory");
|
||||||
|
} else {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -125,8 +153,9 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
|
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
|
||||||
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
|
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
|
||||||
// ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار
|
if (returnUrl) {
|
||||||
if (devRole === "admin") navigate("/admin");
|
navigate(decodeURIComponent(returnUrl));
|
||||||
|
} else if (devRole === "admin") navigate("/admin");
|
||||||
else if (devRole === "pm") navigate("/inventory");
|
else if (devRole === "pm") navigate("/inventory");
|
||||||
else navigate("/");
|
else navigate("/");
|
||||||
};
|
};
|
||||||
@@ -273,10 +302,21 @@ export function LoginPage() {
|
|||||||
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
|
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
|
||||||
<p className="mt-1 text-[12.5px] leading-6 text-muted">
|
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
|
||||||
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
|
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
|
||||||
|
مدیریت پروژه
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
|
||||||
|
تدارکات
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
|
||||||
|
انبارداری
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
|
|||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { AcceptModal } from "./AcceptModal";
|
import { AcceptModal } from "./AcceptModal";
|
||||||
import { EditModal } from "./EditModal";
|
import { EditModal } from "./EditModal";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
|
||||||
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
|
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
@@ -85,7 +89,7 @@ export function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-20 md:pb-6">
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
<Header
|
<Header
|
||||||
title="دفتر پیشنهادهای خرید"
|
title="مدیریت تدارکات"
|
||||||
brand
|
brand
|
||||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
actions={
|
actions={
|
||||||
@@ -114,6 +118,7 @@ export function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||||
|
{canWrite && (
|
||||||
<Link
|
<Link
|
||||||
to="/wizard"
|
to="/wizard"
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
||||||
@@ -121,6 +126,7 @@ export function DashboardPage() {
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>افزودن پیشنهاد جدید</span>
|
<span>افزودن پیشنهاد جدید</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -148,10 +154,12 @@ export function DashboardPage() {
|
|||||||
onToggleExpand={toggleExpand}
|
onToggleExpand={toggleExpand}
|
||||||
onAccept={setAcceptId}
|
onAccept={setAcceptId}
|
||||||
onEdit={setEditId}
|
onEdit={setEditId}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
|
||||||
|
{canWrite && (
|
||||||
<Link
|
<Link
|
||||||
to="/wizard"
|
to="/wizard"
|
||||||
aria-label="افزودن پیشنهاد جدید"
|
aria-label="افزودن پیشنهاد جدید"
|
||||||
@@ -159,6 +167,7 @@ export function DashboardPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="h-7 w-7" />
|
<Plus className="h-7 w-7" />
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
|
||||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
|
|||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
onAccept: (id: string) => void;
|
onAccept: (id: string) => void;
|
||||||
onEdit: (id: string) => void;
|
onEdit: (id: string) => void;
|
||||||
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
// دفتر پیشنهادها — فهرست کارتها (واکنشگرا و بهینه برای موبایل)
|
||||||
@@ -20,6 +21,7 @@ export function LedgerTable({
|
|||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onAccept,
|
onAccept,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
canWrite = true,
|
||||||
}: LedgerTableProps) {
|
}: LedgerTableProps) {
|
||||||
if (!quotes.length) {
|
if (!quotes.length) {
|
||||||
return (
|
return (
|
||||||
@@ -40,6 +42,7 @@ export function LedgerTable({
|
|||||||
onToggleExpand={() => onToggleExpand(q.id)}
|
onToggleExpand={() => onToggleExpand(q.id)}
|
||||||
onAccept={() => onAccept(q.id)}
|
onAccept={() => onAccept(q.id)}
|
||||||
onEdit={() => onEdit(q.id)}
|
onEdit={() => onEdit(q.id)}
|
||||||
|
canWrite={canWrite}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
|
|||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||||
@@ -35,7 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
|
||||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||||
const expired = isExpired(q);
|
const expired = isExpired(q);
|
||||||
const total = quoteTotal(q);
|
const total = quoteTotal(q);
|
||||||
@@ -46,7 +47,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
// ستون / دکمه پذیرش
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
if (q.status === "offered") {
|
if (q.status === "offered") {
|
||||||
acceptedCell = (
|
acceptedCell = canWrite ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -57,6 +58,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
<span>پذیرش</span>
|
<span>پذیرش</span>
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
|
||||||
);
|
);
|
||||||
} else if (q.accepted_date) {
|
} else if (q.accepted_date) {
|
||||||
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
||||||
@@ -185,6 +188,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* دکمه ویرایش */}
|
{/* دکمه ویرایش */}
|
||||||
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
||||||
@@ -198,6 +202,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<Pencil className="h-3 w-3 text-muted" />
|
<Pencil className="h-3 w-3 text-muted" />
|
||||||
<span className="hidden sm:inline">ویرایش</span>
|
<span className="hidden sm:inline">ویرایش</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* دکمه فلش اکسپند در هدر */}
|
{/* دکمه فلش اکسپند در هدر */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { FilePlus2, Info } from "lucide-react";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
import { useProjects } from "../../hooks";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژههای تخصیصیافته
|
||||||
|
export function RequesterHome() {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const projects = useProjects();
|
||||||
|
|
||||||
|
// پروژههای کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژههای عمومی)
|
||||||
|
const myProjects = useMemo(() => {
|
||||||
|
const list: { id: string; name: string }[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// ۱. پروژههای موجود در آرایه user.projects
|
||||||
|
for (const p of user?.projects ?? []) {
|
||||||
|
if (p.id && !seen.has(p.id)) {
|
||||||
|
seen.add(p.id);
|
||||||
|
list.push({ id: p.id, name: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. پروژهی اختصاصی فعال (project_id)
|
||||||
|
if (user?.project_id && !seen.has(user.project_id)) {
|
||||||
|
seen.add(user.project_id);
|
||||||
|
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیصیافته" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳. پروژههای عضویت member_project_ids تطبیقیافته با لیست پروژهها
|
||||||
|
const memberIdSet = new Set(user?.member_project_ids ?? []);
|
||||||
|
for (const p of projects) {
|
||||||
|
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
|
||||||
|
seen.add(p.id);
|
||||||
|
list.push({ id: p.id, name: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
|
||||||
|
if (list.length === 0 && user?.project_name) {
|
||||||
|
const match = projects.find((p) => p.name === user.project_name);
|
||||||
|
if (match && !seen.has(match.id)) {
|
||||||
|
list.push({ id: match.id, name: match.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [user, projects]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header
|
||||||
|
title="افزودن استعلام"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
|
/>
|
||||||
|
<PageLayout className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
شما مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{myProjects.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
|
||||||
|
هنوز پروژهای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{myProjects.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
to={`/wizard?project=${encodeURIComponent(p.name)}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<b className="text-[13.5px]">🏢 {p.name}</b>
|
||||||
|
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
|
||||||
|
</div>
|
||||||
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
|
||||||
|
<FilePlus2 className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/wizard"
|
||||||
|
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
|
||||||
|
>
|
||||||
|
<FilePlus2 className="h-3.5 w-3.5" />
|
||||||
|
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ export function ArrivalsPage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
const isPM = user?.role === "pm";
|
const isPM = user?.role === "pm";
|
||||||
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const canAccess = isAdmin || isPM || isSupervisor;
|
||||||
|
|
||||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||||
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
|
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
|
||||||
@@ -36,9 +38,9 @@ export function ArrivalsPage() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
|
||||||
const [arr, whs, incoming] = await Promise.all([
|
const [arr, whs, incoming] = await Promise.all([
|
||||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM / سرپرست را بررسی میکند
|
||||||
apiV26.pm.onTheWay(activeProject),
|
apiV26.pm.onTheWay(activeProject),
|
||||||
apiV26.warehouses.list(activeProject),
|
apiV26.warehouses.list(activeProject),
|
||||||
apiV26.projectTransfers.onTheWay(activeProject),
|
apiV26.projectTransfers.onTheWay(activeProject),
|
||||||
@@ -53,13 +55,13 @@ export function ArrivalsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [isPM, user?.project_id]);
|
}, [isPM, isSupervisor, user?.project_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin && !isPM) return;
|
if (!canAccess) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void load();
|
void load();
|
||||||
}, [isAdmin, isPM, load]);
|
}, [canAccess, load]);
|
||||||
|
|
||||||
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
@@ -266,12 +268,13 @@ export function ArrivalsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin && !isPM) {
|
if (!canAccess) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
|
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user