Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a | ||
|
|
7bb432e715 | ||
|
|
b82c039265 | ||
|
|
d84637769a | ||
|
|
b0776da061 | ||
|
|
d38cf38bd9 | ||
|
|
a1220db844 | ||
|
|
1190ec42eb | ||
|
|
2747f9d4bb | ||
|
|
934b74ebbd | ||
|
|
dcc4d693f5 | ||
|
|
64bd292335 | ||
|
|
98c4b28815 | ||
|
|
aba9ba6486 | ||
|
|
965054093e | ||
|
|
3931baadd5 | ||
|
|
1bf55ba8a6 | ||
|
|
5458a2f99f | ||
|
|
638076dbaa | ||
|
|
4b84844b54 | ||
|
|
ea6a377023 | ||
|
|
5400588751 | ||
|
|
0ea0322967 | ||
|
|
a84b33dde3 | ||
|
|
fbd34277c0 | ||
|
|
8c5f4ab770 | ||
|
|
af092fe4c8 | ||
|
|
07643dbdf5 | ||
|
|
0a7db5ebe9 | ||
|
|
da68fc683f | ||
|
|
17458d8697 | ||
|
|
1b5fc589fd | ||
|
|
a2e7c27e85 | ||
|
|
09245cdc2c | ||
|
|
dc31f837fd | ||
|
|
57e0e51c16 | ||
|
|
d85ede7d11 | ||
|
|
253c922280 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
da883a28c0 | ||
|
|
b6a1ce2ae7 | ||
|
|
64d80e15f8 | ||
|
|
9e19be8354 | ||
|
|
4cfb9ec6f6 | ||
|
|
6ee0822a96 |
@@ -26,8 +26,11 @@ frontend/.env
|
||||
|
||||
# ---- Local session / backups ----
|
||||
.zcode/
|
||||
.zcodeignore
|
||||
scratch/
|
||||
v1.2 - Copy/
|
||||
inv dash/
|
||||
voice/
|
||||
|
||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
||||
|
||||
Self-contained ship package: everything needed to run the stack is in this
|
||||
@@ -12,19 +13,22 @@ folder — no external files or sibling folders required.
|
||||
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
|
||||
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
|
||||
|
||||
|
||||
```
|
||||
|
||||
Browser ──► Traefik :80 (edge)
|
||||
│
|
||||
├── Host(localhost) ──► frontend (nginx SPA :80)
|
||||
│ │
|
||||
│ └── /api/* proxy ──► api :8000 (/api/v1)
|
||||
│
|
||||
├── Host(api.localhost) ──► api :8000
|
||||
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
|
||||
└── Host(s3/storage.localhost) ──► minio (API / console)
|
||||
│
|
||||
├── postgres :5432 (procurement + casdoor DBs)
|
||||
└── minio :9000 (invoice/proforma files)
|
||||
│
|
||||
├── Host(localhost) ──► frontend (nginx SPA :80)
|
||||
│ │
|
||||
│ └── /api/* proxy ──► api :8000 (/api/v1)
|
||||
│
|
||||
├── Host(api.localhost) ──► api :8000
|
||||
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
|
||||
└── Host(s3/storage.localhost) ──► minio (API / console)
|
||||
│
|
||||
├── postgres :5432 (procurement + casdoor DBs)
|
||||
└── minio :9000 (invoice/proforma files)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -74,6 +78,7 @@ v1.3/
|
||||
├── Projects/ (projects)
|
||||
├── Units/ (unit conversions)
|
||||
└── Inventory/ (warehouse management panel — React port)
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -86,12 +91,12 @@ the form (username / password) is sent to
|
||||
(password grant), verifies the returned JWT, and starts the session —
|
||||
no browser redirect to Casdoor.
|
||||
|
||||
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
||||
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
||||
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
||||
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
||||
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
||||
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
||||
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
||||
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
||||
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
||||
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
||||
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
||||
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
||||
|
||||
**Developer Mode** (top-left button on the login page) is a 3-step flow for
|
||||
the admin (super user): ① superuser auth gate (admin credentials only) →
|
||||
@@ -103,7 +108,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
|
||||
for production builds).
|
||||
|
||||
| What | Where | Credentials |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| App | http://localhost (via Traefik) | — |
|
||||
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
|
||||
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
|
||||
@@ -111,11 +116,11 @@ for production builds).
|
||||
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
||||
Casdoor organization, application and users (idempotent). Full details —
|
||||
addresses, credentials, role model, failure scenarios and the checklist —
|
||||
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
|
||||
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
|
||||
|
||||
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
||||
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
|
||||
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
|
||||
|
||||
---
|
||||
|
||||
@@ -125,13 +130,13 @@ The production deployment of this stack runs on the server and is reachable
|
||||
at these public addresses (HTTPS):
|
||||
|
||||
| What | Address |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
|
||||
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
|
||||
|
||||
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||
assigned inside the app (`users.role` — see «Login» above and
|
||||
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
|
||||
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
|
||||
|
||||
### MinIO console on the server (SSH tunnel)
|
||||
|
||||
@@ -140,21 +145,20 @@ The MinIO console is deliberately **loopback-only** on the server
|
||||
is an SSH tunnel from your machine:
|
||||
|
||||
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||
use the panel):
|
||||
use the panel):
|
||||
```powershell
|
||||
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||
|
||||
```powershell
|
||||
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||
```
|
||||
```
|
||||
|
||||
You will also land in a normal SSH session — that is fine; the tunnel
|
||||
lives as long as the window stays open.
|
||||
|
||||
You will also land in a normal SSH session — that is fine; the tunnel
|
||||
lives as long as the window stays open.
|
||||
2. In your browser, open: **http://localhost:9001**
|
||||
|
||||
3. Log in with the production MinIO root credentials from the server's
|
||||
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||
**never** written in this repo or its history. See
|
||||
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
|
||||
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||
**never** written in this repo or its history. See
|
||||
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
|
||||
|
||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||
ERP stores, browsable and downloadable from there.
|
||||
@@ -168,22 +172,22 @@ tunnel.
|
||||
|
||||
Notes from the server setup:
|
||||
|
||||
- The panel is published only on the server's loopback, so the internet
|
||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||
why the console uses `9001`.
|
||||
- If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||
with a password prompt on top), that can be arranged on request — the
|
||||
tunnel remains the safer default.
|
||||
* The panel is published only on the server's loopback, so the internet
|
||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||
* Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||
why the console uses `9001`.
|
||||
* If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||
with a password prompt on top), that can be arranged on request — the
|
||||
tunnel remains the safer default.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (target machine)
|
||||
|
||||
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||
- Internet access on first build (image + `npm ci` downloads)
|
||||
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||
* Internet access on first build (image + `npm ci` downloads)
|
||||
|
||||
---
|
||||
|
||||
@@ -192,6 +196,7 @@ Notes from the server setup:
|
||||
```powershell
|
||||
cd <this-folder>
|
||||
docker compose up -d --build
|
||||
|
||||
```
|
||||
|
||||
First run downloads base images and builds the frontend (takes a few minutes).
|
||||
@@ -201,6 +206,7 @@ Wait until all services are up:
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
|
||||
```
|
||||
|
||||
All should show `Up` (postgres also `(healthy)`).
|
||||
@@ -210,7 +216,7 @@ All should show `Up` (postgres also `(healthy)`).
|
||||
## Verify
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Frontend UI | http://localhost |
|
||||
| API docs (Swagger) | http://localhost:8000/docs |
|
||||
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
|
||||
@@ -225,23 +231,24 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
|
||||
```powershell
|
||||
backend\tools\smoke_test.py # read paths
|
||||
backend\tools\smoke_test_write.py / smoke_test_misc.py
|
||||
|
||||
```
|
||||
|
||||
> Every API route now requires a Casdoor JWT — a bare
|
||||
> `curl http://localhost/api/v1/quotes` returns **401** by design.
|
||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
|
||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
|
||||
|
||||
---
|
||||
|
||||
## MinIO (object storage for invoice / proforma files)
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
|
||||
| Web console | http://localhost:9001 |
|
||||
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
||||
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
|
||||
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
|
||||
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
|
||||
|
||||
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
|
||||
|
||||
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
|
||||
|
||||
```powershell
|
||||
.\mc.cmd ls --recursive
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -261,10 +269,12 @@ Port: 5433
|
||||
Database: procurement
|
||||
Username: postgres
|
||||
Password: postgres
|
||||
|
||||
```
|
||||
|
||||
```powershell
|
||||
docker exec -it procurement-postgres psql -U postgres -d procurement
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -276,26 +286,28 @@ database dumps must never be committed (they contain user rows and signing
|
||||
material). Two supported ways to bring a fresh stack up:
|
||||
|
||||
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|
||||
`postgres_data` volume; then run the seeders:
|
||||
`postgres_data` volume; then run the seeders:
|
||||
```powershell
|
||||
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
||||
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||
# Demo dataset (optional):
|
||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
||||
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||
# Demo dataset (optional):
|
||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||
```
|
||||
|
||||
2. **Restore your own dump** — create dumps on the server (kept server-side,
|
||||
e.g. `/srv/erp/backups/`), never in git:
|
||||
e.g. `/srv/erp/backups/`), never in git:
|
||||
```powershell
|
||||
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
|
||||
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
||||
|
||||
```powershell
|
||||
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
|
||||
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
||||
```
|
||||
```
|
||||
|
||||
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||
first — this **replaces all data**). The scheduled backup sidecar
|
||||
(see `docs/OPERATIONS.md`) automates this.
|
||||
|
||||
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||
first — this **replaces all data**). The scheduled backup sidecar
|
||||
(see `docs/OPERATIONS.md`) automates this.
|
||||
|
||||
## Common operations
|
||||
|
||||
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
|
||||
docker compose down # stop (keep data)
|
||||
docker compose up -d --build # rebuild after code changes
|
||||
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known behavior
|
||||
|
||||
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||
every API route** except `/health`, `/health/ready` and `/auth/login`
|
||||
(401 without a token, 403 without a role). The former dev-header fallback
|
||||
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
||||
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
||||
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||
the procurement manager can only assign projects to project managers.
|
||||
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
||||
grant/revoke «مدیر تدارکات»).
|
||||
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
||||
at least one item with a valid quantity is enough to submit; other items
|
||||
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
||||
badges and a «رسید جزیی» badge on the quote.
|
||||
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
||||
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
||||
dumped/restored; see `docs/casdoor-auth.md`).
|
||||
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
||||
serves the SPA and proxies `/api` internally. Production (real domains,
|
||||
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
||||
(see the prod file header for the required env vars).
|
||||
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
||||
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
||||
for the units-dashboard tables and `quote_attachments` at startup.
|
||||
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||
invoice/proforma download links from MinIO.
|
||||
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
||||
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||
reachable; check `docker compose ps` and `docker compose logs api`.
|
||||
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||
every API route** except `/health`, `/health/ready` and `/auth/login`
|
||||
(401 without a token, 403 without a role). The former dev-header fallback
|
||||
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
||||
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
||||
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||
the procurement manager can only assign projects to project managers.
|
||||
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
||||
grant/revoke «مدیر تدارکات»).
|
||||
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
||||
at least one item with a valid quantity is enough to submit; other items
|
||||
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
||||
badges and a «رسید جزیی» badge on the quote.
|
||||
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
||||
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
||||
dumped/restored; see `docs/casdoor-auth.md`).
|
||||
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
||||
serves the SPA and proxies `/api` internally. Production (real domains,
|
||||
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
||||
(see the prod file header for the required env vars).
|
||||
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
||||
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
||||
for the units-dashboard tables and `quote_attachments` at startup.
|
||||
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||
invoice/proforma download links from MinIO.
|
||||
* **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامینکنندهای برای این متریال ثبت نشده است») if none exist.
|
||||
* If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
||||
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
||||
reachable; check `docker compose ps` and `docker compose logs api`.
|
||||
|
||||
```
|
||||
|
||||
@@ -40,6 +40,9 @@ class Settings(BaseSettings):
|
||||
MINIO_BUCKET: str = "procurement-files"
|
||||
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.
|
||||
AUTO_EXPIRE_ENABLED: bool = True
|
||||
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
||||
|
||||
+197
-3
@@ -247,6 +247,7 @@ _V26_DDL = [
|
||||
physical_qty NUMERIC(18,6),
|
||||
transfer_pair_id UUID,
|
||||
performed_by TEXT,
|
||||
delivered_to TEXT,
|
||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
@@ -258,6 +259,9 @@ _V26_DDL = [
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
||||
""",
|
||||
"""
|
||||
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
|
||||
""",
|
||||
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
||||
"""
|
||||
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
||||
@@ -380,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||
c,
|
||||
)
|
||||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||
# v2.5/v2.6 category decoupling from materials: drop the old supplier-derived
|
||||
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
|
||||
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
|
||||
# Category→Material filter cascade (see ensure_dashboard_schema below).
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||
await run_statement("""
|
||||
@@ -410,6 +415,50 @@ async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||||
logger.info("category taxonomy tables dropped (attributes / ref units / conversions)")
|
||||
|
||||
|
||||
async def ensure_rbac_schema(engine: AsyncEngine) -> None:
|
||||
"""Panel-access schema (pm branch): users.username, projects planning
|
||||
columns, user_panels and user_project_members. Idempotent; safe on any
|
||||
volume — the RBAC model expects these and login 500s without them."""
|
||||
stmts = (
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_end DATE",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_start DATE",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE'",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now()",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_panels (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
panel TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_user_panel UNIQUE (user_id, panel)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_user_panels_user_id ON user_panels(user_id)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_project_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_user_project_member UNIQUE (user_id, project_id)
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS ix_user_project_members_user_id ON user_project_members(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_user_project_members_project_id ON user_project_members(project_id)",
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
await conn.execute(text(stmt))
|
||||
logger.info("RBAC schema ensured (username / project columns / user_panels / user_project_members)")
|
||||
|
||||
|
||||
async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||
"""v2.7: general activity trail (audit_log). Idempotent; safe on any volume."""
|
||||
ddl = """
|
||||
@@ -432,3 +481,148 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||
for stmt in indexes:
|
||||
await conn.execute(text(stmt))
|
||||
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:
|
||||
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
|
||||
ddl = """
|
||||
CREATE TABLE IF NOT EXISTS project_transfers (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
|
||||
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
|
||||
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
|
||||
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
|
||||
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
|
||||
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
specs_hash TEXT NOT NULL DEFAULT 'default',
|
||||
note TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
|
||||
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
|
||||
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
transferred_by TEXT,
|
||||
received_at TIMESTAMPTZ,
|
||||
received_by TEXT,
|
||||
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
|
||||
receipt_allocations JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
indexes = (
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
|
||||
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
|
||||
"""
|
||||
CREATE TRIGGER trg_project_transfers_updated_at
|
||||
BEFORE UPDATE ON project_transfers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||
""",
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(ddl))
|
||||
for stmt in indexes:
|
||||
await conn.execute(text(stmt))
|
||||
logger.info("project_transfers table ensured")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
|
||||
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
|
||||
# needs a real materials.category_id link. v2.6 had deliberately dropped the
|
||||
# old supplier-derived column; this is a fresh, topic-backfilled link
|
||||
# (nullable — a material may stay uncategorized). The rest is B-tree index
|
||||
# coverage for the aggregation queries in services/dashboard_service.py.
|
||||
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
|
||||
# backfill. Every statement is idempotent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DASHBOARD_SCHEMA_DDL = [
|
||||
# Fresh column (inline FK names the constraint materials_category_id_fkey,
|
||||
# matching the DO-block guard below for volumes that predate v2.8).
|
||||
"""
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
|
||||
""",
|
||||
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
|
||||
# groups those under «بدون دسته»).
|
||||
"""
|
||||
UPDATE materials SET category_id = mc.id
|
||||
FROM material_categories mc
|
||||
WHERE materials.topic = mc.name AND materials.category_id IS NULL
|
||||
""",
|
||||
# FK for the (rare) volume that already had the column without it.
|
||||
"""
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
|
||||
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
|
||||
# Drill-down / PM scoping: every dashboard query filters through it.
|
||||
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
|
||||
# Daily flow + daily control (date-range aggregation).
|
||||
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
|
||||
# Type slices (withdrawal / return / waste) inside a date range.
|
||||
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
|
||||
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
|
||||
# Funnel per-status counts (existing composite leads with supplier_id).
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
|
||||
# Project-density workload grouping.
|
||||
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
|
||||
# Receipt-level arrival reporting.
|
||||
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
|
||||
# «What»-filtered stock scans across warehouses (existing composites lead
|
||||
# with warehouse_id, useless for a bare material/brand filter).
|
||||
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
|
||||
]
|
||||
|
||||
|
||||
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
|
||||
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
|
||||
|
||||
Statement-per-transaction like ensure_v26: one failure can never block
|
||||
the rest of the upgrade. Safe to call on every startup.
|
||||
"""
|
||||
for ddl in _DASHBOARD_SCHEMA_DDL:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(ddl))
|
||||
except Exception: # pragma: no cover - pre-existing data conflicts
|
||||
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
|
||||
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
|
||||
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
|
||||
|
||||
@@ -27,10 +27,14 @@ from app.db.engine import AsyncSessionLocal, engine
|
||||
from app.db.init_db import (
|
||||
ensure_attachment_table,
|
||||
ensure_audit_table,
|
||||
ensure_bot_drafts_table,
|
||||
ensure_dashboard_schema,
|
||||
ensure_drop_category_tables,
|
||||
ensure_dashboard_tables,
|
||||
ensure_material_topic_column,
|
||||
ensure_quote_projects,
|
||||
ensure_rbac_schema,
|
||||
ensure_project_transfers_table,
|
||||
ensure_v26,
|
||||
)
|
||||
from app.observability import RequestContextMiddleware, setup_logging
|
||||
@@ -105,8 +109,12 @@ async def lifespan(_app: FastAPI):
|
||||
await ensure_material_topic_column(engine)
|
||||
await ensure_quote_projects(engine)
|
||||
await ensure_v26(engine)
|
||||
await ensure_rbac_schema(engine)
|
||||
await ensure_dashboard_schema(engine)
|
||||
await ensure_audit_table(engine)
|
||||
await ensure_drop_category_tables(engine)
|
||||
await ensure_bot_drafts_table(engine)
|
||||
await ensure_project_transfers_table(engine)
|
||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||
try:
|
||||
yield
|
||||
@@ -233,12 +241,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
|
||||
# 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.drafts import router as _drafts_router # noqa: E402
|
||||
|
||||
for router in ROUTERS:
|
||||
if router is _auth_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
elif router is _files_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
elif router is _drafts_router:
|
||||
app.include_router(router, prefix=API_V1_PREFIX)
|
||||
else:
|
||||
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ from app.models.procurement import (
|
||||
TermsheetScheduleStep,
|
||||
)
|
||||
|
||||
from app.models.project_transfer import ProjectTransfer
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Unit",
|
||||
@@ -60,4 +62,5 @@ __all__ = [
|
||||
"ItemDecision",
|
||||
"ItemReceipt",
|
||||
"QuoteAttachment",
|
||||
"ProjectTransfer",
|
||||
]
|
||||
|
||||
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||
# v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the
|
||||
# category taxonomy for the dashboard filter cascade (nullable — backfill
|
||||
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("material_categories.id", ondelete="SET NULL")
|
||||
)
|
||||
canonical_unit_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
@@ -147,6 +152,7 @@ class Material(Base):
|
||||
)
|
||||
|
||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||
back_populates="material", lazy="raise", passive_deletes=True
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
|
||||
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
||||
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
||||
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
||||
delivered_to: Mapped[str | None] = mapped_column(Text)
|
||||
transaction_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Numeric,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.catalog import _timestamptz, _uuid_pk
|
||||
|
||||
|
||||
class ProjectTransfer(Base):
|
||||
"""project_transfers — Inter-project (inter-site) material transfer ledger.
|
||||
|
||||
Records the movement of materials from a source project warehouse to a
|
||||
destination project. Stock is deducted from the source warehouse immediately
|
||||
upon creation (status = 'in_transit') via out_transaction_id.
|
||||
When the destination project PM confirms receipt in 'Arrivals', stock is
|
||||
added to the destination warehouse (status = 'received').
|
||||
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
|
||||
Once 'received', the transfer is permanently locked from edit or cancellation.
|
||||
"""
|
||||
|
||||
__tablename__ = "project_transfers"
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
from_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
to_project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
material_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
brand_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("brands.id", ondelete="SET NULL")
|
||||
)
|
||||
unit_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
|
||||
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
|
||||
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
|
||||
|
||||
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
|
||||
)
|
||||
transfer_date: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
transferred_by: Mapped[str | None] = mapped_column(Text)
|
||||
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
received_by: Mapped[str | None] = mapped_column(Text)
|
||||
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("warehouses.id", ondelete="SET NULL")
|
||||
)
|
||||
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = _timestamptz()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
|
||||
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
|
||||
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
|
||||
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
|
||||
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
|
||||
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
|
||||
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
|
||||
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
|
||||
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
|
||||
)
|
||||
@@ -4,6 +4,8 @@ from app.routers import (
|
||||
auth,
|
||||
brand_materials,
|
||||
brands,
|
||||
dashboard,
|
||||
drafts,
|
||||
files,
|
||||
inventory,
|
||||
item_decisions,
|
||||
@@ -14,6 +16,7 @@ from app.routers import (
|
||||
material_categories,
|
||||
materials,
|
||||
pm,
|
||||
project_transfers,
|
||||
projects,
|
||||
quote_events,
|
||||
quotes,
|
||||
@@ -50,5 +53,8 @@ ROUTERS = [
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
project_transfers.router,
|
||||
dashboard.router,
|
||||
users.router,
|
||||
drafts.router,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""MVP dashboard router — read-only aggregations for the warehouse +
|
||||
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
|
||||
client (frontend/src/lib/api.ts).
|
||||
|
||||
Scope (same resolution pattern as the inventory / pm routers):
|
||||
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
|
||||
an explicit ?project_id must be one of them.
|
||||
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
|
||||
an optional filter.
|
||||
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
|
||||
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
|
||||
inventory router).
|
||||
|
||||
Endpoints:
|
||||
- GET /dashboard/filters — «What» cascade options
|
||||
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
|
||||
- GET /dashboard/inventory/dead-stock — dormant stock table
|
||||
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
|
||||
- GET /dashboard/daily-control — today's tickets + performers
|
||||
- GET /dashboard/procurement/workload — workload per project (density)
|
||||
|
||||
Every response is the standard `{"data": …}` envelope.
|
||||
"""
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.services import dashboard_service
|
||||
from app.utils.errors import (
|
||||
ForbiddenError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class DashboardScope:
|
||||
role: str # "pm" | "office"
|
||||
project_ids: list | None # PM: managed project ids; office: None = all
|
||||
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
|
||||
|
||||
|
||||
async def dashboard_scope(
|
||||
request: Request,
|
||||
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژههای خودش)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DashboardScope:
|
||||
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
|
||||
if request.headers.get("authorization"):
|
||||
user: UserContext | None = await get_optional_user(request, db)
|
||||
if user is not None:
|
||||
if user.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
if user.role == "pm":
|
||||
allowed = list(user.project_ids or [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
|
||||
if not allowed:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
|
||||
if user.role in ("admin", "procurement_manager"):
|
||||
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
|
||||
|
||||
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
|
||||
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role == "procurement_manager" or role == "admin":
|
||||
role = "office"
|
||||
elif role == "pm":
|
||||
role = "pm"
|
||||
else:
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
|
||||
if role == "pm":
|
||||
raw = request.headers.get("x-project-id")
|
||||
if not raw:
|
||||
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
|
||||
try:
|
||||
if project_id is None:
|
||||
project_id = uuid.UUID(str(raw))
|
||||
except ValueError:
|
||||
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
|
||||
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
||||
|
||||
|
||||
def _what_filters(
|
||||
material_id: uuid.UUID | None,
|
||||
brand_id: uuid.UUID | None,
|
||||
specs_hash: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"material_id": material_id,
|
||||
"brand_id": brand_id,
|
||||
"specs_hash": specs_hash,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/filters")
|
||||
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||
"""Options for the global filter bar (Material→Brand→Specs)."""
|
||||
return await dashboard_service.filter_options(db)
|
||||
|
||||
|
||||
@router.get("/inventory/drilldown")
|
||||
async def inventory_drilldown(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
):
|
||||
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
||||
return await dashboard_service.inventory_drilldown(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inventory/dead-stock")
|
||||
async def dead_stock(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
):
|
||||
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||
return await dashboard_service.dead_stock(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
idle_days=idle_days,
|
||||
limit=limit,
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/inventory/flow")
|
||||
async def inventory_flow(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
):
|
||||
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||
date_from = date_from or (today - timedelta(days=29))
|
||||
date_to = date_to or (today + timedelta(days=1))
|
||||
if date_to <= date_from:
|
||||
raise ValidationError("بازهی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
|
||||
if (date_to - date_from).days > 180:
|
||||
raise ValidationError("بازهی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
|
||||
return await dashboard_service.daily_flow(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||
warehouse_id=warehouse_id,
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/daily-control")
|
||||
async def daily_control(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیشفرض امروز)"),
|
||||
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||
):
|
||||
"""Daily control: today's outbound/return tickets + exact performer names."""
|
||||
day = date or datetime.now(dashboard_service.TEHRAN).date()
|
||||
return await dashboard_service.daily_control(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
day=day,
|
||||
warehouse_id=warehouse_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/procurement/workload")
|
||||
async def procurement_workload(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژهها."""
|
||||
return await dashboard_service.procurement_workload(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
)
|
||||
@@ -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)}
|
||||
@@ -12,6 +12,7 @@ import base64
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
@@ -214,6 +215,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
|
||||
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||
"performed_by": tx.performed_by,
|
||||
"delivered_to": tx.delivered_to,
|
||||
"transaction_date": to_iso(tx.transaction_date),
|
||||
"note": tx.note,
|
||||
}
|
||||
@@ -447,9 +449,18 @@ async def list_transactions(
|
||||
if transaction_type is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
||||
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||
stmt = stmt.where(
|
||||
InventoryTransaction.transaction_date
|
||||
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||
)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
||||
stmt = stmt.where(
|
||||
InventoryTransaction.transaction_date
|
||||
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||
)
|
||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
|
||||
@@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import BrandMaterial, Material, Unit
|
||||
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemReceipt,
|
||||
ItemReceiptAllocation,
|
||||
LineItem,
|
||||
LineItemSpec,
|
||||
Project,
|
||||
Quote,
|
||||
Supplier,
|
||||
@@ -182,16 +183,24 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
||||
async def pm_on_the_way(
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
|
||||
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
|
||||
):
|
||||
stmt = (
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.join(Supplier, Supplier.id == Quote.supplier_id)
|
||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
|
||||
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
|
||||
.where(ItemDecision.status == "chosen")
|
||||
)
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(Material.id == material_id)
|
||||
if brand_id is not None:
|
||||
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
|
||||
if ctx.role == "pm":
|
||||
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||
if not allowed:
|
||||
@@ -211,8 +220,29 @@ async def pm_on_the_way(
|
||||
# unit / category lookups for display
|
||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
# frozen spec snapshot per line item (key = attribute label)
|
||||
li_ids = [d.LineItem.id for d in decisions]
|
||||
specs_by_line: dict = {}
|
||||
if li_ids:
|
||||
spec_rows = (
|
||||
await db.execute(
|
||||
select(LineItemSpec, AttributeDefinition)
|
||||
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
|
||||
.where(LineItemSpec.line_item_id.in_(li_ids))
|
||||
)
|
||||
).all()
|
||||
for spec, ad in spec_rows:
|
||||
value = spec.value_string
|
||||
if value is None and spec.value_number is not None:
|
||||
value = str(spec.value_number)
|
||||
if value is None and spec.value_boolean is not None:
|
||||
value = str(spec.value_boolean)
|
||||
if value is None and spec.value_date is not None:
|
||||
value = spec.value_date.isoformat()
|
||||
if value is not None:
|
||||
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
|
||||
out = []
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
for d, li, q, m, sup, proj, bm, br in decisions:
|
||||
received = received_map.get(d.id)
|
||||
out.append({
|
||||
"item_decision_id": str(d.id),
|
||||
@@ -224,6 +254,8 @@ async def pm_on_the_way(
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"material": {"id": str(m.id), "name": m.name},
|
||||
"brand": {"id": str(br.id), "name": br.name} if br else None,
|
||||
"specs": specs_by_line.get(li.id, {}),
|
||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||
"ordered_qty": str(li.quantity),
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.routers.pm import ArrivalScope, arrivals_scope
|
||||
from app.schemas.project_transfers import (
|
||||
ProjectTransferCreate,
|
||||
ProjectTransferOut,
|
||||
ProjectTransferReceive,
|
||||
ProjectTransferUpdate,
|
||||
)
|
||||
from app.services import project_transfer_service
|
||||
from app.utils.errors import ForbiddenError
|
||||
|
||||
router = APIRouter(tags=["project-transfers"])
|
||||
|
||||
|
||||
def _to_transfer_out(t) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"from_project": (
|
||||
{"id": t.from_project.id, "name": t.from_project.name}
|
||||
if t.from_project
|
||||
else {"id": t.from_project_id, "name": ""}
|
||||
),
|
||||
"from_warehouse": (
|
||||
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
|
||||
if t.from_warehouse
|
||||
else {"id": t.from_warehouse_id, "name": ""}
|
||||
),
|
||||
"to_project": (
|
||||
{"id": t.to_project.id, "name": t.to_project.name}
|
||||
if t.to_project
|
||||
else {"id": t.to_project_id, "name": ""}
|
||||
),
|
||||
"to_warehouse": (
|
||||
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
|
||||
if t.to_warehouse
|
||||
else None
|
||||
),
|
||||
"material": (
|
||||
{"id": t.material.id, "name": t.material.name}
|
||||
if t.material
|
||||
else {"id": t.material_id, "name": ""}
|
||||
),
|
||||
"brand": (
|
||||
{"id": t.brand.id, "name": t.brand.name}
|
||||
if t.brand
|
||||
else None
|
||||
),
|
||||
"unit": (
|
||||
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
|
||||
if t.unit
|
||||
else {"id": t.unit_id, "code": "", "name_fa": None}
|
||||
),
|
||||
"quantity": t.quantity,
|
||||
"specs": t.specs or {},
|
||||
"specs_hash": t.specs_hash,
|
||||
"note": t.note,
|
||||
"status": t.status,
|
||||
"transfer_date": t.transfer_date,
|
||||
"transferred_by": t.transferred_by,
|
||||
"received_at": t.received_at,
|
||||
"received_by": t.received_by,
|
||||
"receipt_allocations": t.receipt_allocations,
|
||||
"created_at": t.created_at,
|
||||
"updated_at": t.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inventory/project-transfers — Create inter-project transfer
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/inventory/project-transfers", status_code=201)
|
||||
async def create_transfer(
|
||||
payload: ProjectTransferCreate,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.create_project_transfer(
|
||||
db=db,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
transferred_by=ctx.display_name,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /inventory/project-transfers — List transfers with direction and filters
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/inventory/project-transfers")
|
||||
async def list_transfers(
|
||||
project_id: uuid.UUID | None = Query(default=None),
|
||||
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
|
||||
status: str | None = Query(default=None),
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target_pid = project_id or ctx.project_id
|
||||
transfers = await project_transfer_service.list_project_transfers(
|
||||
db=db,
|
||||
project_id=target_pid,
|
||||
direction=direction,
|
||||
status=status,
|
||||
)
|
||||
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /inventory/project-transfers/{id} — Single transfer details
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/inventory/project-transfers/{transfer_id}")
|
||||
async def get_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
|
||||
if ctx.role == "pm" and ctx.project_id is not None:
|
||||
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.patch("/inventory/project-transfers/{transfer_id}")
|
||||
async def update_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferUpdate,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.update_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.delete("/inventory/project-transfers/{transfer_id}")
|
||||
async def delete_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
res = await project_transfer_service.delete_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
)
|
||||
return {"data": res}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/pm/transfers/on-the-way")
|
||||
async def pm_incoming_transfers(
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید")
|
||||
|
||||
transfers = await project_transfer_service.list_project_transfers(
|
||||
db=db,
|
||||
project_id=ctx.project_id,
|
||||
direction="incoming",
|
||||
status="in_transit",
|
||||
)
|
||||
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/inventory/project-transfers/{transfer_id}/receive")
|
||||
async def receive_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferReceive,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.receive_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
received_by=ctx.display_name,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
||||
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
||||
GET /materials/{id}/suppliers — price-comparison data source.
|
||||
GET /materials/{id}/suppliers — suppliers linked to a material.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,29 +24,47 @@ router = APIRouter(tags=["supplier-materials"])
|
||||
_procurement_writer = require_procurement_writer()
|
||||
|
||||
|
||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
||||
category: MaterialCategory | None = None,
|
||||
supplier: Supplier | None = None) -> dict:
|
||||
def link_to_dict(
|
||||
link: SupplierMaterial,
|
||||
material: Material | None = None,
|
||||
category: MaterialCategory | None = None,
|
||||
supplier: Supplier | None = None,
|
||||
) -> dict:
|
||||
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
|
||||
return {
|
||||
"id": str(link.id),
|
||||
"supplier_id": str(link.supplier_id),
|
||||
"material_id": str(link.material_id),
|
||||
"is_active": link.is_active,
|
||||
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
|
||||
"last_quoted_price": (
|
||||
float(link.last_quoted_price) if link.last_quoted_price is not None else None
|
||||
),
|
||||
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
||||
"created_at": to_iso(link.created_at),
|
||||
"material": {
|
||||
"id": str(material.id),
|
||||
"name": material.name,
|
||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
||||
} if material else None,
|
||||
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
||||
"material": (
|
||||
{
|
||||
"id": str(material.id),
|
||||
"name": material.name,
|
||||
"category": (
|
||||
{"id": str(category.id), "name": category.name} if category else None
|
||||
),
|
||||
}
|
||||
if material
|
||||
else None
|
||||
),
|
||||
"supplier": (
|
||||
{"id": str(supplier.id), "name": supplier.name} if supplier else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /suppliers/{supplier_id}/materials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/suppliers/{supplier_id}/materials")
|
||||
async def list_supplier_materials(
|
||||
supplier_id,
|
||||
supplier_id: UUID,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -58,14 +78,24 @@ async def list_supplier_materials(
|
||||
.order_by(SupplierMaterial.created_at)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
mat_ids = {l.material_id for l in items}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
||||
mat_ids = [lnk.material_id for lnk in items]
|
||||
materials: dict = {}
|
||||
if mat_ids:
|
||||
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
|
||||
materials = {m.id: m for m in result.scalars()}
|
||||
return {
|
||||
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /suppliers/{supplier_id}/materials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||
async def create_supplier_material(
|
||||
supplier_id,
|
||||
supplier_id: UUID,
|
||||
payload: SupplierMaterialCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
@@ -73,13 +103,17 @@ async def create_supplier_material(
|
||||
async with db.begin():
|
||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||
material = await db.get(Material, payload.material_id)
|
||||
return {"data": link_to_dict(link, material, None)}
|
||||
return {"data": link_to_dict(link, material)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /suppliers/{supplier_id}/materials/{material_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||
async def delete_supplier_material(
|
||||
supplier_id,
|
||||
material_id,
|
||||
supplier_id: UUID,
|
||||
material_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_auth=Depends(_procurement_writer),
|
||||
):
|
||||
@@ -88,22 +122,66 @@ async def delete_supplier_material(
|
||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||
|
||||
|
||||
@router.get("/materials/{material_id}/suppliers")
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /materials/{material_id}/suppliers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/materials/{material_id}/suppliers",
|
||||
summary="List all suppliers linked to a material",
|
||||
)
|
||||
async def list_material_suppliers(
|
||||
material_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
material_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the suppliers linked to a material, straight from PostgreSQL.
|
||||
|
||||
The path parameter is typed as UUID so FastAPI validates and coerces it
|
||||
before the handler runs; a malformed id is rejected with 422, an unknown
|
||||
material with 404.
|
||||
|
||||
Atomic JOIN — a supplier is returned only through its supplier_materials
|
||||
link, so the response can never contain a supplier that is not actually
|
||||
linked to this material:
|
||||
|
||||
select(Supplier)
|
||||
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
|
||||
Response envelope (frontend MaterialSupplier binds these fields directly):
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "<supplier_uuid>",
|
||||
"name": "فولاد ناب آریا",
|
||||
"phone": "021-..." | null,
|
||||
"contact_person": "علی محمدی" | null,
|
||||
"location": "تهران" | null
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
|
||||
stmt = (
|
||||
select(SupplierMaterial)
|
||||
select(Supplier)
|
||||
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
||||
.order_by(Supplier.name)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
sup_ids = {l.supplier_id for l in items}
|
||||
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
||||
suppliers = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": str(supplier.id),
|
||||
"name": supplier.name,
|
||||
"phone": supplier.phone,
|
||||
"contact_person": supplier.contact_person,
|
||||
"location": supplier.location,
|
||||
}
|
||||
for supplier in suppliers
|
||||
]
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
|
||||
unit_id: int | None = None
|
||||
specs: dict | None = None
|
||||
note: str | None = None
|
||||
delivered_to: str | None = None
|
||||
transaction_date: datetime | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -140,6 +140,11 @@ class AcceptTermsheetIn(APIModel):
|
||||
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
||||
|
||||
|
||||
class ProjectAllocationIn(APIModel):
|
||||
project_id: UUID
|
||||
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
|
||||
|
||||
|
||||
class ItemDecisionIn(APIModel):
|
||||
line_item_id: UUID
|
||||
status: Literal["chosen", "rejected"]
|
||||
@@ -147,6 +152,7 @@ class ItemDecisionIn(APIModel):
|
||||
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
||||
adjusted_notes: str | None = None
|
||||
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
||||
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
|
||||
|
||||
|
||||
class ItemProjectsIn(APIModel):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.schemas.common import APIModel
|
||||
|
||||
|
||||
class WarehouseAllocationIn(APIModel):
|
||||
warehouse_id: UUID
|
||||
quantity: Decimal = Field(gt=0)
|
||||
|
||||
|
||||
class ProjectTransferCreate(APIModel):
|
||||
from_warehouse_id: UUID
|
||||
to_project_id: UUID
|
||||
material_id: UUID
|
||||
brand_id: UUID | None = None
|
||||
unit_id: int
|
||||
quantity: Decimal = Field(gt=0)
|
||||
specs: dict = Field(default_factory=dict)
|
||||
note: str | None = None
|
||||
transfer_date: datetime | None = None
|
||||
|
||||
|
||||
class ProjectTransferUpdate(APIModel):
|
||||
to_project_id: UUID | None = None
|
||||
quantity: Decimal | None = Field(default=None, gt=0)
|
||||
note: str | None = None
|
||||
transfer_date: datetime | None = None
|
||||
|
||||
|
||||
class ProjectTransferReceive(APIModel):
|
||||
allocations: list[WarehouseAllocationIn] = Field(
|
||||
min_length=1,
|
||||
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
|
||||
)
|
||||
received_at: datetime | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class IdNameOut(APIModel):
|
||||
id: UUID
|
||||
name: str
|
||||
|
||||
|
||||
class UnitBriefOut(APIModel):
|
||||
id: int
|
||||
code: str
|
||||
name_fa: str | None = None
|
||||
|
||||
|
||||
class ProjectTransferOut(APIModel):
|
||||
id: UUID
|
||||
from_project: IdNameOut
|
||||
from_warehouse: IdNameOut
|
||||
to_project: IdNameOut
|
||||
to_warehouse: IdNameOut | None = None
|
||||
material: IdNameOut
|
||||
brand: IdNameOut | None = None
|
||||
unit: UnitBriefOut
|
||||
quantity: Decimal
|
||||
specs: dict
|
||||
specs_hash: str
|
||||
note: str | None = None
|
||||
status: str
|
||||
transfer_date: datetime
|
||||
transferred_by: str | None = None
|
||||
received_at: datetime | None = None
|
||||
received_by: str | None = None
|
||||
receipt_allocations: list[dict] | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -0,0 +1,732 @@
|
||||
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
|
||||
|
||||
Read-only service. Every function receives the resolved scope (PM → the list
|
||||
of projects they manage; office roles → project_ids=None = whole
|
||||
organization) plus an optional explicit project filter, and the optional
|
||||
«What» filters (material_id / brand_id / specs_hash).
|
||||
|
||||
Quantities are NEVER mixed across units: inventory rows live in the
|
||||
material's canonical unit, so stock aggregates carry their unit code and the
|
||||
UI picks one unit at a time. Numeric results leave the service chart-ready
|
||||
(floats); identifiers and labels are plain strings.
|
||||
|
||||
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
|
||||
exactly — this module is the server side of that contract.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from app.models.catalog import Brand, BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemReceipt,
|
||||
LineItem,
|
||||
Project,
|
||||
Quote,
|
||||
QuoteProjects,
|
||||
)
|
||||
|
||||
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
|
||||
# daily widgets means today at this offset.
|
||||
TEHRAN = timezone(timedelta(hours=3, minutes=30))
|
||||
|
||||
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
|
||||
# separately so the UI can stack it as a distinct red segment inside the
|
||||
# outbound bar. Transfers are excluded from the buckets (they are internal
|
||||
# moves) but DO count toward the running balance.
|
||||
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
|
||||
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
||||
|
||||
|
||||
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
|
||||
"""«What» filters for a statement that already joins inventory→material."""
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||
if brand_id is not None:
|
||||
stmt = stmt.where(InventoryItem.brand_id == brand_id)
|
||||
if specs_hash:
|
||||
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
|
||||
return stmt
|
||||
|
||||
|
||||
def _scope_filters(stmt, *, project_ids, project_id):
|
||||
"""warehouse.project_id restriction for the resolved dashboard scope."""
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
elif project_ids is not None:
|
||||
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
|
||||
return stmt
|
||||
|
||||
|
||||
def _qty(value) -> float:
|
||||
return round(float(value), 4) if value is not None else 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# «What» filter cascade — Category → Material → Brand → Specs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def filter_options(db: AsyncSession) -> dict:
|
||||
"""Options for the global filter bar (Material → Brand → Specs).
|
||||
|
||||
Materials come from the catalog (active only). Brand options per material
|
||||
are the UNION of the catalog links (brand_materials — every brand defined
|
||||
for that material) and the distinct brands actually present in stock, so
|
||||
the cascade is complete even for materials whose current stock rows carry
|
||||
no brand yet. Spec options come from stock rows (inventory.specs_hash),
|
||||
since specs are a stock-level dimension.
|
||||
"""
|
||||
materials = (
|
||||
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
||||
).scalars().all()
|
||||
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
|
||||
|
||||
inv_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
InventoryItem.material_id,
|
||||
InventoryItem.brand_id,
|
||||
InventoryItem.specs_hash,
|
||||
InventoryItem.specs,
|
||||
Brand.name,
|
||||
)
|
||||
.select_from(InventoryItem)
|
||||
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||
.distinct()
|
||||
)
|
||||
).all()
|
||||
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
|
||||
catalog_brand_rows = (
|
||||
await db.execute(
|
||||
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
||||
.select_from(BrandMaterial)
|
||||
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
||||
.where(BrandMaterial.is_active.is_(True))
|
||||
)
|
||||
).all()
|
||||
|
||||
brands_by_material: dict[uuid.UUID, dict] = {}
|
||||
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
|
||||
for material_id, brand_id, brand_name in catalog_brand_rows:
|
||||
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
|
||||
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
|
||||
if brand_id is not None:
|
||||
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
|
||||
if specs_hash:
|
||||
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
|
||||
|
||||
def material_entry(m: Material) -> dict:
|
||||
unit = units.get(m.canonical_unit_id)
|
||||
brands = brands_by_material.get(m.id, {})
|
||||
spec_map = specs_by_material.get(m.id, {})
|
||||
return {
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
|
||||
"brands": [
|
||||
{"id": str(bid), "name": bname}
|
||||
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
|
||||
],
|
||||
"specs": [
|
||||
{"hash": h, "specs": sp}
|
||||
for h, sp in sorted(spec_map.items())
|
||||
],
|
||||
}
|
||||
|
||||
# فهرست تخت متریالها — سرآغاز آبشار سهفیلتری (متریال → برند → مشخصات)
|
||||
return {"data": {"materials": [material_entry(m) for m in materials]}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget 1 — Drill-down matrix (organization → project → warehouse)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def inventory_drilldown(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_ids,
|
||||
project_id,
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
) -> dict:
|
||||
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||
|
||||
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
|
||||
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
|
||||
warehouse, unit) stock aggregation. Active catalog materials without any
|
||||
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
|
||||
(labelled with their canonical unit) instead of disappearing in an inner
|
||||
join.
|
||||
"""
|
||||
# تجمیع دفتر انبار بهازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
|
||||
# راست LEFT JOIN؛ محدودیت دامنه (پروژههای مجاز) همینجا اعمال میشود تا
|
||||
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
|
||||
stock = (
|
||||
select(
|
||||
InventoryItem.material_id.label("material_id"),
|
||||
InventoryItem.brand_id.label("brand_id"),
|
||||
InventoryItem.specs_hash.label("specs_hash"),
|
||||
InventoryItem.specs.label("specs"),
|
||||
Project.id.label("pid"),
|
||||
Project.name.label("pname"),
|
||||
Warehouse.id.label("wid"),
|
||||
Warehouse.name.label("wname"),
|
||||
Unit.code.label("ucode"),
|
||||
Unit.name_fa.label("uname_fa"),
|
||||
func.sum(InventoryItem.quantity).label("qty"),
|
||||
func.count().label("rows"),
|
||||
)
|
||||
.select_from(InventoryItem)
|
||||
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
.join(Project, Project.id == Warehouse.project_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.group_by(
|
||||
InventoryItem.material_id,
|
||||
InventoryItem.brand_id,
|
||||
InventoryItem.specs_hash,
|
||||
InventoryItem.specs,
|
||||
Project.id,
|
||||
Project.name,
|
||||
Warehouse.id,
|
||||
Warehouse.name,
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
)
|
||||
)
|
||||
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
|
||||
stock_sq = stock.subquery()
|
||||
|
||||
# واحد کاتالوگی ماده — برچسب واحدِ ردیفهای صفر («۰ عدد»)
|
||||
canon_unit = aliased(Unit)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Material.id.label("mid"),
|
||||
Material.name.label("mname"),
|
||||
Brand.name.label("bname"),
|
||||
stock_sq.c.specs,
|
||||
stock_sq.c.pid,
|
||||
stock_sq.c.pname,
|
||||
stock_sq.c.wid,
|
||||
stock_sq.c.wname,
|
||||
stock_sq.c.ucode,
|
||||
stock_sq.c.uname_fa,
|
||||
func.coalesce(stock_sq.c.qty, 0).label("qty"),
|
||||
func.coalesce(stock_sq.c.rows, 0).label("rows"),
|
||||
canon_unit.code.label("canon_ucode"),
|
||||
canon_unit.name_fa.label("canon_uname_fa"),
|
||||
)
|
||||
.select_from(Material)
|
||||
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
|
||||
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
|
||||
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
|
||||
.where(Material.status == "active")
|
||||
)
|
||||
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(Material.id == material_id)
|
||||
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||
# موجودی روی محور میمانند (رفتار قبلی دستنخورده)
|
||||
if brand_id is not None:
|
||||
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
|
||||
if specs_hash:
|
||||
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
units_legend: dict[str, str] = {}
|
||||
total_by_unit: dict[str, float] = {}
|
||||
total_rows = 0
|
||||
|
||||
materials_tree: dict[str, dict] = {}
|
||||
|
||||
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
|
||||
q = _qty(qty)
|
||||
|
||||
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||
if ucode is not None:
|
||||
units_legend[ucode] = uname_fa
|
||||
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||
elif canon_ucode is not None:
|
||||
units_legend.setdefault(canon_ucode, canon_uname_fa)
|
||||
total_by_unit.setdefault(canon_ucode, 0.0)
|
||||
total_rows += row_count
|
||||
|
||||
# Build full material name
|
||||
parts = [mname]
|
||||
if bname:
|
||||
parts.append(bname)
|
||||
if specs:
|
||||
parts.extend(str(v) for v in specs.values() if v)
|
||||
full_name = " ".join(parts)
|
||||
# Use full_name + unit as unique key for material level, or just full_name
|
||||
mat_key = f"{mid}_{full_name}"
|
||||
|
||||
mat = materials_tree.setdefault(
|
||||
mat_key,
|
||||
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
||||
)
|
||||
if ucode is not None:
|
||||
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||
elif canon_ucode is not None:
|
||||
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
|
||||
mat["rows"] += row_count
|
||||
|
||||
# مواد بدون هیچ ردیف موجودی: فقط روی محور میمانند (بدون پروژه/انبار)
|
||||
if pid is None:
|
||||
continue
|
||||
|
||||
proj = mat["_projects"].setdefault(
|
||||
pid,
|
||||
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
||||
)
|
||||
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||
proj["rows"] += row_count
|
||||
|
||||
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
|
||||
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||
wh["rows"] += row_count
|
||||
|
||||
materials = []
|
||||
for mat in materials_tree.values():
|
||||
projects_list = []
|
||||
for proj in mat.pop("_projects").values():
|
||||
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||
projects_list.append({**proj, "warehouses": warehouses})
|
||||
projects_list.sort(key=lambda p: p["name"])
|
||||
materials.append({**mat, "projects": projects_list})
|
||||
|
||||
materials.sort(key=lambda m: m["name"])
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"units": units_legend,
|
||||
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||
"materials": materials,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget 2 — Dead stock (dormant capital)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def dead_stock(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_ids,
|
||||
project_id,
|
||||
idle_days: int = 60,
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
|
||||
|
||||
Last activity = MAX(inventory_transactions.transaction_date) per row —
|
||||
served by idx_inventory_tx_inventory_date — falling back to
|
||||
inventory.updated_at for rows that never posted a transaction.
|
||||
"""
|
||||
last_tx = (
|
||||
select(func.max(InventoryTransaction.transaction_date))
|
||||
.where(InventoryTransaction.inventory_id == InventoryItem.id)
|
||||
.correlate(InventoryItem)
|
||||
.scalar_subquery()
|
||||
)
|
||||
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
|
||||
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
InventoryItem.id,
|
||||
InventoryItem.quantity,
|
||||
InventoryItem.specs,
|
||||
Material.name,
|
||||
Brand.name,
|
||||
Warehouse.name,
|
||||
Project.name,
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
last_activity.label("last_activity"),
|
||||
)
|
||||
.select_from(InventoryItem)
|
||||
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
.join(Project, Project.id == Warehouse.project_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
|
||||
.order_by(last_activity.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
stmt = _stock_filters(
|
||||
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||
)
|
||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
now = datetime.now(TEHRAN)
|
||||
items = []
|
||||
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
|
||||
items.append({
|
||||
"inventory_id": str(inv_id),
|
||||
"material": mat_name,
|
||||
"brand": brand_name,
|
||||
"specs": specs or {},
|
||||
"warehouse": wh_name,
|
||||
"project": proj_name,
|
||||
"quantity": _qty(quantity),
|
||||
"unit": {"code": ucode, "name_fa": uname_fa},
|
||||
"last_activity": last.isoformat() if last else None,
|
||||
"idle_days": (now - last).days if last else None,
|
||||
})
|
||||
return {"data": {"idle_days_threshold": idle_days, "items": items}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def daily_flow(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_ids,
|
||||
project_id,
|
||||
date_from: datetime,
|
||||
date_to: datetime,
|
||||
warehouse_id=None,
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
) -> dict:
|
||||
"""Per-day buckets from the signed ledger.
|
||||
|
||||
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
|
||||
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
||||
balance = running sum of EVERY signed movement (transfers/found/stock
|
||||
counts included), seeded with the opening balance before `date_from`.
|
||||
"""
|
||||
def _with_joins(sel):
|
||||
return (
|
||||
sel.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
)
|
||||
|
||||
scope_conditions = []
|
||||
if project_id is not None:
|
||||
scope_conditions.append(Warehouse.project_id == project_id)
|
||||
elif project_ids is not None:
|
||||
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||
if warehouse_id is not None:
|
||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||
what_conditions = []
|
||||
if material_id is not None:
|
||||
what_conditions.append(InventoryItem.material_id == material_id)
|
||||
if brand_id is not None:
|
||||
what_conditions.append(InventoryItem.brand_id == brand_id)
|
||||
if specs_hash:
|
||||
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
||||
|
||||
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
|
||||
bucket_rows = (
|
||||
await db.execute(
|
||||
_with_joins(
|
||||
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity))
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= date_from,
|
||||
InventoryTransaction.transaction_date < date_to,
|
||||
*scope_conditions,
|
||||
*what_conditions,
|
||||
)
|
||||
.group_by(day_expr, InventoryTransaction.transaction_type)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
opening = _qty(
|
||||
await db.scalar(
|
||||
_with_joins(
|
||||
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
|
||||
InventoryTransaction.transaction_date < date_from,
|
||||
*scope_conditions,
|
||||
*what_conditions,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
||||
for day, tx_type, signed in bucket_rows:
|
||||
day_key = day.date() if isinstance(day, datetime) else day
|
||||
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
|
||||
|
||||
days = []
|
||||
running = opening
|
||||
current = date_from.date()
|
||||
while current < date_to.date():
|
||||
types = signed_by_day.get(current, {})
|
||||
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
|
||||
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
|
||||
running = round(running + sum(types.values()), 4)
|
||||
days.append({
|
||||
"date": current.isoformat(),
|
||||
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
|
||||
"outbound": outbound,
|
||||
"waste": waste,
|
||||
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
||||
"balance": running,
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
|
||||
return {"data": {"opening_balance": opening, "days": days}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget 4 — Daily control (today's site operations)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def daily_control(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
project_ids,
|
||||
project_id,
|
||||
day: date_cls,
|
||||
warehouse_id=None,
|
||||
) -> dict:
|
||||
"""One day (Tehran calendar): outbound / return / waste / purchase ticket
|
||||
counts + quantities, the exact performer names, and the operation detail."""
|
||||
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
scope_conditions = []
|
||||
if project_id is not None:
|
||||
scope_conditions.append(Warehouse.project_id == project_id)
|
||||
elif project_ids is not None:
|
||||
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||
if warehouse_id is not None:
|
||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||
|
||||
count_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
InventoryTransaction.transaction_type,
|
||||
func.count().label("tickets"),
|
||||
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
*scope_conditions,
|
||||
)
|
||||
.group_by(InventoryTransaction.transaction_type)
|
||||
)
|
||||
).all()
|
||||
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
|
||||
|
||||
performers_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
InventoryTransaction.performed_by,
|
||||
func.count().label("tickets"),
|
||||
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
*scope_conditions,
|
||||
)
|
||||
.group_by(InventoryTransaction.performed_by)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
detail_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
InventoryTransaction,
|
||||
Warehouse.name,
|
||||
Material.name,
|
||||
InventoryItem.specs,
|
||||
Unit.code,
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
InventoryTransaction.transaction_type.in_(
|
||||
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
|
||||
),
|
||||
*scope_conditions,
|
||||
)
|
||||
.order_by(InventoryTransaction.transaction_date.desc())
|
||||
.limit(200)
|
||||
)
|
||||
).all()
|
||||
|
||||
zero = {"tickets": 0, "qty": 0.0}
|
||||
items = []
|
||||
for tx, wh_name, mat_name, specs, ucode in detail_rows:
|
||||
items.append({
|
||||
"id": str(tx.id),
|
||||
"transaction_type": str(tx.transaction_type),
|
||||
"quantity": _qty(tx.quantity),
|
||||
"unit_code": ucode,
|
||||
"performed_by": tx.performed_by,
|
||||
"note": tx.note,
|
||||
"warehouse": wh_name,
|
||||
"material": mat_name,
|
||||
"specs": specs or {},
|
||||
"transaction_date": tx.transaction_date.isoformat(),
|
||||
})
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"date": day.isoformat(),
|
||||
"outbound": by_type.get("WITHDRAWAL", zero),
|
||||
"returns": by_type.get("RETURN", zero),
|
||||
"waste": {
|
||||
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
|
||||
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
|
||||
},
|
||||
"purchases": by_type.get("PURCHASE", zero),
|
||||
"performers": [
|
||||
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
|
||||
for name, tickets, qty in performers_rows
|
||||
],
|
||||
"items": items,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget 8 — Procurement workload per project (project density)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
|
||||
"""Per project: offered quotes still open + chosen purchase lines (open
|
||||
vs fully received). Chosen lines attribute to item_decisions.project_id
|
||||
(the purchased destination); offered quotes to any linked project."""
|
||||
received_sq = (
|
||||
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
|
||||
.group_by(ItemReceipt.item_decision_id)
|
||||
.subquery()
|
||||
)
|
||||
order_conditions = [ItemDecision.status == "chosen"]
|
||||
if project_id is not None:
|
||||
order_conditions.append(ItemDecision.project_id == project_id)
|
||||
elif project_ids is not None:
|
||||
order_conditions.append(ItemDecision.project_id.in_(project_ids))
|
||||
|
||||
order_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Project.id,
|
||||
Project.name,
|
||||
func.count().label("chosen_items"),
|
||||
func.sum(LineItem.quantity).label("ordered_qty"),
|
||||
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
|
||||
func.sum(
|
||||
case(
|
||||
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("open_items"),
|
||||
)
|
||||
.select_from(ItemDecision)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
|
||||
.where(*order_conditions)
|
||||
.group_by(Project.id, Project.name)
|
||||
)
|
||||
).all()
|
||||
|
||||
offered_by_project: dict[uuid.UUID | None, set] = {}
|
||||
offered_primary = (
|
||||
await db.execute(
|
||||
select(Quote.id, Quote.project_id).where(
|
||||
Quote.status == "offered", Quote.project_id.is_not(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
for quote_id, pid in offered_primary:
|
||||
offered_by_project.setdefault(pid, set()).add(quote_id)
|
||||
offered_junction = (
|
||||
await db.execute(
|
||||
select(QuoteProjects.quote_id, QuoteProjects.project_id)
|
||||
.join(Quote, Quote.id == QuoteProjects.quote_id)
|
||||
.where(Quote.status == "offered")
|
||||
)
|
||||
).all()
|
||||
for quote_id, pid in offered_junction:
|
||||
offered_by_project.setdefault(pid, set()).add(quote_id)
|
||||
|
||||
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
|
||||
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
|
||||
names: dict[uuid.UUID, str] = {}
|
||||
if project_ids_needed:
|
||||
names = {
|
||||
p.id: p.name
|
||||
for p in (
|
||||
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
|
||||
).scalars()
|
||||
}
|
||||
|
||||
projects = []
|
||||
for pid, _pname, chosen, ordered, received, open_items in order_rows:
|
||||
if pid is None:
|
||||
offered = 0
|
||||
else:
|
||||
offered = len(offered_by_project.pop(pid, set()))
|
||||
if project_id is not None and pid != project_id:
|
||||
offered = 0
|
||||
elif project_ids is not None and pid not in project_ids:
|
||||
offered = 0
|
||||
projects.append({
|
||||
"id": str(pid) if pid else None,
|
||||
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
|
||||
"offered_quotes": offered,
|
||||
"chosen_items": chosen,
|
||||
"open_orders": open_items,
|
||||
"ordered_qty": _qty(ordered),
|
||||
"received_qty": _qty(received),
|
||||
})
|
||||
# offered-only projects that have no chosen lines yet
|
||||
for pid, quote_ids in offered_by_project.items():
|
||||
if not quote_ids:
|
||||
continue
|
||||
if project_id is not None and pid != project_id:
|
||||
continue
|
||||
if project_ids is not None and (pid is None or pid not in project_ids):
|
||||
continue
|
||||
projects.append({
|
||||
"id": str(pid) if pid else None,
|
||||
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
|
||||
"offered_quotes": len(quote_ids),
|
||||
"chosen_items": 0,
|
||||
"open_orders": 0,
|
||||
"ordered_qty": 0.0,
|
||||
"received_qty": 0.0,
|
||||
})
|
||||
|
||||
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
|
||||
return {"data": {"projects": projects}}
|
||||
@@ -290,6 +290,7 @@ async def apply_transaction(
|
||||
transaction_type=t,
|
||||
quantity=quantity,
|
||||
performed_by=performed_by,
|
||||
delivered_to=payload.delivered_to,
|
||||
transaction_date=payload.transaction_date,
|
||||
note=payload.note,
|
||||
)
|
||||
@@ -319,6 +320,7 @@ async def apply_transaction(
|
||||
transaction_type=t,
|
||||
quantity=-quantity,
|
||||
performed_by=performed_by,
|
||||
delivered_to=payload.delivered_to,
|
||||
transaction_date=payload.transaction_date,
|
||||
note=payload.note,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.catalog import Brand, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import Project
|
||||
from app.models.project_transfer import ProjectTransfer
|
||||
from app.schemas.project_transfers import (
|
||||
ProjectTransferCreate,
|
||||
ProjectTransferReceive,
|
||||
ProjectTransferUpdate,
|
||||
)
|
||||
from app.services.inventory_service import (
|
||||
_find_inventory,
|
||||
_get_or_create_inventory,
|
||||
canonical_specs_hash,
|
||||
)
|
||||
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
|
||||
|
||||
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
|
||||
stmt = (
|
||||
select(ProjectTransfer)
|
||||
.where(ProjectTransfer.id == transfer_id)
|
||||
.options(
|
||||
selectinload(ProjectTransfer.from_project),
|
||||
selectinload(ProjectTransfer.to_project),
|
||||
selectinload(ProjectTransfer.from_warehouse),
|
||||
selectinload(ProjectTransfer.to_warehouse),
|
||||
selectinload(ProjectTransfer.material),
|
||||
selectinload(ProjectTransfer.brand),
|
||||
selectinload(ProjectTransfer.unit),
|
||||
)
|
||||
)
|
||||
transfer = await db.scalar(stmt)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
|
||||
return transfer
|
||||
|
||||
|
||||
async def create_project_transfer(
|
||||
db: AsyncSession,
|
||||
payload: ProjectTransferCreate,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
transferred_by: str | None = None,
|
||||
) -> ProjectTransfer:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
# Validate source warehouse
|
||||
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
|
||||
if from_wh is None:
|
||||
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
|
||||
|
||||
# RBAC: PM can only send from their own project
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if from_wh.project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
|
||||
|
||||
from_proj = await db.get(Project, from_wh.project_id)
|
||||
to_proj = await db.get(Project, payload.to_project_id)
|
||||
if to_proj is None:
|
||||
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||
|
||||
if from_wh.project_id == payload.to_project_id:
|
||||
raise ValidationError(
|
||||
"پروژه مقصد نمیتواند همان پروژه مبدأ باشد (از انتقال بینانباری استفاده کنید)",
|
||||
field="to_project_id",
|
||||
)
|
||||
|
||||
# Validate material, brand, unit
|
||||
mat = await db.get(Material, payload.material_id)
|
||||
if mat is None:
|
||||
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
|
||||
|
||||
if payload.brand_id is not None:
|
||||
brand = await db.get(Brand, payload.brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||
|
||||
unit = await db.get(Unit, payload.unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||
|
||||
# Requirement 2: Canonical specs hash from inventory_service
|
||||
specs = payload.specs or {}
|
||||
specs_hash = canonical_specs_hash(specs)
|
||||
|
||||
# Check stock in source warehouse with SELECT ... FOR UPDATE
|
||||
source_item = await _find_inventory(
|
||||
db,
|
||||
payload.from_warehouse_id,
|
||||
payload.material_id,
|
||||
payload.brand_id,
|
||||
specs_hash,
|
||||
for_update=True,
|
||||
)
|
||||
|
||||
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
|
||||
if source_item is None:
|
||||
candidates_res = await db.execute(
|
||||
select(InventoryItem)
|
||||
.where(
|
||||
InventoryItem.warehouse_id == payload.from_warehouse_id,
|
||||
InventoryItem.material_id == payload.material_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
candidates = candidates_res.scalars().all()
|
||||
|
||||
if len(candidates) == 1:
|
||||
source_item = candidates[0]
|
||||
elif len(candidates) > 1:
|
||||
best_cand = None
|
||||
best_score = -1
|
||||
p_keys = set(specs.keys())
|
||||
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
|
||||
|
||||
for cand in candidates:
|
||||
score = 0
|
||||
if cand.brand_id == payload.brand_id:
|
||||
score += 10
|
||||
elif cand.brand_id is None and payload.brand_id is None:
|
||||
score += 10
|
||||
elif cand.brand_id is None:
|
||||
score += 4
|
||||
|
||||
c_specs = cand.specs or {}
|
||||
c_keys = set(c_specs.keys())
|
||||
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
|
||||
|
||||
for pk in p_keys:
|
||||
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
|
||||
score += 5
|
||||
for pv in p_vals:
|
||||
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
|
||||
score += 5
|
||||
|
||||
if cand.quantity >= payload.quantity:
|
||||
score += 2
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_cand = cand
|
||||
|
||||
source_item = best_cand
|
||||
|
||||
if source_item is None or source_item.quantity < payload.quantity:
|
||||
avail = source_item.quantity if source_item else Decimal("0")
|
||||
raise ValidationError(
|
||||
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
|
||||
field="quantity",
|
||||
)
|
||||
|
||||
# Deduct from source warehouse immediately
|
||||
source_item.quantity -= payload.quantity
|
||||
|
||||
transfer_pair_id = uuid.uuid4()
|
||||
now_dt = payload.transfer_date or datetime.now(timezone.utc)
|
||||
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
|
||||
if payload.note:
|
||||
note_text += f" — {payload.note}"
|
||||
|
||||
out_tx = InventoryTransaction(
|
||||
inventory_id=source_item.id,
|
||||
warehouse_id=from_wh.id,
|
||||
transaction_type="TRANSFER_OUT",
|
||||
quantity=-payload.quantity,
|
||||
transfer_pair_id=transfer_pair_id,
|
||||
performed_by=transferred_by,
|
||||
transaction_date=now_dt,
|
||||
note=note_text,
|
||||
)
|
||||
db.add(out_tx)
|
||||
await db.flush()
|
||||
|
||||
actual_brand_id = source_item.brand_id
|
||||
actual_specs = source_item.specs if source_item.specs is not None else specs
|
||||
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
|
||||
|
||||
transfer = ProjectTransfer(
|
||||
from_project_id=from_wh.project_id,
|
||||
from_warehouse_id=from_wh.id,
|
||||
to_project_id=payload.to_project_id,
|
||||
material_id=payload.material_id,
|
||||
brand_id=actual_brand_id,
|
||||
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
|
||||
quantity=payload.quantity,
|
||||
specs=actual_specs,
|
||||
specs_hash=actual_specs_hash,
|
||||
note=payload.note,
|
||||
status="in_transit",
|
||||
out_transaction_id=out_tx.id,
|
||||
transfer_date=now_dt,
|
||||
transferred_by=transferred_by,
|
||||
)
|
||||
db.add(transfer)
|
||||
await db.flush()
|
||||
|
||||
return await get_project_transfer_or_404(db, transfer.id)
|
||||
|
||||
|
||||
async def update_project_transfer(
|
||||
db: AsyncSession,
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferUpdate,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
) -> ProjectTransfer:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
transfer = await db.scalar(
|
||||
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||
)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||
|
||||
# RBAC (Requirement 4)
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if transfer.from_project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به ویرایش انتقالهای پروژه مبدأ خود هستید")
|
||||
|
||||
# Status Lock Guard (Requirement 4)
|
||||
if transfer.status == "received":
|
||||
raise ConflictError(
|
||||
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
|
||||
)
|
||||
if transfer.status != "in_transit":
|
||||
raise ConflictError("فقط انتقالهای در راه (in_transit) قابل ویرایش هستند")
|
||||
|
||||
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
|
||||
new_to_proj = await db.get(Project, payload.to_project_id)
|
||||
if new_to_proj is None:
|
||||
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
|
||||
if payload.to_project_id == transfer.from_project_id:
|
||||
raise ValidationError("پروژه مقصد نمیتواند همان پروژه مبدأ باشد", field="to_project_id")
|
||||
transfer.to_project_id = payload.to_project_id
|
||||
|
||||
# Quantity adjustment (reconciles source stock difference)
|
||||
if payload.quantity is not None and payload.quantity != transfer.quantity:
|
||||
delta = payload.quantity - transfer.quantity
|
||||
source_item = await _find_inventory(
|
||||
db,
|
||||
transfer.from_warehouse_id,
|
||||
transfer.material_id,
|
||||
transfer.brand_id,
|
||||
transfer.specs_hash,
|
||||
for_update=True,
|
||||
)
|
||||
if delta > 0:
|
||||
if source_item is None or source_item.quantity < delta:
|
||||
avail = source_item.quantity if source_item else Decimal("0")
|
||||
raise ValidationError(
|
||||
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
|
||||
field="quantity",
|
||||
)
|
||||
source_item.quantity -= delta
|
||||
else:
|
||||
if source_item is not None:
|
||||
source_item.quantity += abs(delta)
|
||||
|
||||
transfer.quantity = payload.quantity
|
||||
|
||||
# Update out_transaction
|
||||
if transfer.out_transaction_id is not None:
|
||||
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if out_tx is not None:
|
||||
out_tx.quantity = -payload.quantity
|
||||
|
||||
if payload.note is not None:
|
||||
transfer.note = payload.note
|
||||
|
||||
if payload.transfer_date is not None:
|
||||
transfer.transfer_date = payload.transfer_date
|
||||
if transfer.out_transaction_id is not None:
|
||||
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if out_tx is not None:
|
||||
out_tx.transaction_date = payload.transfer_date
|
||||
|
||||
return await get_project_transfer_or_404(db, transfer_id)
|
||||
|
||||
|
||||
async def delete_project_transfer(
|
||||
db: AsyncSession,
|
||||
transfer_id: uuid.UUID,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
) -> dict:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
transfer = await db.scalar(
|
||||
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||
)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||
|
||||
# RBAC (Requirement 4)
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if transfer.from_project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به لغو انتقالهای پروژه مبدأ خود هستید")
|
||||
|
||||
# Status Lock Guard (Requirement 4)
|
||||
if transfer.status == "received":
|
||||
raise ConflictError(
|
||||
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
|
||||
)
|
||||
|
||||
# Reversibility (Requirement 4): Restore stock to source warehouse
|
||||
source_item = await _find_inventory(
|
||||
db,
|
||||
transfer.from_warehouse_id,
|
||||
transfer.material_id,
|
||||
transfer.brand_id,
|
||||
transfer.specs_hash,
|
||||
for_update=True,
|
||||
)
|
||||
if source_item is not None:
|
||||
source_item.quantity += transfer.quantity
|
||||
|
||||
# Delete out_transaction
|
||||
if transfer.out_transaction_id is not None:
|
||||
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if out_tx is not None:
|
||||
await db.delete(out_tx)
|
||||
|
||||
await db.delete(transfer)
|
||||
|
||||
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
|
||||
|
||||
|
||||
async def receive_project_transfer(
|
||||
db: AsyncSession,
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferReceive,
|
||||
caller_project_id: uuid.UUID | None,
|
||||
caller_role: str,
|
||||
received_by: str | None = None,
|
||||
) -> ProjectTransfer:
|
||||
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||
async with tx:
|
||||
transfer = await db.scalar(
|
||||
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
|
||||
)
|
||||
if transfer is None:
|
||||
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
|
||||
|
||||
# RBAC (Requirement 4)
|
||||
if caller_role == "pm" and caller_project_id is not None:
|
||||
if transfer.to_project_id != caller_project_id:
|
||||
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
|
||||
|
||||
# Lock Guard
|
||||
if transfer.status == "received":
|
||||
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
|
||||
if transfer.status != "in_transit":
|
||||
raise ConflictError("این انتقال در وضعیت در راه نیست")
|
||||
|
||||
# Requirement 1: 100% full delivery logic (no partial receipt)
|
||||
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
|
||||
if alloc_sum != transfer.quantity:
|
||||
raise ValidationError(
|
||||
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
|
||||
field="allocations",
|
||||
)
|
||||
|
||||
# Validate destination warehouses belong to destination project
|
||||
wh_ids = [a.warehouse_id for a in payload.allocations]
|
||||
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
|
||||
wh_map = {w.id: w for w in wh_rows}
|
||||
|
||||
for a in payload.allocations:
|
||||
wh = wh_map.get(a.warehouse_id)
|
||||
if wh is None or wh.project_id != transfer.to_project_id:
|
||||
raise ValidationError(
|
||||
"انبار انتخابشده معتبر نیست یا متعلق به پروژه مقصد نمیباشد",
|
||||
field="allocations.warehouse_id",
|
||||
)
|
||||
|
||||
from_proj = await db.get(Project, transfer.from_project_id)
|
||||
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
|
||||
|
||||
out_tx = (
|
||||
await db.get(InventoryTransaction, transfer.out_transaction_id)
|
||||
if transfer.out_transaction_id
|
||||
else None
|
||||
)
|
||||
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
|
||||
now_dt = payload.received_at or datetime.now(timezone.utc)
|
||||
|
||||
# Add stock to destination warehouse(s) via TRANSFER_IN
|
||||
for a in payload.allocations:
|
||||
dest_item = await _get_or_create_inventory(
|
||||
db,
|
||||
a.warehouse_id,
|
||||
transfer.material_id,
|
||||
transfer.brand_id,
|
||||
transfer.unit_id,
|
||||
transfer.specs,
|
||||
transfer.specs_hash,
|
||||
)
|
||||
dest_item.quantity += a.quantity
|
||||
|
||||
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
|
||||
if payload.note:
|
||||
note_text += f" — {payload.note}"
|
||||
|
||||
in_tx = InventoryTransaction(
|
||||
inventory_id=dest_item.id,
|
||||
warehouse_id=a.warehouse_id,
|
||||
transaction_type="TRANSFER_IN",
|
||||
quantity=a.quantity,
|
||||
transfer_pair_id=pair_id,
|
||||
performed_by=received_by,
|
||||
transaction_date=now_dt,
|
||||
note=note_text,
|
||||
)
|
||||
db.add(in_tx)
|
||||
|
||||
transfer.status = "received"
|
||||
transfer.received_at = now_dt
|
||||
transfer.received_by = received_by
|
||||
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
|
||||
transfer.receipt_allocations = [
|
||||
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
|
||||
for a in payload.allocations
|
||||
]
|
||||
|
||||
return await get_project_transfer_or_404(db, transfer_id)
|
||||
|
||||
|
||||
async def list_project_transfers(
|
||||
db: AsyncSession,
|
||||
project_id: uuid.UUID | None = None,
|
||||
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
|
||||
status: str | None = None,
|
||||
) -> list[ProjectTransfer]:
|
||||
stmt = (
|
||||
select(ProjectTransfer)
|
||||
.options(
|
||||
selectinload(ProjectTransfer.from_project),
|
||||
selectinload(ProjectTransfer.to_project),
|
||||
selectinload(ProjectTransfer.from_warehouse),
|
||||
selectinload(ProjectTransfer.to_warehouse),
|
||||
selectinload(ProjectTransfer.material),
|
||||
selectinload(ProjectTransfer.brand),
|
||||
selectinload(ProjectTransfer.unit),
|
||||
)
|
||||
)
|
||||
|
||||
if project_id is not None:
|
||||
if direction == "outgoing":
|
||||
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
|
||||
elif direction == "incoming":
|
||||
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
|
||||
else:
|
||||
stmt = stmt.where(
|
||||
(ProjectTransfer.from_project_id == project_id)
|
||||
| (ProjectTransfer.to_project_id == project_id)
|
||||
)
|
||||
|
||||
if status:
|
||||
stmt = stmt.where(ProjectTransfer.status == status)
|
||||
|
||||
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
|
||||
res = await db.execute(stmt)
|
||||
return list(res.scalars().all())
|
||||
@@ -7,6 +7,7 @@ same transaction.
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
@@ -18,6 +19,7 @@ from app.models.procurement import (
|
||||
ItemTermsheet,
|
||||
LineItem,
|
||||
LineItemProjects,
|
||||
LineItemSpec,
|
||||
Project,
|
||||
Quote,
|
||||
QuoteEvent,
|
||||
@@ -418,6 +420,78 @@ async def replace_quote_children(
|
||||
|
||||
|
||||
|
||||
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
|
||||
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
|
||||
exists = await db.scalar(
|
||||
select(QuoteProjects).where(
|
||||
QuoteProjects.quote_id == quote_id,
|
||||
QuoteProjects.project_id == project_id,
|
||||
)
|
||||
)
|
||||
if not exists:
|
||||
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
|
||||
|
||||
|
||||
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
|
||||
"""Consolidate split line items when reverting a quote back to offered.
|
||||
|
||||
Line items sharing the exact same material, brand_material, unit, notes,
|
||||
and identical specs are merged into a single consolidated row whose quantity
|
||||
is the sum of all split rows. Secondary split rows are safely deleted.
|
||||
"""
|
||||
line_items = (
|
||||
await db.execute(
|
||||
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
if len(line_items) <= 1:
|
||||
return
|
||||
|
||||
# Load all specs for these line items to compare canonical signatures
|
||||
specs_rows = (
|
||||
await db.execute(
|
||||
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
|
||||
)
|
||||
).scalars().all()
|
||||
specs_by_li: dict[UUID, list[tuple]] = {}
|
||||
for sp in specs_rows:
|
||||
sig = (
|
||||
str(sp.attribute_definition_id),
|
||||
str(sp.value_number) if sp.value_number is not None else "",
|
||||
(sp.value_string or "").strip(),
|
||||
str(sp.value_boolean) if sp.value_boolean is not None else "",
|
||||
str(sp.value_date) if sp.value_date is not None else "",
|
||||
)
|
||||
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
|
||||
|
||||
# Group line items by identity
|
||||
groups: dict[tuple, list[LineItem]] = {}
|
||||
for li in line_items:
|
||||
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
|
||||
key = (
|
||||
li.material_id,
|
||||
li.brand_material_id,
|
||||
li.unit_id,
|
||||
(li.notes or "").strip(),
|
||||
specs_sig,
|
||||
)
|
||||
groups.setdefault(key, []).append(li)
|
||||
|
||||
for key, items in groups.items():
|
||||
if len(items) > 1:
|
||||
primary = items[0]
|
||||
total_qty = sum(Decimal(str(it.quantity)) for it in items)
|
||||
primary.quantity = total_qty
|
||||
|
||||
secondary_ids = [it.id for it in items[1:]]
|
||||
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
|
||||
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||
current = quote.status
|
||||
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
# Requirement 2: Merge split line items back into single rows
|
||||
await _merge_split_line_items_on_reopen(db, quote.id)
|
||||
# LineItemProjects (candidate projects) are preserved untouched!
|
||||
elif new_status == "accepted": # arrived -> accepted
|
||||
quote.arrived_date = None
|
||||
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
||||
)
|
||||
termsheet_id = ts_ids_by_index[index]
|
||||
|
||||
li_row = await db.scalar(
|
||||
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
|
||||
)
|
||||
if li_row is None:
|
||||
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
|
||||
|
||||
allocations = decision_in.allocations or []
|
||||
if allocations:
|
||||
# Requirement 3: Validate no duplicate projects within allocations of this item
|
||||
alloc_pids = [a.project_id for a in allocations]
|
||||
if len(alloc_pids) != len(set(alloc_pids)):
|
||||
raise ValidationError(
|
||||
"پروژه تکراری برای یک قلم کالا در ردیفهای تخصیص انتخاب شده است",
|
||||
field="item_decisions.allocations",
|
||||
)
|
||||
# Requirement 5: Decimal precision check for sum of allocation quantities
|
||||
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
|
||||
target_qty = Decimal(str(li_row.quantity))
|
||||
if alloc_sum != target_qty:
|
||||
raise ValidationError(
|
||||
f"مجموع مقادیر تخصیصیافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
|
||||
field="item_decisions.allocations",
|
||||
)
|
||||
# Validate all projects exist
|
||||
for a in allocations:
|
||||
if await db.get(Project, a.project_id) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
|
||||
|
||||
if len(allocations) >= 2:
|
||||
# 1. Update the original line item to the first allocation quantity
|
||||
first_alloc = allocations[0]
|
||||
li_row.quantity = first_alloc.quantity
|
||||
decision_in.project_id = first_alloc.project_id
|
||||
|
||||
# Query all existing item_termsheets and line_item_specs for copying
|
||||
orig_termsheets = (
|
||||
await db.execute(
|
||||
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
|
||||
)
|
||||
).scalars().all()
|
||||
orig_specs = (
|
||||
await db.execute(
|
||||
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
|
||||
for split_alloc in allocations[1:]:
|
||||
new_li = LineItem(
|
||||
quote_id=quote.id,
|
||||
material_id=li_row.material_id,
|
||||
brand_material_id=li_row.brand_material_id,
|
||||
unit_id=li_row.unit_id,
|
||||
quantity=split_alloc.quantity,
|
||||
notes=li_row.notes,
|
||||
)
|
||||
db.add(new_li)
|
||||
await db.flush()
|
||||
|
||||
# Copy LineItemSpec
|
||||
for sp in orig_specs:
|
||||
db.add(
|
||||
LineItemSpec(
|
||||
line_item_id=new_li.id,
|
||||
attribute_definition_id=sp.attribute_definition_id,
|
||||
value_number=sp.value_number,
|
||||
value_string=sp.value_string,
|
||||
value_boolean=sp.value_boolean,
|
||||
value_date=sp.value_date,
|
||||
unit_id_at_time=sp.unit_id_at_time,
|
||||
)
|
||||
)
|
||||
|
||||
# Requirement 1: Copy ALL payment options (ItemTermsheet)
|
||||
chosen_new_it: ItemTermsheet | None = None
|
||||
for it_orig in orig_termsheets:
|
||||
price = (
|
||||
decision_in.adjusted_unit_price
|
||||
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
|
||||
else it_orig.unit_price
|
||||
)
|
||||
copied_it = ItemTermsheet(
|
||||
line_item_id=new_li.id,
|
||||
termsheet_id=it_orig.termsheet_id,
|
||||
unit_price=price,
|
||||
min_order_qty=it_orig.min_order_qty,
|
||||
)
|
||||
db.add(copied_it)
|
||||
if it_orig.termsheet_id == termsheet_id:
|
||||
chosen_new_it = copied_it
|
||||
|
||||
if chosen_new_it is None:
|
||||
chosen_new_it = ItemTermsheet(
|
||||
line_item_id=new_li.id,
|
||||
termsheet_id=termsheet_id,
|
||||
unit_price=decision_in.adjusted_unit_price,
|
||||
)
|
||||
db.add(chosen_new_it)
|
||||
await db.flush()
|
||||
|
||||
# Create ItemDecision for the split line item
|
||||
new_decision = ItemDecision(
|
||||
line_item_id=new_li.id,
|
||||
status="chosen",
|
||||
chosen_item_termsheet_id=chosen_new_it.id,
|
||||
adjusted_unit_price=decision_in.adjusted_unit_price,
|
||||
adjusted_notes=decision_in.adjusted_notes,
|
||||
project_id=split_alloc.project_id,
|
||||
decided_at=utcnow(),
|
||||
)
|
||||
db.add(new_decision)
|
||||
|
||||
# Requirement 4: sync QuoteProjects & LineItemProjects
|
||||
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
|
||||
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
|
||||
elif len(allocations) == 1:
|
||||
decision_in.project_id = allocations[0].project_id
|
||||
|
||||
it = await db.scalar(
|
||||
select(ItemTermsheet).where(
|
||||
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
||||
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
if decision.project_id is not None:
|
||||
if await db.get(Project, decision.project_id) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
||||
# Requirement 4: sync QuoteProjects
|
||||
await _ensure_quote_project(db, quote.id, decision.project_id)
|
||||
chosen_ts_ids.add(termsheet_id)
|
||||
else: # rejected
|
||||
if decision is None:
|
||||
@@ -903,6 +1100,17 @@ async def quote_stats(
|
||||
*conditions,
|
||||
)
|
||||
)
|
||||
# Procurement funnel (cumulative, not the current-state counts above):
|
||||
# a quote counts as accepted/arrived if it EVER reached that stage.
|
||||
funnel_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("offered_total"),
|
||||
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
|
||||
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
|
||||
).where(*conditions)
|
||||
)
|
||||
).one()
|
||||
return {
|
||||
"total": sum(counts.values()),
|
||||
"offered": counts["offered"],
|
||||
@@ -911,6 +1119,11 @@ async def quote_stats(
|
||||
"rejected": counts["rejected"],
|
||||
"expired": counts["expired"],
|
||||
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
|
||||
"funnel": {
|
||||
"offered_total": funnel_row.offered_total,
|
||||
"accepted_total": funnel_row.accepted_total,
|
||||
"arrived_total": funnel_row.arrived_total,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
|
||||
|
||||
USER_PLAN = [
|
||||
# (casdoor username, first, last, role)
|
||||
("ali.mohaghegh", "علی", "محقق", "admin"),
|
||||
("razaei", "علی", "رضایی", "admin"),
|
||||
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
||||
("mohaghegh", "محمد", "محقق", "pm"),
|
||||
@@ -261,8 +262,11 @@ async def run() -> None:
|
||||
material_by_name: dict[str, Material] = {}
|
||||
brand_material_by_material: dict[str, object] = {}
|
||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||
cat = await get_category(db, cat_slug)
|
||||
material = Material(
|
||||
name=name,
|
||||
topic=cat.name,
|
||||
category_id=cat.id, # v2.8: dashboard Category→Material cascade
|
||||
canonical_unit_id=unit_ids[unit_code],
|
||||
status="active",
|
||||
)
|
||||
@@ -440,6 +444,86 @@ async def run() -> None:
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
# ---- MVP dashboard demo ledger -----------------------------------
|
||||
# Multi-day history so every widget has real data to show:
|
||||
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
|
||||
# - withdrawals/returns/waste over the last ~12 days → flow series
|
||||
# - tickets dated TODAY (performed_by names) → daily control
|
||||
# Quantities are hand-checked to keep every row >= 0.
|
||||
|
||||
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
|
||||
mat = material_by_name[MATERIALS[mat_idx][0]]
|
||||
wh = warehouse_rows[project_idx][wh_idx]
|
||||
inv = await db.scalar(select(InventoryItem).where(
|
||||
InventoryItem.warehouse_id == wh.id,
|
||||
InventoryItem.material_id == mat.id,
|
||||
InventoryItem.specs_hash == "default",
|
||||
))
|
||||
if inv is None:
|
||||
inv = InventoryItem(
|
||||
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
|
||||
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
|
||||
specs={}, specs_hash="default",
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
return inv
|
||||
|
||||
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
|
||||
days_ago: int, performed_by: str, note: str | None = None) -> None:
|
||||
inv.quantity += Decimal(str(qty))
|
||||
db.add(InventoryTransaction(
|
||||
inventory_id=inv.id,
|
||||
warehouse_id=wh.id,
|
||||
transaction_type=tx_type,
|
||||
quantity=Decimal(str(qty)),
|
||||
performed_by=performed_by,
|
||||
note=note or "تاریخچه دمو داشبورد",
|
||||
transaction_date=d(days_ago),
|
||||
))
|
||||
|
||||
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
|
||||
|
||||
# dead stock: cable + switches bought long ago, untouched since
|
||||
cable = await ensure_inv(0, 0, 0)
|
||||
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
|
||||
keys = await ensure_inv(1, 0, 0)
|
||||
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
|
||||
|
||||
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
|
||||
cement = await ensure_inv(4, 2, 0)
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
|
||||
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
|
||||
|
||||
blocks = await ensure_inv(5, 2, 1)
|
||||
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
|
||||
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
|
||||
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
|
||||
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
|
||||
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
|
||||
|
||||
pe16_a = await ensure_inv(6, 1, 0)
|
||||
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
|
||||
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
|
||||
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
|
||||
|
||||
pe16_b = await ensure_inv(6, 1, 1)
|
||||
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
|
||||
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
|
||||
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
|
||||
|
||||
pe25 = await ensure_inv(7, 1, 0)
|
||||
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
|
||||
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
|
||||
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
|
||||
|
||||
await db.flush()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# summary
|
||||
|
||||
@@ -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()))
|
||||
+110
-1
@@ -11,7 +11,7 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "5455:5432"
|
||||
|
||||
networks:
|
||||
- default
|
||||
@@ -208,6 +208,7 @@ services:
|
||||
mem_limit: 768m
|
||||
|
||||
api:
|
||||
image: v15-api:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
@@ -279,6 +280,7 @@ services:
|
||||
mem_limit: 768m
|
||||
|
||||
frontend:
|
||||
image: v15-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
@@ -314,6 +316,112 @@ services:
|
||||
restart: unless-stopped
|
||||
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:
|
||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||
@@ -330,3 +438,4 @@ volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
traefik_certs:
|
||||
pm_files:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||
|
||||
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||
|
||||
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||
|
||||
---
|
||||
|
||||
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||
|
||||
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||
(`backend/app/routers/inventory.py` — list_transactions)
|
||||
|
||||
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||
|
||||
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||
|
||||
---
|
||||
|
||||
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||
|
||||
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||
|
||||
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||
|
||||
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||
|
||||
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||
|
||||
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||
|
||||
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||
|
||||
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||
|
||||
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||
|
||||
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||
|
||||
## ۵. 🟡 Audit Trail ناقص است
|
||||
|
||||
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||
- CRUD انبارها و تغییرات کاربران
|
||||
|
||||
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||
|
||||
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||
|
||||
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||
|
||||
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||
|
||||
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||
|
||||
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||
|
||||
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||
|
||||
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||
|
||||
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||
|
||||
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||
|
||||
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||
|
||||
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||
|
||||
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||
|
||||
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||
|
||||
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||
|
||||
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||
|
||||
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||
|
||||
---
|
||||
|
||||
## چیزهایی که بررسی شد و درست است ✅
|
||||
|
||||
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||
Generated
+390
@@ -28,6 +28,7 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
@@ -1366,6 +1367,32 @@
|
||||
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
|
||||
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
|
||||
@@ -1714,6 +1741,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
@@ -2003,6 +2042,69 @@
|
||||
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
@@ -2033,6 +2135,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
@@ -2127,6 +2235,133 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -2155,6 +2390,24 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks",
|
||||
"tests/types",
|
||||
"tests/browser-compat"
|
||||
]
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2201,6 +2454,25 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
|
||||
"integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2645,6 +2917,36 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
|
||||
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -2752,6 +3054,57 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
|
||||
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^11.1.8",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.2.0",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
|
||||
@@ -2884,6 +3237,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -2970,6 +3329,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.85.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
+32
-4
@@ -46,6 +46,10 @@ const AdminPanelPage = lazy(() =>
|
||||
const RequesterHome = lazy(() =>
|
||||
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
|
||||
);
|
||||
// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجتهای Power-BI-style
|
||||
const MvpDashboardPage = lazy(() =>
|
||||
import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })),
|
||||
);
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
@@ -55,11 +59,23 @@ function Loading() {
|
||||
);
|
||||
}
|
||||
|
||||
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
|
||||
// همهی صفحات پشت ورود هستند — به جز پیشنویسهای ویزارد از ربات یا بدون نشست که به صفحهی ورود میروند.
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { pathname } = useLocation();
|
||||
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}</>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const returnUrl = encodeURIComponent(location.pathname + location.search);
|
||||
return <Navigate to={`/login?returnUrl=${returnUrl}`} replace />;
|
||||
}
|
||||
if (user.role === "supervisor") {
|
||||
// اگر سرپرست پروژهای نداشته باشد، دسترسی مسدود میشود
|
||||
const hasProjects =
|
||||
@@ -71,7 +87,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
<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
|
||||
@@ -310,6 +326,7 @@ export default function App() {
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/review" element={<WizardPage isStandalone />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
@@ -373,6 +390,17 @@ export default function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<MvpDashboardPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/units"
|
||||
element={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
@@ -16,7 +17,11 @@ export interface ComboboxProps {
|
||||
value?: string;
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||
pinnedIds?: string[];
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||
addSuffix?: string; // متن بعد از +
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
@@ -25,6 +30,11 @@ export interface ComboboxProps {
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
// با ارائهی این تابع، دکمهی «×» داخل فیلد ظاهر میشود تا انتخاب فعلی
|
||||
// (و فیلتر وابسته به آن) بهتنهایی پاک شود.
|
||||
onClear?: () => void;
|
||||
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||
openOnFocus?: boolean;
|
||||
@@ -43,7 +53,9 @@ export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
items,
|
||||
pinnedIds,
|
||||
allowAdd = true,
|
||||
addPrefix,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
placeholder,
|
||||
className,
|
||||
@@ -52,11 +64,17 @@ export function Combobox({
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
onClear,
|
||||
inputRef,
|
||||
openOnFocus = true,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlight, setHighlight] = useState(-1);
|
||||
// کلیکِ روی اینپوتِ در حالت فوکوسنشده، اول onFocus را صدا میزند و منو باز
|
||||
// میشود؛ این نشانگر میگوید «همین لحظه با فوکوس باز شد» تا همان کلیک
|
||||
// دوباره منو را نبندد. کلیکهای بعدی = تاگل باز/بسته.
|
||||
const justOpenedByFocus = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setText(value ?? "");
|
||||
@@ -74,19 +92,37 @@ export function Combobox({
|
||||
return all.filter((x) => normalize(x.label).includes(q));
|
||||
}, [all, q]);
|
||||
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
||||
// آیتمهای pinشده همیشه در ابتدای فهرست میمانند و با جستجو حذف نمیشوند؛
|
||||
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام میشود.
|
||||
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
|
||||
const pin = pinnedIds ?? [];
|
||||
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
|
||||
const pinned = pin
|
||||
.map((id) => all.find((x) => x.id === id))
|
||||
.filter((x): x is ComboboxItem => x !== undefined);
|
||||
const pinnedSet = new Set(pinned.map((x) => x.id));
|
||||
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
|
||||
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
|
||||
}, [all, filtered, pinnedIds]);
|
||||
|
||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
||||
const showAdd =
|
||||
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
|
||||
|
||||
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||
|
||||
const listCount = visible.length + (showAdd ? 1 : 0);
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
const pick = (it: ComboboxItem) => {
|
||||
setText(it.label);
|
||||
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||
const nextText = it.id ? it.label : "";
|
||||
setText(nextText);
|
||||
setOpen(false);
|
||||
setHighlight(-1);
|
||||
onChange?.(it);
|
||||
onInputChange?.(it.label);
|
||||
onInputChange?.(nextText);
|
||||
};
|
||||
|
||||
const addCurrent = () => {
|
||||
@@ -111,9 +147,9 @@ export function Combobox({
|
||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlight >= 0 && highlight < filtered.length) {
|
||||
pick(filtered[highlight]);
|
||||
} else if (highlight === filtered.length && showAdd) {
|
||||
if (highlight >= 0 && highlight < visible.length) {
|
||||
pick(visible[highlight]);
|
||||
} else if (highlight === visible.length && showAdd) {
|
||||
addCurrent();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -125,8 +161,10 @@ export function Combobox({
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
showClear && "pe-8",
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
@@ -140,24 +178,53 @@ export function Combobox({
|
||||
onInputChange?.(e.target.value);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (openOnFocus) setOpen(true);
|
||||
if (openOnFocus && !open) {
|
||||
setOpen(true);
|
||||
justOpenedByFocus.current = true;
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیکهای بعدی تاگل میکنند
|
||||
if (justOpenedByFocus.current) {
|
||||
justOpenedByFocus.current = false;
|
||||
return;
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{showClear && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="پاک کردن"
|
||||
title="پاک کردن"
|
||||
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setText("");
|
||||
setHighlight(-1);
|
||||
setOpen(true);
|
||||
onClear?.();
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||
{listCount === 0 && (
|
||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||
)}
|
||||
{filtered.map((it, i) => (
|
||||
{visible.map((it, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={it.id + "|" + i}
|
||||
className={cn(
|
||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||
i === highlight && "bg-slate-bg",
|
||||
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // حفظ فوکوس input
|
||||
@@ -182,9 +249,15 @@ export function Combobox({
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
{addPrefix ? (
|
||||
<span>
|
||||
{addPrefix} «<b>{text.trim()}</b>»
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// ============================================================
|
||||
// ویجت ۴ — کنترل روزانه (امروز)
|
||||
// کارتهای KPI: بلیتهای خروج امروز، برگشت از سایت، ضایعات و پذیرش
|
||||
// + فهرست دقیق نام تحویلدهندهها/انجامدهندهها + ریز عملیات امروز.
|
||||
// هر ۶۰ ثانیه خودکار تازهسازی میشود (useDailyControl).
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
PackageOpen,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useDailyControl } from "../../hooks/useDashboardData";
|
||||
import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
|
||||
const TYPE_FA: Record<string, string> = {
|
||||
WITHDRAWAL: "برداشت از انبار",
|
||||
RETURN: "برگشت از سایت",
|
||||
DAMAGE: "آسیبدیده",
|
||||
WASTE: "اتلاف",
|
||||
LOSS: "مفقودی",
|
||||
PURCHASE: "پذیرش خرید",
|
||||
TRANSFER_IN: "انتقال ورودی",
|
||||
TRANSFER_OUT: "انتقال خروجی",
|
||||
FOUND: "پیدا شده",
|
||||
STOCK_COUNT: "انبارگردانی",
|
||||
};
|
||||
|
||||
const TYPE_STYLE: Record<string, string> = {
|
||||
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
|
||||
RETURN: "bg-amber-bg text-amber",
|
||||
DAMAGE: "bg-red-bg text-red",
|
||||
WASTE: "bg-red-bg text-red",
|
||||
LOSS: "bg-red-bg text-red",
|
||||
PURCHASE: "bg-green-bg text-green",
|
||||
};
|
||||
|
||||
function KpiCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
tickets,
|
||||
qty,
|
||||
tone,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
tickets: number;
|
||||
qty: number;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
|
||||
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
|
||||
<Icon className="h-4.5 w-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className="flex items-baseline gap-1.5 tabular-nums">
|
||||
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
|
||||
<span className="text-[10.5px] text-muted">بلیت</span>
|
||||
<span className="text-[11px] text-muted">·</span>
|
||||
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DailyControlPanel() {
|
||||
const query = useDailyControl();
|
||||
const d = query.data;
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="کنترل روزانه — عملیات امروز"
|
||||
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه بهروزرسانی میشود` : "هر دقیقه بهروزرسانی میشود"}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !d ? (
|
||||
<ChartEmpty message="دادهی امروز در دسترس نیست" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* KPI کارتها */}
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={ArrowUpFromLine}
|
||||
label="خروج به سایت (برداشت)"
|
||||
tickets={d.outbound.tickets}
|
||||
qty={d.outbound.qty}
|
||||
tone="bg-blue-bg text-[#1d4ed8]"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ArrowDownToLine}
|
||||
label="برگشت از سایت"
|
||||
tickets={d.returns.tickets}
|
||||
qty={d.returns.qty}
|
||||
tone="bg-amber-bg text-amber"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={TriangleAlert}
|
||||
label="ضایعات / اتلاف / مفقودی"
|
||||
tickets={d.waste.tickets}
|
||||
qty={d.waste.qty}
|
||||
tone="bg-red-bg text-red"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={PackageOpen}
|
||||
label="پذیرش خرید (ورود)"
|
||||
tickets={d.purchases.tickets}
|
||||
qty={d.purchases.qty}
|
||||
tone="bg-green-bg text-green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-5">
|
||||
{/* نام دقیق انجامدهندهها */}
|
||||
<div className="rounded-xl border border-line p-3 lg:col-span-2">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
|
||||
<Users className="h-4 w-4 text-muted" />
|
||||
تحویلدهندهها / انجامدهندههای امروز
|
||||
</h3>
|
||||
{d.performers.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-line">
|
||||
{d.performers.map((p, i) => (
|
||||
<li key={i} className="flex items-center gap-2 py-1.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
|
||||
{faNum(i + 1)}
|
||||
</span>
|
||||
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
|
||||
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
|
||||
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ریز عملیات امروز */}
|
||||
<div className="rounded-xl border border-line p-3 lg:col-span-3">
|
||||
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
|
||||
{d.items.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">هنوز جابهجاییای امروز ثبت نشده است</p>
|
||||
) : (
|
||||
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
|
||||
{d.items.map((it) => (
|
||||
<li key={it.id} className="flex items-center gap-2 py-1.5">
|
||||
<Badge
|
||||
className={cn(
|
||||
"shrink-0 px-2 py-0 text-[10px]",
|
||||
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
|
||||
)}
|
||||
>
|
||||
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
|
||||
</Badge>
|
||||
<span className="truncate text-[12px] font-bold">{it.material}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||
{fmtNum(it.quantity)} {it.unit_code}
|
||||
</span>
|
||||
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
|
||||
{fmtDateTime(it.transaction_date)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// ============================================================
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||
// متریال → مشخصات فنی موجودِ همان متریال
|
||||
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||
// سه فیلتر در یک ردیفِ وسطچین با عرض یکسان مینشینند (موبایل: تکستونی).
|
||||
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import { Combobox, type ComboboxItem } from "../Combobox";
|
||||
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// گزینهی «همه» — انتخابی که همان سطح و سطوح پاییندستش را پاک میکند.
|
||||
// شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمیشود.
|
||||
const ALL_OPTION_ID = "";
|
||||
// این شناسه همیشه در ابتدای فهرست میماند و با جستجوی کاربر فیلتر نمیشود
|
||||
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
||||
|
||||
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
||||
const LABELS = {
|
||||
allMaterials: "همهی متریالها",
|
||||
allBrands: "همهی برندها",
|
||||
allSpecs: "همهی مشخصات",
|
||||
materialFirst: "ابتدا متریال را انتخاب کنید",
|
||||
clearAll: "حذف فیلترها",
|
||||
wholeOrg: "نمای کل سازمان",
|
||||
cascade: "متریال ← برند ← مشخصات فنی",
|
||||
} as const;
|
||||
|
||||
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
||||
|
||||
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
||||
function specLabel(specs: Record<string, string> | null): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||
}
|
||||
|
||||
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||
const filterQuery = useDashboardFilterOptions();
|
||||
const state = useDashboardFilter();
|
||||
|
||||
// فهرست متریالها مستقیماً از کاتالوگ سراسری میآید؛ فقط یکتاسازی شناسهها
|
||||
// انجام میشود تا هیچ گزینهی تکراری در فهرست نباشد.
|
||||
const materials = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return (filterQuery.data?.materials ?? []).filter((m) =>
|
||||
seen.has(m.id) ? false : (seen.add(m.id), true),
|
||||
);
|
||||
}, [filterQuery.data]);
|
||||
|
||||
const selectedMaterial = useMemo(
|
||||
() => materials.find((m) => m.id === state.materialId) ?? null,
|
||||
[materials, state.materialId],
|
||||
);
|
||||
|
||||
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||
|
||||
// انتخاب از فهرست؛ گزینهی «همه» همان سطح را پاک میکند (آبشار خودکار)
|
||||
const pick =
|
||||
(set: (id: string | null, label: string | null) => void) =>
|
||||
(item: ComboboxItem) =>
|
||||
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
|
||||
|
||||
// فهرست هر فیلد با گزینهی «همه» در ابتدا
|
||||
const materialItems = useMemo<ComboboxItem[]>(
|
||||
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
|
||||
[materials],
|
||||
);
|
||||
|
||||
const brandItems = useMemo<ComboboxItem[]>(
|
||||
() => [
|
||||
allOption(LABELS.allBrands),
|
||||
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
|
||||
],
|
||||
[selectedMaterial],
|
||||
);
|
||||
|
||||
const specItems = useMemo<ComboboxItem[]>(
|
||||
() => [
|
||||
allOption(LABELS.allSpecs),
|
||||
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
|
||||
],
|
||||
[selectedMaterial],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
|
||||
aria-label="فیلتر سراسری متریال"
|
||||
>
|
||||
<div className="mb-2.5 flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
||||
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
|
||||
</div>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.reset}
|
||||
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
|
||||
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
|
||||
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
|
||||
>
|
||||
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
|
||||
{LABELS.clearAll}
|
||||
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
|
||||
{faNum(activeCount)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سه فیلتر با عرض یکسان، وسطچین و متقارن؛ روی موبایل تکستونی میشود */}
|
||||
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||
{/* ۱ — متریال (سرآغاز آبشار) */}
|
||||
<Combobox
|
||||
value={state.materialLabel ?? ""}
|
||||
placeholder={LABELS.allMaterials}
|
||||
allowAdd={false}
|
||||
disabled={materials.length === 0}
|
||||
items={materialItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setMaterial(null, null)}
|
||||
onChange={pick(state.setMaterial)}
|
||||
/>
|
||||
|
||||
{/* ۲ — برند (فقط برندهای همان متریال) */}
|
||||
<Combobox
|
||||
value={state.brandLabel ?? ""}
|
||||
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||
items={brandItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setBrand(null, null)}
|
||||
onChange={pick(state.setBrand)}
|
||||
/>
|
||||
|
||||
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||
<Combobox
|
||||
value={state.specsLabel ?? ""}
|
||||
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||
items={specItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setSpecs(null, null)}
|
||||
onChange={pick(state.setSpecs)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// ============================================================
|
||||
// ویجت ۲ — جدول موجودی راکد (سرمایهی خوابیده)
|
||||
// ردیفهایی با موجودی مثبت که هیچ تراکنشی در بازهی رکود نداشتهاند.
|
||||
// آستانهی ۶۰/۹۰ روز با چیپ انتخاب میشود؛ روزهای رکود رنگی است.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { Snowflake } from "lucide-react";
|
||||
import { useDeadStock } from "../../hooks/useDashboardData";
|
||||
import { fmtNum, faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
|
||||
function specLabel(specs: Record<string, string>): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||
}
|
||||
|
||||
function idleSeverity(days: number | null): string {
|
||||
if (days == null) return "text-muted";
|
||||
if (days >= 90) return "text-red";
|
||||
if (days >= 60) return "text-amber";
|
||||
return "text-muted";
|
||||
}
|
||||
|
||||
export function DeadStockTable() {
|
||||
const [idleDays, setIdleDays] = useState<60 | 90>(60);
|
||||
const query = useDeadStock(idleDays);
|
||||
const items = query.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
|
||||
موجودی راکد — سرمایهی خوابیده
|
||||
</span>
|
||||
}
|
||||
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازهی انتخابی نداشتهاند"
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
{([60, 90] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setIdleDays(d)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
|
||||
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{faNum(d)} روز و بیشتر
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : items.length === 0 ? (
|
||||
<ChartEmpty message={`موجودی راکدی با آستانهی ${faNum(idleDays)} روز وجود ندارد`} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>متریال و مشخصات</TableHead>
|
||||
<TableHead>برند</TableHead>
|
||||
<TableHead>انبار / پروژه</TableHead>
|
||||
<TableHead className="text-end">مقدار راکد</TableHead>
|
||||
<TableHead className="text-end">روزهای رکود</TableHead>
|
||||
<TableHead>آخرین فعالیت</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((it) => {
|
||||
const specs = specLabel(it.specs);
|
||||
return (
|
||||
<TableRow key={it.inventory_id}>
|
||||
<TableCell>
|
||||
<div className="text-[13px] font-bold">{it.material}</div>
|
||||
{specs && <div className="text-[11px] text-muted">{specs}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-[12px]">{it.warehouse}</div>
|
||||
<div className="text-[10.5px] text-muted">{it.project}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{fmtNum(it.quantity)}{" "}
|
||||
<span className="text-[10.5px] font-normal text-muted">
|
||||
{it.unit?.name_fa || it.unit?.code}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
|
||||
{it.idle_days == null ? "—" : faNum(it.idle_days)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted">
|
||||
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
// ============================================================
|
||||
// ویجت ۱ — ماتریس Drill-Down سلسلهمراتبی با نمودار ۱۰۰٪ استکشده
|
||||
// • سطح ۰: محور X = متریالها، استکها = پروژهها
|
||||
// • سطح ۱: محور X = پروژههای متریال انتخابشده، استکها = انبارها
|
||||
// • قفل هوشمند: با فیلتر فعال انبار، drill-down غیرفعال میشود (کلیک بیاثر
|
||||
// و نشانگر پیشفرض) چون اسکوپ انبار از قبل بسته شده است.
|
||||
// • «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار؛ دامنههای سراسری
|
||||
// پروژه/انبار (کنترلهای چندانتخابی) و متریال انتخابشده بهصورت تگ حذفپذیر.
|
||||
// فیلتر سراسری «چه چیزی» (Zustand) دستنخورده میماند و متریال انتخابی با
|
||||
// هر تغییر آن در برابر payload تازه اعتبارسنجی میشود.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { XAxisTickContentProps } from "recharts";
|
||||
import {
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
MousePointerClick,
|
||||
Package,
|
||||
RotateCcw,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
|
||||
import type { DrilldownProject } from "../../lib/types";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import {
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
} from "./chartShared";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type Level = 0 | 1; // ۰=متریالها (استک: پروژهها)، ۱=پروژهها (استک: انبارها)
|
||||
|
||||
// متریال انتخابشده برای drill-down — نام همان لحظهی کلیک ثبت میشود تا برچسب
|
||||
// تگ breadcrumb حتی هنگام بارگذاری مجدد دادهها پایدار بماند.
|
||||
interface DrillNode {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// یک گروه استک (پروژه در نمای کلی، انبار در نمای drill-down) — نام و رنگ از
|
||||
// payload مشتق میشوند
|
||||
interface StackGroup {
|
||||
key: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// یک میله روی محور X — سهم هر گروه (segments) از مجموع همان واحد.
|
||||
// کمیت هر گروه علاوه بر segments، بهصورت پراپرتیِ مسطح و عددی هم روی خودِ شیء
|
||||
// مینشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتیهای داده خوانده میشود.
|
||||
interface StackedBar {
|
||||
id: string;
|
||||
label: string;
|
||||
unitLabel: string;
|
||||
total: number;
|
||||
segments: Record<string, number>;
|
||||
[segmentKey: string]: number | string | Record<string, number>;
|
||||
}
|
||||
|
||||
// سطح ریشه + برچسبهای ثابت UI (یکجا تعریف میشوند تا در JSX تکرار نشوند)
|
||||
const ROOT_LEVEL: Level = 0;
|
||||
const DRILL_LEVEL: Level = 1;
|
||||
|
||||
const LABELS = {
|
||||
root: "کل سازمان",
|
||||
allProjects: "همهی پروژهها",
|
||||
allWarehouses: "همهی انبارها",
|
||||
widgetTitle: "",
|
||||
projectScopeHint: "دامنهی پروژه — برای تغییر انتخابها کلیک کنید",
|
||||
warehouseScopeHint: "دامنهی انبار — برای تغییر انتخابها کلیک کنید",
|
||||
drillHint: "برای مشاهدهی سهم پروژهها و انبارها روی میلهها کلیک کنید",
|
||||
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
|
||||
removeMaterial: "حذف متریال انتخابشده و بازگشت به نمای متریالها",
|
||||
totalLabel: "مجموع نمایش داده شده",
|
||||
itemsLabel: "اقلام نمایش داده شده",
|
||||
tooltipTotal: "مجموع",
|
||||
tooltipBreakdown: "تفکیک بر اساس",
|
||||
emptyChart: "موجودیای با این فیلترها یافت نشد",
|
||||
} as const;
|
||||
|
||||
// فرادادهی سطوح — برچسب محور X هر سطح (نامها از payload میآیند)
|
||||
const LEVEL_META: Record<Level, { axisTitle: string }> = {
|
||||
0: { axisTitle: "متریالها" },
|
||||
1: { axisTitle: "پروژهها" },
|
||||
};
|
||||
|
||||
// ——— تفکیک سگمنتها (استکها) ———
|
||||
// نوع تفکیک زمینهآگاه است: نمای کلی بدون فیلتر پروژه → پروژهها؛ نمای
|
||||
// drill-down متریال یا نمای دارای فیلتر پروژه → انبارها (تفکیک ریزتر).
|
||||
type SegmentKind = "project" | "warehouse";
|
||||
|
||||
const SEGMENT_META: Record<SegmentKind, { label: string; plural: string }> = {
|
||||
project: { label: "پروژه", plural: "پروژهها" },
|
||||
warehouse: { label: "انبار", plural: "انبارها" },
|
||||
};
|
||||
|
||||
function segmentKindFor(level: Level, projectFilterActive: boolean): SegmentKind {
|
||||
return level === DRILL_LEVEL || projectFilterActive ? "warehouse" : "project";
|
||||
}
|
||||
|
||||
// نام کامل سگمنت انبار — انبارهای همنام در پروژههای مختلف با پیشوند پروژه از
|
||||
// هم تفکیک میشوند («مجتمع مسکونی آرام - انبار شمالی»)
|
||||
const SEGMENT_NAME_SEPARATOR = " - ";
|
||||
const warehouseSegmentName = (projectName: string, warehouseName: string): string =>
|
||||
`${projectName}${SEGMENT_NAME_SEPARATOR}${warehouseName}`;
|
||||
|
||||
// انبار همنام در «همان پروژه» هم ممکن است (دیتابیس محدودیت یکتایی روی
|
||||
// (project_id, name) ندارد)؛ فقط در همان حالتِ برخورد، یک شمارهی ترتیب پایدار
|
||||
// به انتهای نام اضافه میشود تا راهنما هیچوقت دو برچسب یکسان نداشته باشد.
|
||||
function disambiguateNames(entries: { key: string; name: string }[]): Map<string, string> {
|
||||
const keysByName = new Map<string, string[]>();
|
||||
entries.forEach((entry) => {
|
||||
const keys = keysByName.get(entry.name) ?? [];
|
||||
keys.push(entry.key);
|
||||
keysByName.set(entry.name, keys);
|
||||
});
|
||||
const names = new Map<string, string>();
|
||||
entries.forEach((entry) => {
|
||||
const twins = keysByName.get(entry.name) ?? [];
|
||||
names.set(
|
||||
entry.key,
|
||||
twins.length > 1 ? `${entry.name} (${faNum(twins.indexOf(entry.key) + 1)})` : entry.name,
|
||||
);
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
// شناسهی استک در Recharts + سقف عرض میلهها بهازای هر سطح
|
||||
const STACK_ID = "dashboard-stack";
|
||||
const MAX_BAR_SIZE: Record<Level, number> = { 0: 110, 1: 64 };
|
||||
const BAR_TOP_RADIUS: [number, number, number, number] = [6, 6, 0, 0];
|
||||
|
||||
// ——— رنگ گروههای استک ———
|
||||
// رنگ هر گروه فقط از «ترتیب آن در payload» مشتق میشود (هیچ نگاشت دستی
|
||||
// گروه→رنگ وجود ندارد)؛ چرخش طلایی تضمین میکند هر تعداد گروه، رنگهای
|
||||
// بهاندازهی کافی متمایز بگیرند.
|
||||
const GROUP_HUE_BASE = 232; // همخانواده با رنگ accent سیستم
|
||||
const GROUP_HUE_STEP = 137.5; // چرخش طلایی
|
||||
const GROUP_SATURATION = 58;
|
||||
const GROUP_LIGHTNESS = 52;
|
||||
|
||||
function groupColor(index: number): string {
|
||||
const hue = (GROUP_HUE_BASE + index * GROUP_HUE_STEP) % 360;
|
||||
return `hsl(${hue.toFixed(1)} ${GROUP_SATURATION}% ${GROUP_LIGHTNESS}%)`;
|
||||
}
|
||||
|
||||
// پسوند شمارش گزینههای چندانتخابی («… و ۲ پروژهی دیگر»)
|
||||
const otherProjectsLabel = (count: number) => `${faNum(count)} پروژهی دیگر`;
|
||||
const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`;
|
||||
|
||||
function crumbHint(level: Level, label: string): string {
|
||||
if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاککردن دامنهها و متریال انتخابی`;
|
||||
return `حذف «${label}» و بازگشت به نمای ${LEVEL_META[ROOT_LEVEL].axisTitle}`;
|
||||
}
|
||||
|
||||
// برچسب یک دامنهی چندانتخابی: «همهی …» / نام / «نام + پسوند شمارش»
|
||||
function scopeLabel(
|
||||
options: { id: string; name: string }[],
|
||||
selectedIds: string[],
|
||||
allLabel: string,
|
||||
othersLabel: (count: number) => string,
|
||||
): string {
|
||||
if (selectedIds.length === 0) return allLabel;
|
||||
const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name);
|
||||
const first = names[0] ?? allLabel;
|
||||
return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first;
|
||||
}
|
||||
|
||||
// ——— چیدمان نمودار: میلهها و برچسبهای محور X ———
|
||||
// هر میله یک اسلات عرض ثابت میگیرد تا میلهها پخش شوند و برچسبهای فارسی
|
||||
// بلند روی هم نیفتند؛ نامهای بلندتر در چند خط شکسته میشوند.
|
||||
const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
|
||||
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
|
||||
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
|
||||
const AXIS_HEIGHT = 110; // ارتفاع ناحیهی برچسبهای محور X (px)
|
||||
const AXIS_TICK_MARGIN = 15; // فاصلهی خط محور تا خط اول برچسب (px)
|
||||
const LABEL_LINE_CHARS = 20; // سقف نویسهی هر خط برچسب
|
||||
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
|
||||
const LABEL_LINE_HEIGHT = 18; // فاصلهی خطوط برچسب (px)
|
||||
|
||||
// شکستن نام بلند به خطوط کوتاه — پایهی تیک محور X
|
||||
function wrapAxisLabel(text: string): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const word of text.split(/\s+/).filter(Boolean)) {
|
||||
// واژههای بسیار بلند هم به تکههای قابل نمایش شکسته میشوند
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) {
|
||||
chunks.push(word.slice(i, i + LABEL_LINE_CHARS));
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) {
|
||||
lines[lines.length - 1] = `${last} ${chunk}`;
|
||||
} else {
|
||||
lines.push(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lines.length <= LABEL_MAX_LINES) return lines;
|
||||
const head = lines.slice(0, LABEL_MAX_LINES - 1);
|
||||
head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`);
|
||||
return head;
|
||||
}
|
||||
|
||||
// تیک سفارشی محور X — برچسب چندخطی با فاصلهی کافی (بدون همپوشانی).
|
||||
// فاصلهی خط اول از محور از tickMargin خودِ XAxis میآید و شروع متن با
|
||||
// dominantBaseline=hanging تراز میشود (مثل تیک پیشفرض Recharts).
|
||||
function AxisTick({ x, y, payload }: XAxisTickContentProps) {
|
||||
const xPos = typeof x === "number" ? x : Number(x);
|
||||
const yPos = typeof y === "number" ? y : Number(y);
|
||||
const lines = wrapAxisLabel(String(payload?.value ?? ""));
|
||||
return (
|
||||
<text
|
||||
x={xPos}
|
||||
y={yPos}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="hanging"
|
||||
fill="#64748b"
|
||||
fontSize={11}
|
||||
fontFamily="Vazirmatn"
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<tspan key={i} x={xPos} dy={i === 0 ? 0 : LABEL_LINE_HEIGHT}>
|
||||
{line}
|
||||
</tspan>
|
||||
))}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
|
||||
// تیک محور Y در نمای ۱۰۰٪ — کسر ۰..۱ به درصد فارسی
|
||||
function percentTick(value: unknown): string {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? `${faNum(Math.round(n * 100))}٪` : "";
|
||||
}
|
||||
|
||||
// کمیتها هرگز بین واحدها جمع نمیشوند: برای هر میله «واحد غالب» (بیشترین
|
||||
// مجموع) از خود payload انتخاب میشود و فقط کمیتهای همان واحد استک میشوند.
|
||||
function pickDominantUnit(qtyByUnit: Record<string, number>): string | null {
|
||||
let code: string | null = null;
|
||||
let best = -Infinity;
|
||||
for (const [unitCode, value] of Object.entries(qtyByUnit)) {
|
||||
if (value > best) {
|
||||
best = value;
|
||||
code = unitCode;
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
// کمیتها همیشه عدد خالص میمانند: هر مقدار غیرعددی/نامعتبر (رشته، NaN) از
|
||||
// محاسبه حذف میشود تا میلهها هرگز بهخاطر دادهی نامعتبر نامرئی نشوند.
|
||||
function numericValues(qtyByUnit: Record<string, number>): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [unitCode, rawValue] of Object.entries(qtyByUnit)) {
|
||||
const value = Number(rawValue);
|
||||
if (Number.isFinite(value)) out[unitCode] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// تولتیپ سفارشی — کارت شیشهای مینیمال با سلسلهمراتب روشن:
|
||||
// سربرگ (موجودیت میله + نوع تفکیک جاری)، بدنه (بهازای هر سگمنت: نقطهی رنگ
|
||||
// داینامیک، نام کامل، کمیت و سهم درصدی) و پاصفحه (جمع میله).
|
||||
// عرض کارت با محتوا رشد میکند (w-max) و فقط با سقف واکنشگرا محدود میشود؛
|
||||
// متنهای بلند (عنوان/نام سگمنت) در همان سقف میشکنند تا هرگز از کادر بیرون
|
||||
// نزنند، و گروههای عددی با whitespace-nowrap یکتکه میمانند تا اعداد و متن
|
||||
// در حالت RTL بههم نریزند.
|
||||
function CustomTooltip({
|
||||
active,
|
||||
label,
|
||||
payload,
|
||||
stacks,
|
||||
segmentKind,
|
||||
}: {
|
||||
active?: boolean;
|
||||
label?: string | number;
|
||||
payload?: readonly { payload?: StackedBar }[];
|
||||
stacks: StackGroup[];
|
||||
segmentKind: SegmentKind;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const bar = payload[0]?.payload;
|
||||
if (!bar) return null;
|
||||
const rows = stacks
|
||||
.map((stack) => ({ stack, value: bar.segments[stack.key] ?? 0 }))
|
||||
.filter((row) => row.value > 0);
|
||||
if (rows.length === 0) return null;
|
||||
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[320px] sm:whitespace-nowrap"
|
||||
>
|
||||
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="whitespace-normal text-xs font-bold text-slate-800">
|
||||
{String(label ?? bar.label)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 sm:text-xs">
|
||||
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
|
||||
{/* ردیف هر سگمنت: نام سمت راست، کمیت و درصد چسبیده به هم سمت چپ */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.stack.key} className="flex w-full items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ background: row.stack.color }}
|
||||
/>
|
||||
<span className="whitespace-normal text-[11px] font-medium text-slate-700 sm:text-xs">
|
||||
{row.stack.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||
{fmtNum(row.value)} {bar.unitLabel}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[11px] tabular-nums text-slate-700 sm:text-xs">
|
||||
{faNum(Math.round((row.value / total) * 100))}٪
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
|
||||
{/* جمع میله — «مجموع: ۸۰۰ متر» کنار هم و از سمت راست */}
|
||||
<div className="flex w-full items-center justify-start gap-1.5 text-xs font-semibold text-slate-600">
|
||||
<span>{LABELS.tooltipTotal}:</span>
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{fmtNum(total)} {bar.unitLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازهای میآید و
|
||||
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
|
||||
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
|
||||
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
|
||||
|
||||
// --- MultiSelect Dropdown Component ---
|
||||
// کنترل چندانتخابی دامنه (پروژه/انبار) — بهعنوان گرهی چندانتخابیِ breadcrumb
|
||||
// رندر میشود؛ برچسبش بازتاب انتخابهای فعلی است و ردیف «همهی …» انتخابها را
|
||||
// آزاد میکند. کلیکهای درون منو با stopPropagation جدا میشوند تا هرگز به
|
||||
// handlerهای نمودار نرسند.
|
||||
function MultiSelectDropdown({
|
||||
label,
|
||||
options,
|
||||
selectedIds,
|
||||
onChange,
|
||||
disabled,
|
||||
allLabel,
|
||||
onClearAll,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
options: { id: string; name: string }[];
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
disabled?: boolean;
|
||||
// با ارائهی این دو، ردیف «همهی …» در ابتدای فهرست ظاهر میشود
|
||||
allLabel?: string;
|
||||
onClearAll?: () => void;
|
||||
title?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const toggleItem = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((x) => x !== id));
|
||||
} else {
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
|
||||
disabled ? "cursor-not-allowed opacity-50" : "hover:bg-slate-bg hover:text-accent cursor-pointer",
|
||||
selectedIds.length > 0 ? "text-accent" : "text-ink"
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 max-h-60 w-56 overflow-auto rounded-lg border border-line bg-panel p-1 shadow-xl">
|
||||
{allLabel && onClearAll && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// رویداد منو هرگز نباید به کلیکهای نمودار برسد
|
||||
event.stopPropagation();
|
||||
onClearAll();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs font-bold text-accent transition-colors hover:bg-slate-bg"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{allLabel}</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-line" />
|
||||
</>
|
||||
)}
|
||||
{options.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-muted">گزینهای یافت نشد</div>
|
||||
) : (
|
||||
options.map((opt) => {
|
||||
const isSelected = selectedIds.includes(opt.id);
|
||||
return (
|
||||
<div
|
||||
key={opt.id}
|
||||
onClick={(event) => {
|
||||
// رویداد انتخاب گزینه هم ایزوله میشود
|
||||
event.stopPropagation();
|
||||
toggleItem(opt.id);
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-slate-bg"
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-accent" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-muted" />
|
||||
)}
|
||||
<span className="truncate">{opt.name}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrilldownMatrix() {
|
||||
const query = useDrilldown();
|
||||
|
||||
// متریال انتخابشده (سطح ۱) — تنها حالت محلی نمودار؛ سطح از آن مشتق میشود
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<DrillNode | null>(null);
|
||||
const level: Level = selectedMaterial ? DRILL_LEVEL : ROOT_LEVEL;
|
||||
const materialId = selectedMaterial?.id ?? null;
|
||||
|
||||
// فیلترهای دستی (چندگزینهای پروژه/انبار) — وابسته به payload جاری
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
|
||||
|
||||
// تفکیک جاری نمودار: نمای کلی بدون فیلتر پروژه → پروژهها؛ نمای drill-down یا
|
||||
// نمای دارای فیلتر پروژه → انبارها
|
||||
const segmentKind = segmentKindFor(level, selectedProjectIds.length > 0);
|
||||
|
||||
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
|
||||
const plotRef = useRef<HTMLDivElement>(null);
|
||||
const [plotWidth, setPlotWidth] = useState(0);
|
||||
|
||||
const data = query.data;
|
||||
|
||||
// گزینههای آبشاری از دل payload ساخته میشوند
|
||||
const projectOptions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, string>();
|
||||
data.materials.forEach((m) => {
|
||||
m.projects.forEach((p) => {
|
||||
map.set(p.id, p.name);
|
||||
});
|
||||
});
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [data]);
|
||||
|
||||
const warehouseOptions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, string>();
|
||||
data.materials.forEach((m) => {
|
||||
m.projects.forEach((p) => {
|
||||
if (selectedProjectIds.length === 0 || selectedProjectIds.includes(p.id)) {
|
||||
p.warehouses.forEach((w) => {
|
||||
map.set(w.id, w.name);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [data, selectedProjectIds]);
|
||||
|
||||
// ——— همگامسازی با فیلتر سراسری (Zustand) ———
|
||||
// فیلتر سراسری، payload را زیر پای حالت محلی عوض میکند؛ بهجای پاککردن
|
||||
// کورکورانهی انتخابها، حالت محلی را با دادهی تازه اعتبارسنجی میکنیم تا نه
|
||||
// در سطح کهنه گیر کنیم و نه بیدلیل به عقب بپریم.
|
||||
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
|
||||
|
||||
// ۱) انتخابهای وابسته به payload (پروژه/انبار) نباید به شناسههایی اشاره کنند
|
||||
// که در نتیجهی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپهای فعال میبینیم.
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const validProjectIds = new Set(projectOptions.map((o) => o.id));
|
||||
setSelectedProjectIds((ids) =>
|
||||
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
|
||||
);
|
||||
const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id));
|
||||
setSelectedWarehouseIds((ids) =>
|
||||
ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)),
|
||||
);
|
||||
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
|
||||
|
||||
// ۲) اگر متریال انتخابشده از payload حذف شده باشد، نمودار به نمای متریالها
|
||||
// برمیگردد؛ انتخاب معتبر دستنخورده میماند.
|
||||
useEffect(() => {
|
||||
if (!data || !selectedMaterial) return;
|
||||
if (!data.materials.some((m) => m.id === selectedMaterial.id)) {
|
||||
setSelectedMaterial(null);
|
||||
}
|
||||
}, [globalFilterKey, data, selectedMaterial]);
|
||||
|
||||
// ——— اندازهگیری عرض کانتینر نمودار ———
|
||||
// عرض صریح BarChart از همین مقدار و تعداد میلهها حساب میشود؛ کانتینر
|
||||
// اسکرولدار است، پس با هر تغییر اندازهی پنجره/کارت دوباره اندازهگیری میشود.
|
||||
useEffect(() => {
|
||||
const el = plotRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setPlotWidth(el.clientWidth);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// ——— دادهی نمودار ۱۰۰٪ استکشده ———
|
||||
// محورها، سگمنتها (پروژه/انبار بر اساس segmentKind)، نامهای کامل، واحدها
|
||||
// و رنگها همه از payload مشتق میشوند؛ هیچ نگاشت دستی در کد وجود ندارد.
|
||||
const { bars, stacks, unitLabels } = useMemo(() => {
|
||||
const empty = {
|
||||
bars: [] as StackedBar[],
|
||||
stacks: [] as StackGroup[],
|
||||
unitLabels: [] as string[],
|
||||
};
|
||||
if (!data) return empty;
|
||||
|
||||
const matchesProject = (pid: string) =>
|
||||
selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
|
||||
const matchesWarehouse = (wid: string) =>
|
||||
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
|
||||
|
||||
interface GroupAcc {
|
||||
name: string;
|
||||
byUnit: Record<string, number>;
|
||||
}
|
||||
const barsAcc = new Map<
|
||||
string,
|
||||
{ id: string; label: string; ownUnits: Record<string, number>; groups: Map<string, GroupAcc> }
|
||||
>();
|
||||
|
||||
// هر میله حتی بدون گروه هم ساخته میشود تا برچسبش روی محور بماند
|
||||
// (متریالهای کاتالوگی با موجودی صفر هم روی محور دیده میشوند).
|
||||
const ensureBar = (id: string, label: string, ownUnits: Record<string, number>) => {
|
||||
let bar = barsAcc.get(id);
|
||||
if (!bar) {
|
||||
bar = { id, label, ownUnits: numericValues(ownUnits), groups: new Map() };
|
||||
barsAcc.set(id, bar);
|
||||
}
|
||||
return bar;
|
||||
};
|
||||
const addGroup = (
|
||||
bar: { groups: Map<string, GroupAcc> },
|
||||
groupId: string,
|
||||
groupName: string,
|
||||
qtyByUnit: Record<string, number>,
|
||||
) => {
|
||||
let group = bar.groups.get(groupId);
|
||||
if (!group) {
|
||||
group = { name: groupName, byUnit: {} };
|
||||
bar.groups.set(groupId, group);
|
||||
}
|
||||
for (const [unitCode, value] of Object.entries(numericValues(qtyByUnit))) {
|
||||
group.byUnit[unitCode] = (group.byUnit[unitCode] ?? 0) + value;
|
||||
}
|
||||
};
|
||||
|
||||
// میلهها و سگمنتها با یک الگوی واحد ساخته میشوند: میله = متریال (نمای
|
||||
// کلی) یا پروژه (نمای drill-down)، و سگمنت بر اساس تفکیک زمینهآگاه
|
||||
// (segmentKind) پروژه یا انبار است.
|
||||
const addBar = (
|
||||
barId: string,
|
||||
barLabel: string,
|
||||
ownUnits: Record<string, number>,
|
||||
projects: DrilldownProject[],
|
||||
) => {
|
||||
const bar = ensureBar(barId, barLabel, ownUnits);
|
||||
projects.forEach((proj) => {
|
||||
if (!matchesProject(proj.id)) return;
|
||||
proj.warehouses.forEach((wh) => {
|
||||
if (!matchesWarehouse(wh.id)) return;
|
||||
if (segmentKind === "project") {
|
||||
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
|
||||
} else {
|
||||
// سگمنت انبار: نام کامل «پروژه - انبار» تا انبارهای همنام در
|
||||
// پروژههای مختلف در راهنما و تولتیپ قابل تفکیک باشند
|
||||
addGroup(bar, wh.id, warehouseSegmentName(proj.name, wh.name), wh.qty_by_unit);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (level === ROOT_LEVEL) {
|
||||
// میله = متریال، سگمنت = پروژه (نمای کلی) یا انبار (وقتی فیلتر پروژه فعال است)
|
||||
data.materials.forEach((mat) => addBar(mat.id, mat.name, mat.qty_by_unit, mat.projects));
|
||||
} else {
|
||||
// میله = پروژهی متریال انتخابشده، سگمنت = انبار
|
||||
const mat = data.materials.find((m) => m.id === materialId);
|
||||
mat?.projects.forEach((proj) => addBar(proj.id, proj.name, proj.qty_by_unit, [proj]));
|
||||
}
|
||||
|
||||
const groupNames = new Map<string, string>();
|
||||
const bars: StackedBar[] = [];
|
||||
for (const bar of barsAcc.values()) {
|
||||
// واحد غالب میله: از مجموع گروهها و در نبود گروه، از واحد خودِ موجودیت
|
||||
const unitTotals: Record<string, number> = { ...bar.ownUnits };
|
||||
for (const group of bar.groups.values()) {
|
||||
for (const [unitCode, value] of Object.entries(group.byUnit)) {
|
||||
unitTotals[unitCode] = (unitTotals[unitCode] ?? 0) + value;
|
||||
}
|
||||
}
|
||||
const unitCode = pickDominantUnit(unitTotals);
|
||||
const segments: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const [groupId, group] of bar.groups) {
|
||||
const value = unitCode ? group.byUnit[unitCode] ?? 0 : 0;
|
||||
segments[groupId] = value;
|
||||
total += value;
|
||||
groupNames.set(groupId, group.name);
|
||||
}
|
||||
bars.push({
|
||||
id: bar.id,
|
||||
label: bar.label,
|
||||
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
|
||||
total,
|
||||
segments,
|
||||
// مسطحسازی برای Recharts: هر <Bar dataKey={stack.key}> مقدار عددی را
|
||||
// مستقیماً از سطح پراپرتیهای همین شیء میخواند
|
||||
...segments,
|
||||
});
|
||||
}
|
||||
bars.sort((a, b) => b.total - a.total);
|
||||
|
||||
// فهرست استکها = اجتماع گروههای حاضر؛ ترتیب (و بنابراین رنگها) از مجموع
|
||||
// کمیتها مشتق میشود و در حالت تساوی با نام، پایدار میماند.
|
||||
const stackTotals = new Map<string, number>();
|
||||
for (const bar of bars) {
|
||||
for (const [groupId, value] of Object.entries(bar.segments)) {
|
||||
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
|
||||
}
|
||||
}
|
||||
const orderedKeys = [...stackTotals.entries()]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b[1] - a[1] ||
|
||||
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
|
||||
)
|
||||
.map(([key]) => key);
|
||||
// نامهای تکراری (انبار همنامِ همان پروژه) پیش از ساخت راهنما تفکیک میشوند
|
||||
const uniqueNames = disambiguateNames(
|
||||
orderedKeys.map((key) => ({ key, name: groupNames.get(key) ?? key })),
|
||||
);
|
||||
const stacks: StackGroup[] = orderedKeys.map((key, index) => ({
|
||||
key,
|
||||
name: uniqueNames.get(key) ?? groupNames.get(key) ?? key,
|
||||
color: groupColor(index),
|
||||
}));
|
||||
|
||||
return {
|
||||
bars,
|
||||
stacks,
|
||||
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
|
||||
};
|
||||
}, [data, level, materialId, segmentKind, selectedProjectIds, selectedWarehouseIds]);
|
||||
|
||||
// عرض صریح نمودار: هر میله یک اسلات ثابت میگیرد تا میلهها پخش شوند و
|
||||
// برچسبهای محور X فاصلهی کافی داشته باشند؛ هرگز باریکتر از عرض واقعی
|
||||
// کانتینر (و کف تعیینشده) نمیشود.
|
||||
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
|
||||
|
||||
// ——— قفل هوشمند drill-down ———
|
||||
// اگر کاربر انبار(های) مشخصی را در دامنهی سراسری فیلتر کرده باشد، عمیقتر
|
||||
// رفتن روی نمودار بیمعناست؛ کلیک بیاثر و نشانگر پیشفرض میشود.
|
||||
const drillLocked = selectedWarehouseIds.length > 0;
|
||||
|
||||
const handleBarClick = (entry: unknown) => {
|
||||
// قفل فعال → هیچ پرشی انجام نمیشود
|
||||
if (drillLocked) return;
|
||||
// سطح پروژهها، سطح پایانی است؛ فقط از نمای متریالها drill میشویم
|
||||
if (level !== ROOT_LEVEL) return;
|
||||
const bar = (entry as { payload?: StackedBar })?.payload;
|
||||
if (!bar) return;
|
||||
setSelectedMaterial({ id: bar.id, name: bar.label });
|
||||
};
|
||||
|
||||
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک میشوند
|
||||
const resetToRoot = () => {
|
||||
setSelectedMaterial(null);
|
||||
setSelectedProjectIds([]);
|
||||
setSelectedWarehouseIds([]);
|
||||
};
|
||||
|
||||
// حذف تگ متریال — فقط حالت محلی نمودار صفر میشود و دامنههای سراسری دستنخورده میمانند
|
||||
const clearMaterial = () => setSelectedMaterial(null);
|
||||
|
||||
// دامنهی پروژه فقط دامنهی انبارِ زیرمجموعهاش را آزاد میکند
|
||||
const handleProjectScopeChange = (ids: string[]) => {
|
||||
setSelectedProjectIds(ids);
|
||||
if (ids.length === 0) setSelectedWarehouseIds([]);
|
||||
};
|
||||
|
||||
// ردیف «همهی پروژهها» در منوی گره: دامنهی پروژه و دامنهی عمیقترش (انبار) آزاد میشود
|
||||
const clearProjectScope = () => {
|
||||
setSelectedProjectIds([]);
|
||||
setSelectedWarehouseIds([]);
|
||||
};
|
||||
|
||||
// انبار ریزترین دامنه است: فقط خودش آزاد میشود و قفل drill-down برداشته میشود
|
||||
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
|
||||
|
||||
const projectScopeLabel = () =>
|
||||
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
|
||||
const warehouseScopeLabel = () =>
|
||||
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
|
||||
|
||||
// راهنمای تعامل با نمودار — هماهنگ با سطح جاری و قفل هوشمند
|
||||
const drillHint = (): string => {
|
||||
if (level !== ROOT_LEVEL) return "";
|
||||
return drillLocked ? LABELS.lockedHint : LABELS.drillHint;
|
||||
};
|
||||
|
||||
// جمع زدن کمیتهای با واحدهای متفاوت بیمعناست؛ در آن حالت تعداد اقلام میآید
|
||||
const totalText =
|
||||
unitLabels.length === 1
|
||||
? `${LABELS.totalLabel}: ${fmtNum(bars.reduce((sum, bar) => sum + bar.total, 0))} ${unitLabels[0]}`
|
||||
: `${LABELS.itemsLabel}: ${faNum(bars.length)}`;
|
||||
|
||||
// ظاهر مشترک گرههای کلیکی مسیر (گرهی جاری: برجسته؛ گرههای عقبرونده: قابل کلیک)
|
||||
const crumbClass = (isCurrent: boolean) =>
|
||||
cn(
|
||||
"max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]",
|
||||
isCurrent
|
||||
? "bg-accent-bg text-accent hover:bg-accent-bg/70"
|
||||
: "text-muted hover:bg-slate-bg hover:text-ink",
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard title={LABELS.widgetTitle}>
|
||||
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
|
||||
گرههای دامنه (پروژه/انبار) همان فیلترهای سراسریاند و متریال انتخابشده
|
||||
بهصورت تگ حذفپذیر میآید؛ ✕ و ریشه هر دو انتخاب متریال را صفر میکنند. */}
|
||||
<nav
|
||||
dir="rtl"
|
||||
aria-label="مسیر پیمایش گزارش"
|
||||
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
|
||||
>
|
||||
{/* ریشه — بازگشت کامل به کل سازمان */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetToRoot}
|
||||
aria-current={level === ROOT_LEVEL ? "page" : undefined}
|
||||
title={crumbHint(ROOT_LEVEL, LABELS.root)}
|
||||
className={crumbClass(level === ROOT_LEVEL)}
|
||||
>
|
||||
{LABELS.root}
|
||||
</button>
|
||||
|
||||
{/* گرهی دامنهی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخابها */}
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<MultiSelectDropdown
|
||||
label={projectScopeLabel()}
|
||||
options={projectOptions}
|
||||
selectedIds={selectedProjectIds}
|
||||
onChange={handleProjectScopeChange}
|
||||
allLabel={LABELS.allProjects}
|
||||
onClearAll={clearProjectScope}
|
||||
title={LABELS.projectScopeHint}
|
||||
/>
|
||||
|
||||
{/* گرهی دامنهی انبار — فقط وقتی دامنهی پروژه/انبار فعال است */}
|
||||
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
|
||||
<>
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<MultiSelectDropdown
|
||||
label={warehouseScopeLabel()}
|
||||
options={warehouseOptions}
|
||||
selectedIds={selectedWarehouseIds}
|
||||
onChange={setSelectedWarehouseIds}
|
||||
allLabel={LABELS.allWarehouses}
|
||||
onClearAll={clearWarehouseScope}
|
||||
title={LABELS.warehouseScopeHint}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* تگ متریال انتخابشده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف میکنند */}
|
||||
{selectedMaterial && (
|
||||
<>
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearMaterial}
|
||||
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
|
||||
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// ✕ نباید به handler بدنهی تگ برسد
|
||||
event.stopPropagation();
|
||||
clearMaterial();
|
||||
}}
|
||||
aria-label={LABELS.removeMaterial}
|
||||
title={LABELS.removeMaterial}
|
||||
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
<X aria-hidden className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="ms-auto text-[11px] tabular-nums text-muted">{totalText}</span>
|
||||
</nav>
|
||||
|
||||
{/* میلههای صفر عمداً رندر میشوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط
|
||||
وقتی است که هیچ میلهای با فیلترها همخوانی نداشته باشد */}
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !bars.length ? (
|
||||
<ChartEmpty message={LABELS.emptyChart} />
|
||||
) : (
|
||||
<>
|
||||
{/* کانتینر اسکرولدار — عرض صریح نمودار از chartWidth میآید تا
|
||||
میلهها پخش شوند و برچسبهای فاصلهی کافی داشته باشند.
|
||||
ریشهی حلقهی مشکی فوکوس: Recharts لایههای z-index را بهصورت
|
||||
<g tabindex="-1" class="recharts-zIndex-layer_*"> رندر میکند
|
||||
(ZIndexPortal.js) و آن g کل ناحیهی نمودار را میپوشاند؛ پس با
|
||||
کلیک، فوکوس روی همین g مینشیند و مرورگر حلقهی پیشفرض را دور کل
|
||||
نمودار میکشد. چون outline ارثبری نمیشود، مهار آن فقط با
|
||||
سلکتور فرزند (g/سایر عناصر داخل نمودار) ممکن است. */}
|
||||
<div
|
||||
ref={plotRef}
|
||||
dir="ltr"
|
||||
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
|
||||
style={{ width: '100%', height: CHART_HEIGHT }}
|
||||
>
|
||||
{/* بدون لایهی دسترسپذیری، Recharts روی خود SVG دیگر tabIndex
|
||||
نمیگذارد؛ ولی لایههای z-index داخلیاش (g[tabindex="-1"]) هنوز
|
||||
فوکوسپذیرند و همانها منبع حلقهی فوکوساند — که با سلکتورهای
|
||||
فرزند روی wrapper مهار شدهاند. */}
|
||||
<BarChart
|
||||
width={chartWidth}
|
||||
height={CHART_HEIGHT}
|
||||
data={bars}
|
||||
stackOffset="expand"
|
||||
accessibilityLayer={false}
|
||||
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={AxisTick}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
interval={0}
|
||||
height={AXIS_HEIGHT}
|
||||
tickMargin={AXIS_TICK_MARGIN}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={percentTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
domain={[0, 1]}
|
||||
tickCount={5}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||
content={(props) => (
|
||||
<CustomTooltip {...props} stacks={stacks} segmentKind={segmentKind} />
|
||||
)}
|
||||
/>
|
||||
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشههای گرد فقط
|
||||
برای بالاترین گروه (آخرین Bar) اعمال میشود */}
|
||||
{stacks.map((stack, index) => (
|
||||
<Bar
|
||||
key={stack.key}
|
||||
dataKey={stack.key}
|
||||
stackId={STACK_ID}
|
||||
fill={stack.color}
|
||||
radius={index === stacks.length - 1 ? BAR_TOP_RADIUS : 0}
|
||||
maxBarSize={MAX_BAR_SIZE[level]}
|
||||
isAnimationActive={false}
|
||||
onClick={handleBarClick}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
drillLocked ? "cursor-default" : "cursor-pointer hover:opacity-90",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</div>
|
||||
|
||||
{/* راهنمای رنگها — از همان سگمنتهای payload ساخته میشود و نام
|
||||
انبارها بهصورت کامل «پروژه - انبار» نمایش داده میشود */}
|
||||
{stacks.length > 0 && (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
|
||||
>
|
||||
<span className="font-bold">
|
||||
{LEVEL_META[level].axisTitle} × {SEGMENT_META[segmentKind].plural}:
|
||||
</span>
|
||||
{stacks.map((stack) => (
|
||||
<span key={stack.key} className="inline-flex items-center gap-1">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: stack.color }} />
|
||||
<span className="max-w-[16rem] truncate" title={stack.name}>
|
||||
{stack.name}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{drillHint() && (
|
||||
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
|
||||
<MousePointerClick className="h-3.5 w-3.5" />
|
||||
{drillHint()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// ============================================================
|
||||
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
||||
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
||||
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
||||
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
|
||||
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||
|
||||
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
|
||||
const FLOW_COLORS = {
|
||||
inbound: "#059669", // ورود به انبار — زمردی
|
||||
returns: "#0d9488", // برگشت از سایت — تیل
|
||||
siteOut: "#f59e0b", // خروج به سایت — کهربایی
|
||||
waste: "#dc2626", // ضایعات و اتلاف — قرمز
|
||||
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
|
||||
balance: "#0f172a", // خط مانده — سرمهای
|
||||
} as const;
|
||||
|
||||
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
|
||||
const LABELS = {
|
||||
title: "جریان روزانه موجودی",
|
||||
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
|
||||
inbound: "ورود به انبار",
|
||||
returns: "برگشت از سایت",
|
||||
siteOut: "خروج به سایت",
|
||||
waste: "ضایعات و اتلاف",
|
||||
other: "سایر حرکات (انتقال/شمارش)",
|
||||
balance: "مانده",
|
||||
opening: "مانده ابتدای روز",
|
||||
net: "تغییر خالص روز",
|
||||
closing: "مانده پایان روز",
|
||||
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||
} as const;
|
||||
|
||||
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> بهعنوان مقدار محدوده میخواند
|
||||
type Range = [number, number];
|
||||
|
||||
// یک ردیف زمانی — سگمنتها بهصورت پراپرتیهای مسطح مینشینند تا dataKey هر
|
||||
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
|
||||
interface FlowRow {
|
||||
date: string;
|
||||
balance: number; // خط پلهای مانده = مانده پایان روز
|
||||
opening: number;
|
||||
closing: number;
|
||||
net: number;
|
||||
inbound: number;
|
||||
returns: number;
|
||||
siteOut: number;
|
||||
waste: number;
|
||||
other: number;
|
||||
hasMovement: boolean;
|
||||
segInbound: Range;
|
||||
segReturns: Range;
|
||||
segSiteOut: Range;
|
||||
segWaste: Range;
|
||||
segOther: Range;
|
||||
}
|
||||
|
||||
// گرد کردن تا ۴ رقم اعشار — مثل بقیهی تجمیعهای داشبورد
|
||||
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
||||
|
||||
// حاشیهی دامنهی محور Y (نسبت به بازهی داده) تا لبهها به قاب نچسبند
|
||||
const DOMAIN_PAD_RATIO = 0.04;
|
||||
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور میآید
|
||||
const BAR_CATEGORY_GAP = "18%";
|
||||
// همهی سگمنتها یک ستون مشترک میسازند (نه چند ستون باریک کنار هم)؛ چون
|
||||
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
|
||||
// دقیقاً در همان ارتفاع شناور رسم میکند.
|
||||
const FLOW_STACK_ID = "flow-waterfall";
|
||||
|
||||
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
|
||||
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||
|
||||
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
|
||||
function FlowTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: readonly { payload?: FlowRow }[];
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const row = payload[0]?.payload;
|
||||
if (!row) return null;
|
||||
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
|
||||
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
|
||||
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
|
||||
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
|
||||
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
|
||||
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
|
||||
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
|
||||
];
|
||||
if (row.other !== 0) {
|
||||
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
|
||||
}
|
||||
const netTone =
|
||||
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
|
||||
>
|
||||
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
|
||||
{isoToJalaliStr(row.date) ?? row.date}
|
||||
</div>
|
||||
<div className="my-2 border-b border-slate-100" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{lines.map((line) => (
|
||||
<div key={line.label} className="flex items-center justify-between gap-6">
|
||||
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
|
||||
{line.label}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
|
||||
>
|
||||
{line.value === 0 ? fmtNum(0) : signed(line.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-0.5 border-b border-slate-100" />
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
||||
{LABELS.net}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
|
||||
>
|
||||
{signed(row.net)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
||||
{LABELS.closing}
|
||||
</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
|
||||
>
|
||||
{fmtNum(row.closing)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowChart() {
|
||||
const query = useDailyFlow();
|
||||
|
||||
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار میشود ———
|
||||
const rows: FlowRow[] = useMemo(() => {
|
||||
const days = query.data?.days ?? [];
|
||||
let running = query.data?.opening_balance ?? 0;
|
||||
return days.map((d) => {
|
||||
const opening = running;
|
||||
const closing = d.balance;
|
||||
const waste = Math.max(0, d.waste);
|
||||
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته میشود
|
||||
const siteOut = Math.max(0, d.outbound - waste);
|
||||
const net = round4(closing - opening);
|
||||
// «سایر حرکات» = باقیماندهی تراز (انتقال/شمارش/یافتشده)؛ معمولاً صفر
|
||||
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
|
||||
|
||||
let cursor = opening;
|
||||
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
|
||||
const segment = (delta: number): Range => {
|
||||
const from = cursor;
|
||||
cursor = round4(cursor + delta);
|
||||
return [Math.min(from, cursor), Math.max(from, cursor)];
|
||||
};
|
||||
const segInbound = segment(d.inbound);
|
||||
const segReturns = segment(d.returns);
|
||||
const segSiteOut = segment(-siteOut);
|
||||
const segWaste = segment(-waste);
|
||||
const segOther = segment(other);
|
||||
|
||||
running = closing;
|
||||
return {
|
||||
date: d.date,
|
||||
balance: closing,
|
||||
opening,
|
||||
closing,
|
||||
net,
|
||||
inbound: d.inbound,
|
||||
returns: d.returns,
|
||||
siteOut,
|
||||
waste,
|
||||
other,
|
||||
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
|
||||
segInbound,
|
||||
segReturns,
|
||||
segSiteOut,
|
||||
segWaste,
|
||||
segOther,
|
||||
};
|
||||
});
|
||||
}, [query.data]);
|
||||
|
||||
// ——— دامنهی محور Y از خود داده (نه از صفر) تا ستونها و خط در کادر جا شوند ———
|
||||
const [domainMin, domainMax] = useMemo(() => {
|
||||
const values: number[] = [];
|
||||
rows.forEach((r) => {
|
||||
values.push(r.balance, r.opening, r.closing);
|
||||
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
|
||||
values.push(lo, hi);
|
||||
});
|
||||
});
|
||||
if (values.length === 0) return [0, 1] as const;
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
||||
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
||||
}, [rows]);
|
||||
|
||||
const hasMovement = rows.some((r) => r.hasMovement);
|
||||
const hasOther = rows.some((r) => r.other !== 0);
|
||||
|
||||
return (
|
||||
<ChartCard title={LABELS.title} sub={LABELS.sub}>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !hasMovement ? (
|
||||
<ChartEmpty message={LABELS.empty} />
|
||||
) : (
|
||||
<div dir="ltr" className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={rows}
|
||||
barCategoryGap={BAR_CATEGORY_GAP}
|
||||
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
|
||||
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
minTickGap={18}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[domainMin, domainMax]}
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={<FlowTooltip />}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||
verticalAlign="top"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value: unknown) => (
|
||||
<span dir="rtl" className="text-[11px] text-muted">
|
||||
{String(value)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
{/* سگمنتهای شناور آبشاری — هر جزء دقیقاً روی مسیر مانده مینشیند */}
|
||||
<Bar
|
||||
dataKey="segInbound"
|
||||
name={LABELS.inbound}
|
||||
fill={FLOW_COLORS.inbound}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="segReturns"
|
||||
name={LABELS.returns}
|
||||
fill={FLOW_COLORS.returns}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="segSiteOut"
|
||||
name={LABELS.siteOut}
|
||||
fill={FLOW_COLORS.siteOut}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="segWaste"
|
||||
name={LABELS.waste}
|
||||
fill={FLOW_COLORS.waste}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده میشود */}
|
||||
{hasOther && (
|
||||
<Bar
|
||||
dataKey="segOther"
|
||||
name={LABELS.other}
|
||||
fill={FLOW_COLORS.other}
|
||||
stackId={FLOW_STACK_ID}
|
||||
radius={2}
|
||||
maxBarSize={MAX_BAR_SIZE}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="balance"
|
||||
name={LABELS.balance}
|
||||
stroke={FLOW_COLORS.balance}
|
||||
strokeWidth={1.75}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// ============================================================
|
||||
// ویجت ۶ — شبکهی وضعیت سفارشهای در راه
|
||||
// جدول کمی سفارشهای باز: متریال و تأمینکننده | سفارششده | رسیده
|
||||
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
|
||||
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
|
||||
// ============================================================
|
||||
|
||||
import { Truck } from "lucide-react";
|
||||
import { useInTransit } from "../../hooks/useDashboardData";
|
||||
import type { InTransitRow } from "../../lib/types";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
|
||||
const STATE_META: Record<
|
||||
InTransitRow["state"],
|
||||
{ label: string; cls: string; bar: string }
|
||||
> = {
|
||||
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
|
||||
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
|
||||
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
|
||||
};
|
||||
|
||||
function specLabel(specs: Record<string, string>): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||
}
|
||||
|
||||
function num(v: string): number {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export function InTransitGrid() {
|
||||
const query = useInTransit();
|
||||
const rows = query.data ?? [];
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Truck className="h-4 w-4 text-muted" />
|
||||
سفارشهای در راه — وضعیت تحویل
|
||||
</span>
|
||||
}
|
||||
sub="اقلام پذیرفتهشدهی خرید تا رسیدن کامل به انبار"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : rows.length === 0 ? (
|
||||
<ChartEmpty message="سفارش بازی در جریان نیست" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>متریال / تأمینکننده</TableHead>
|
||||
<TableHead className="text-end">سفارششده</TableHead>
|
||||
<TableHead className="text-end">رسیده تاکنون</TableHead>
|
||||
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
|
||||
<TableHead>وضعیت</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => {
|
||||
const ordered = num(r.ordered_qty);
|
||||
const received = num(r.received_qty);
|
||||
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
|
||||
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
|
||||
const specs = specLabel(r.specs);
|
||||
return (
|
||||
<TableRow key={r.item_decision_id}>
|
||||
<TableCell>
|
||||
<div className="text-[13px] font-bold">{r.material.name}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{r.supplier.name}
|
||||
{r.brand ? ` · ${r.brand.name}` : ""}
|
||||
</div>
|
||||
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
|
||||
{r.project && (
|
||||
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{fmtNum(ordered)}
|
||||
<div className="text-[10px] font-normal text-muted">
|
||||
{r.unit?.name_fa || r.unit?.code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{fmtNum(received)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
|
||||
<div
|
||||
className={cn("h-full rounded-full", meta.bar)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
|
||||
{faNum(pct)}٪
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// ============================================================
|
||||
// ویجت ۵ — KPI زمان تأمین (Lead Time)
|
||||
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
|
||||
// از /quotes/stats (میانگین arrived_date − accepted_date).
|
||||
// ============================================================
|
||||
|
||||
import { Timer } from "lucide-react";
|
||||
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { ChartCard, ChartLoading } from "./chartShared";
|
||||
|
||||
export function LeadTimeKpiCard() {
|
||||
const query = useQuoteStats();
|
||||
const days = query.data?.average_lead_time_days;
|
||||
|
||||
return (
|
||||
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
|
||||
<Timer className="h-7 w-7" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
|
||||
{days == null ? "—" : faNum(days)}
|
||||
</span>
|
||||
<span className="text-[14px] font-bold text-muted">روز</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted">
|
||||
{days == null
|
||||
? "هنوز سفارشی به مرحلهی رسیدن نرسیده است"
|
||||
: `بر پایهی سفارشهای رسیدهشده به انبار`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// ============================================================
|
||||
// ویجت ۷ — قیف تدارکات
|
||||
// تبدیل تجمعی: کل درخواستها (پیشنهادشده) → پذیرفتهشده → رسیده به انبار
|
||||
// از /quotes/stats.funnel (شمارش تجمعی بر پایهی تاریخهای واقعی).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
|
||||
|
||||
interface Step {
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
conversion: number | null;
|
||||
}
|
||||
|
||||
export function ProcurementFunnel() {
|
||||
const query = useQuoteStats();
|
||||
|
||||
const steps: Step[] = useMemo(() => {
|
||||
const f = query.data?.funnel;
|
||||
if (!f) return [];
|
||||
const offered = f.offered_total;
|
||||
return [
|
||||
{
|
||||
name: "کل درخواستها (پیشنهادشده)",
|
||||
value: f.offered_total,
|
||||
fill: CHART_COLORS.accent,
|
||||
conversion: null,
|
||||
},
|
||||
{
|
||||
name: "پذیرفتهشده",
|
||||
value: f.accepted_total,
|
||||
fill: CHART_COLORS.blue,
|
||||
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
|
||||
},
|
||||
{
|
||||
name: "رسیده به انبار",
|
||||
value: f.arrived_total,
|
||||
fill: CHART_COLORS.green,
|
||||
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
|
||||
},
|
||||
];
|
||||
}, [query.data]);
|
||||
|
||||
const total = steps[0]?.value ?? 0;
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="قیف تدارکات"
|
||||
sub="تبدیل درخواستها به سفارش پذیرفتهشده و رسیدن کالا به انبار"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : total === 0 ? (
|
||||
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
|
||||
) : (
|
||||
<div className="grid items-center gap-3 sm:grid-cols-5">
|
||||
<div dir="ltr" className="h-56 sm:col-span-3">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<FunnelChart>
|
||||
<Tooltip
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${fmtNum(v)} درخواست`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
|
||||
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
|
||||
<LabelList
|
||||
dataKey="value"
|
||||
position="right"
|
||||
fill="#0f172a"
|
||||
stroke="none"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
formatter={(v: unknown) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? fmtNum(n) : "";
|
||||
}}
|
||||
/>
|
||||
{/* نام مرحله داخل نوار */}
|
||||
<LabelList
|
||||
dataKey="name"
|
||||
position="left"
|
||||
fill="#ffffff"
|
||||
stroke="none"
|
||||
fontSize={11}
|
||||
formatter={(v: unknown) => (v == null ? "" : String(v))}
|
||||
/>
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* مراحل قیف + درصد تبدیل */}
|
||||
<ul className="flex flex-col gap-2 sm:col-span-2">
|
||||
{steps.map((s) => (
|
||||
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
|
||||
<span className="text-[12px] font-bold">{s.name}</span>
|
||||
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
|
||||
{fmtNum(s.value)}
|
||||
</span>
|
||||
</div>
|
||||
{s.conversion != null && (
|
||||
<div className="mt-0.5 text-[10.5px] text-muted">
|
||||
{faNum(s.conversion)}٪ از کل درخواستها
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// ============================================================
|
||||
// ویجت ۸ — تراکم پروژهها (Project Density)
|
||||
// نمودار میلهای افقی: توزیع بار تدارکات روی پروژههای فعال —
|
||||
// سفارشهای باز (ستهشده با پیشنهادهای باز).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useProcurementWorkload } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import {
|
||||
CHART_COLORS,
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
faAxisTick,
|
||||
} from "./chartShared";
|
||||
|
||||
export function ProjectDensityChart() {
|
||||
const query = useProcurementWorkload();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const projects = query.data?.projects ?? [];
|
||||
return projects
|
||||
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
|
||||
.map((p) => ({
|
||||
name: p.name,
|
||||
"سفارش باز (در راه)": p.open_orders,
|
||||
"پیشنهاد باز": p.offered_quotes,
|
||||
_ordered: p.ordered_qty,
|
||||
_received: p.received_qty,
|
||||
}))
|
||||
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
|
||||
}, [query.data]);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="تراکم بار تدارکات روی پروژهها"
|
||||
sub="تعداد سفارشهای باز و پیشنهادهای در جریان به تفکیک پروژه"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : rows.length === 0 ? (
|
||||
<ChartEmpty message="بار تدارکاتی فعالی روی پروژهها وجود ندارد" />
|
||||
) : (
|
||||
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={rows}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
|
||||
barCategoryGap="28%"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={150}
|
||||
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${faNum(v)} مورد`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||
verticalAlign="top"
|
||||
height={26}
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
formatter={(value: unknown) => (
|
||||
<span dir="rtl" className="text-[11px] text-muted">
|
||||
{String(value)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="سفارش باز (در راه)"
|
||||
stackId="load"
|
||||
fill={CHART_COLORS.blue}
|
||||
maxBarSize={22}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="پیشنهاد باز"
|
||||
stackId="load"
|
||||
fill={CHART_COLORS.amber}
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={22}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// ============================================================
|
||||
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
|
||||
// همهی نمودارهای داشبورد از این قالب استفاده میکنند تا خروجی
|
||||
// Recharts فارسی و هماهنگ با دیزاینسیستم (Tailwind @theme) باشد.
|
||||
// ============================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// پالت معنایی نمودارها — همراستا با توکنهای @theme در globals.css
|
||||
export const CHART_COLORS = {
|
||||
inbound: "#16a34a", // ورود = سبز
|
||||
outbound: "#2563eb", // خروج = آبی
|
||||
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایهی داخل ستون خروج)
|
||||
returns: "#d97706", // برگشت از سایت = کهربایی
|
||||
balance: "#0f172a", // مانده = خط سرمهای نقطهچین
|
||||
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
|
||||
amber: "#d97706",
|
||||
green: "#16a34a",
|
||||
red: "#dc2626",
|
||||
blue: "#2563eb",
|
||||
} as const;
|
||||
|
||||
// عدد → رقم فارسی با جداکنندهی هزارگان (تیک محورها)
|
||||
export function faAxisTick(value: unknown): string {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return "";
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
|
||||
return groupCommas(n, true);
|
||||
}
|
||||
|
||||
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
|
||||
export function jaAxisDate(iso: unknown): string {
|
||||
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
|
||||
return j || String(iso ?? "");
|
||||
}
|
||||
|
||||
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نامهای فارسی سریها ———
|
||||
interface TooltipEntry {
|
||||
name?: string | number;
|
||||
value?: string | number;
|
||||
color?: string;
|
||||
dataKey?: string | number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
label?: string | number;
|
||||
labelFormatter?: (label: string | number) => ReactNode;
|
||||
valueFormatter?: (value: number, name: string) => string;
|
||||
}
|
||||
|
||||
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
// ردیفهای صفر را از تولتیپ حذف میکنیم — شلوغیِ بیمعنا (اصل Power BI)
|
||||
const rows = payload.filter((p) => {
|
||||
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||
return Number.isFinite(v) && v !== 0;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
{label !== undefined && label !== "" && (
|
||||
<div className="mb-1 text-[11px] font-bold text-muted">
|
||||
{labelFormatter ? labelFormatter(label) : String(label)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{rows.map((p, i) => {
|
||||
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||
const name = String(p.name ?? p.dataKey ?? "");
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
|
||||
<span className="text-muted">{name}</span>
|
||||
<span className="ms-auto font-bold tabular-nums text-ink">
|
||||
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— کارت نگهدارندهی ویجت — عنوان + توضیح + اکشن ———
|
||||
export function ChartCard({
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
sub?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
|
||||
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
|
||||
{sub && <p className="text-[11px] text-muted">{sub}</p>}
|
||||
</div>
|
||||
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— حالت خالی/خطای ویجت ———
|
||||
export function ChartEmpty({ message = "دادهای برای نمایش وجود ندارد" }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartLoading() {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
|
||||
در حال بارگذاری...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
|
||||
export function UnitPicker({
|
||||
units,
|
||||
value,
|
||||
onChange,
|
||||
totals,
|
||||
}: {
|
||||
units: Record<string, string>;
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
totals?: Record<string, number>;
|
||||
}) {
|
||||
const codes = Object.keys(units);
|
||||
if (codes.length <= 1) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{codes.map((code) => {
|
||||
const total = totals?.[code];
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => onChange(code)}
|
||||
title={"نمایش موجودی به " + (units[code] || code)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
|
||||
value === code
|
||||
? "bg-accent text-white"
|
||||
: "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{units[code] || code}
|
||||
{total != null && (
|
||||
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
|
||||
{fmtNum(total)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,9 +98,10 @@ export function MobileTabBar() {
|
||||
}
|
||||
// سرپرست پروژه به بخشهای تدارکات/دفتر دسترسی ندارد
|
||||
if (isSupervisor) return null;
|
||||
if (pathname === "/wizard") return null;
|
||||
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
||||
const onOfficePage =
|
||||
pathname === "/" ||
|
||||
pathname === "/dashboard" ||
|
||||
pathname === "/projects" ||
|
||||
pathname === "/units" ||
|
||||
pathname.startsWith("/suppliers");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// ============================================================
|
||||
// هوکهای TanStack Query برای داشبورد MVP
|
||||
// همهی ویجتها مستقیم به API واقعی وصلاند — بدون هیچ دادهی ساختگی.
|
||||
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
|
||||
// تغییر فیلتر، فقط ویجتهای همان تب دوباره خوانده شوند.
|
||||
// ============================================================
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiV26 } from "../lib/api";
|
||||
import type { DashboardFilterParams } from "../lib/api";
|
||||
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
|
||||
|
||||
function useFilterParams(): DashboardFilterParams {
|
||||
const state = useDashboardFilter();
|
||||
return dashboardFilterParams(state);
|
||||
}
|
||||
|
||||
// گزینههای آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
|
||||
export function useDashboardFilterOptions() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "filters"],
|
||||
queryFn: () => apiV26.dashboard.filters(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
|
||||
export function useDrilldown() {
|
||||
const params = useFilterParams();
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "drilldown", params],
|
||||
queryFn: () => apiV26.dashboard.drilldown(params),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۲ — موجودی راکد (dead stock)
|
||||
export function useDeadStock(idleDays: number) {
|
||||
const params = useFilterParams();
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "dead-stock", idleDays, params],
|
||||
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۳ — سری زمانی جریان روزانه
|
||||
export function useDailyFlow(dateFrom?: string, dateTo?: string) {
|
||||
const params = useFilterParams();
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازهسازی میشود
|
||||
export function useDailyControl(date?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "daily-control", date],
|
||||
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
|
||||
export function useQuoteStats() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "quote-stats"],
|
||||
queryFn: () => apiV26.dashboard.quoteStats(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
|
||||
export function useInTransit() {
|
||||
const state = useDashboardFilter();
|
||||
const params = dashboardFilterParams(state);
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "in-transit", params],
|
||||
queryFn: () =>
|
||||
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
|
||||
});
|
||||
}
|
||||
|
||||
// ویجت ۸ — بار تدارکات روی پروژهها
|
||||
export function useProcurementWorkload() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "workload"],
|
||||
queryFn: () => apiV26.dashboard.workload(),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
+245
-18
@@ -19,19 +19,38 @@ import { useAuthStore } from "../store/auth";
|
||||
import type {
|
||||
Attachment,
|
||||
ConfirmedReceipt,
|
||||
DashboardFilterOptions,
|
||||
DailyControlData,
|
||||
DeadStockItem,
|
||||
DrilldownData,
|
||||
FlowDay,
|
||||
InTransitRow,
|
||||
InventoryRow,
|
||||
InventoryTransaction,
|
||||
Material,
|
||||
PendingArrival,
|
||||
Project,
|
||||
Quote,
|
||||
QuoteStats,
|
||||
StandardConversion,
|
||||
Store,
|
||||
Supplier,
|
||||
ProjectTransfer,
|
||||
ProjectTransferCreateIn,
|
||||
ProjectTransferReceiveIn,
|
||||
ProjectTransferUpdateIn,
|
||||
TransactionType,
|
||||
Warehouse,
|
||||
WorkloadProject,
|
||||
} from "./types";
|
||||
|
||||
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||
export type DashboardFilterParams = {
|
||||
material_id?: string | null;
|
||||
brand_id?: string | null;
|
||||
specs_hash?: string | null;
|
||||
};
|
||||
|
||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
@@ -239,24 +258,36 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
async function getData<T>(path: string): Promise<T> {
|
||||
const res = await request<{ data: T }>(path);
|
||||
return res.data;
|
||||
const res = await request<any>(path);
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function postData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function putData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
}
|
||||
async function deleteData<T>(path: string): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
||||
return res.data;
|
||||
const res = await request<any>(path, { method: "DELETE" });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
|
||||
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
||||
@@ -468,6 +499,39 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
|
||||
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 دانلود میآیند)
|
||||
interface RawAttachment {
|
||||
id: string;
|
||||
@@ -937,13 +1001,28 @@ export const api = {
|
||||
|
||||
if (becomingAccepted) {
|
||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
||||
// پروژهی واحدِ انتخابشده یا تخصیصهای چندپروژهای برای هر قلم
|
||||
const targetProjByLine = new Map<string, string>();
|
||||
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
|
||||
for (const li of patch.line_items || []) {
|
||||
const tp = li.target_project;
|
||||
if (tp && tp.name) {
|
||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||
if (pid) targetProjByLine.set(li.id, pid);
|
||||
if (li.allocations && li.allocations.length > 0) {
|
||||
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
|
||||
for (const a of li.allocations) {
|
||||
const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
|
||||
if (pid && a.quantity > 0) {
|
||||
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
|
||||
}
|
||||
}
|
||||
if (resolvedAllocs.length) {
|
||||
allocationsByLine.set(li.id, resolvedAllocs);
|
||||
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
|
||||
}
|
||||
} else {
|
||||
const tp = li.target_project;
|
||||
if (tp && tp.name) {
|
||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||
if (pid) targetProjByLine.set(li.id, pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,11 +1042,13 @@ export const api = {
|
||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||
const chosen = !li.rejected && idx >= 0;
|
||||
const chosenProjId = targetProjByLine.get(li.id);
|
||||
const lineAllocs = allocationsByLine.get(li.id);
|
||||
return {
|
||||
line_item_id: li.id,
|
||||
status: chosen ? "chosen" : "rejected",
|
||||
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
||||
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
||||
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
|
||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||
};
|
||||
}),
|
||||
@@ -1030,6 +1111,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: {
|
||||
list: async (): Promise<Supplier[]> => {
|
||||
const raw = await getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`);
|
||||
@@ -1136,6 +1284,16 @@ export const api = {
|
||||
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
|
||||
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: {
|
||||
@@ -1717,16 +1875,23 @@ export const apiV26 = {
|
||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||
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 qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
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: {
|
||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
||||
note?: string | null; transaction_date?: string | null;
|
||||
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
|
||||
}): Promise<InventoryTransaction[]> => {
|
||||
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
||||
},
|
||||
@@ -1737,8 +1902,12 @@ export const apiV26 = {
|
||||
|
||||
pm: {
|
||||
// پروژه فعال بهصورت صریح ارسال میشود؛ بکاند تعلق آن به PM را بررسی میکند
|
||||
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
|
||||
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
|
||||
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => {
|
||||
const qs = new URLSearchParams(
|
||||
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
|
||||
).toString();
|
||||
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
confirmReceipt: async (body: {
|
||||
item_decision_id: string; received_qty: number;
|
||||
allocations: { warehouse_id: string; quantity: number }[];
|
||||
@@ -1749,6 +1918,27 @@ export const apiV26 = {
|
||||
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
|
||||
},
|
||||
|
||||
// ---------- داشبورد MVP (v2.8) — تجمیعهای سمت سرور، فقطخواندنی ----------
|
||||
// نقش/پروژه سمت بکاند از JWT (یا هدرهای dev) حل میشود؛ PM به پروژههای
|
||||
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را میبیند.
|
||||
dashboard: {
|
||||
filters: async (): Promise<DashboardFilterOptions> =>
|
||||
getData<DashboardFilterOptions>("/dashboard/filters"),
|
||||
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
|
||||
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
||||
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
||||
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
||||
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> =>
|
||||
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
||||
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
||||
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
||||
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
||||
getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"),
|
||||
// KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats)
|
||||
quoteStats: async (): Promise<QuoteStats> =>
|
||||
getData<QuoteStats>("/quotes/stats"),
|
||||
},
|
||||
|
||||
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
||||
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته میشوند.
|
||||
catalog: {
|
||||
@@ -1777,4 +1967,41 @@ export const apiV26 = {
|
||||
attributes: [],
|
||||
}),
|
||||
},
|
||||
|
||||
projectTransfers: {
|
||||
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
|
||||
const q = new URLSearchParams();
|
||||
if (projectId) q.set("project_id", projectId);
|
||||
if (direction) q.set("direction", direction);
|
||||
if (status) q.set("status", status);
|
||||
const qs = q.toString() ? `?${q.toString()}` : "";
|
||||
try {
|
||||
const res = await getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
|
||||
return Array.isArray(res) ? res : [];
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch project transfers:", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
|
||||
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
|
||||
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
|
||||
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
|
||||
try {
|
||||
const res = await getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`);
|
||||
return Array.isArray(res) ? res : [];
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch on-the-way transfers:", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface LineItem {
|
||||
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||
target_project_id?: string | null;
|
||||
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژهای در پذیرش
|
||||
}
|
||||
|
||||
// پیشنهاد خرید (اصلیترین entity)
|
||||
@@ -169,6 +170,7 @@ export interface InventoryTransaction {
|
||||
physical_qty?: number | null;
|
||||
transfer_pair_id?: string | null;
|
||||
performed_by?: string | null;
|
||||
delivered_to?: string | null;
|
||||
transaction_date: string;
|
||||
note?: string | null;
|
||||
material?: { id: string; name: string } | null;
|
||||
@@ -193,6 +195,12 @@ export interface PendingArrival {
|
||||
state: "on_the_way" | "partially_received" | "arrived";
|
||||
}
|
||||
|
||||
// v2.6.1 — برند/مشخصههای فrozen قلم (اضافهشده به /pm/on-the-way برای داشبورد)
|
||||
export interface InTransitRow extends PendingArrival {
|
||||
brand: { id: string; name: string } | null;
|
||||
specs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ConfirmedReceipt {
|
||||
receipt_id: string;
|
||||
quote_id: string;
|
||||
@@ -205,3 +213,178 @@ export interface ConfirmedReceipt {
|
||||
quality_note: string | null;
|
||||
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// v2.8 — داشبورد MVP (انبار + بازرگانی)
|
||||
// ============================================================
|
||||
|
||||
// — فیلتر سراسری «چه چیزی» —
|
||||
export interface FilterSpecOption {
|
||||
hash: string;
|
||||
specs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FilterMaterialOption {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
brands: { id: string; name: string }[];
|
||||
specs: FilterSpecOption[];
|
||||
}
|
||||
|
||||
export interface DashboardFilterOptions {
|
||||
materials: FilterMaterialOption[];
|
||||
}
|
||||
|
||||
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||
export type QtyByUnit = Record<string, number>;
|
||||
|
||||
export interface DrilldownWarehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
qty_by_unit: QtyByUnit;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface DrilldownProject {
|
||||
id: string;
|
||||
name: string;
|
||||
qty_by_unit: QtyByUnit;
|
||||
rows: number;
|
||||
warehouses: DrilldownWarehouse[];
|
||||
}
|
||||
|
||||
export interface DrilldownMaterial {
|
||||
id: string;
|
||||
name: string;
|
||||
qty_by_unit: QtyByUnit;
|
||||
rows: number;
|
||||
projects: DrilldownProject[];
|
||||
}
|
||||
|
||||
export interface DrilldownData {
|
||||
units: Record<string, string>;
|
||||
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||
materials: DrilldownMaterial[];
|
||||
}
|
||||
|
||||
// — جدول موجودی راکد —
|
||||
export interface DeadStockItem {
|
||||
inventory_id: string;
|
||||
material: string;
|
||||
brand: string | null;
|
||||
specs: Record<string, string>;
|
||||
warehouse: string;
|
||||
project: string;
|
||||
quantity: number;
|
||||
unit: { code: string; name_fa: string | null };
|
||||
last_activity: string | null;
|
||||
idle_days: number | null;
|
||||
}
|
||||
|
||||
// — سری زمانی جریان روزانه —
|
||||
export interface FlowDay {
|
||||
date: string; // ISO (YYYY-MM-DD)
|
||||
inbound: number;
|
||||
outbound: number;
|
||||
waste: number;
|
||||
returns: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
// — کنترل روزانه —
|
||||
export interface DailyControlData {
|
||||
date: string;
|
||||
outbound: { tickets: number; qty: number };
|
||||
returns: { tickets: number; qty: number };
|
||||
waste: { tickets: number; qty: number };
|
||||
purchases: { tickets: number; qty: number };
|
||||
performers: { name: string; tickets: number; qty: number }[];
|
||||
items: {
|
||||
id: string;
|
||||
transaction_type: string;
|
||||
quantity: number;
|
||||
unit_code: string;
|
||||
performed_by: string | null;
|
||||
note: string | null;
|
||||
warehouse: string;
|
||||
material: string;
|
||||
specs: Record<string, string>;
|
||||
transaction_date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
|
||||
export interface QuoteStats {
|
||||
total: number;
|
||||
offered: number;
|
||||
accepted: number;
|
||||
arrived: number;
|
||||
rejected: number;
|
||||
expired: number;
|
||||
average_lead_time_days: number | null;
|
||||
funnel: {
|
||||
offered_total: number;
|
||||
accepted_total: number;
|
||||
arrived_total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// — بار تدارکات روی پروژهها (Project Density) —
|
||||
export interface WorkloadProject {
|
||||
id: string | null;
|
||||
name: string;
|
||||
offered_quotes: number;
|
||||
chosen_items: number;
|
||||
open_orders: number;
|
||||
ordered_qty: number;
|
||||
received_qty: number;
|
||||
}
|
||||
|
||||
export interface ProjectTransfer {
|
||||
id: string;
|
||||
from_project: { id: string; name: string };
|
||||
from_warehouse: { id: string; name: string };
|
||||
to_project: { id: string; name: string };
|
||||
to_warehouse?: { id: string; name: string } | null;
|
||||
material: { id: string; name: string };
|
||||
brand?: { id: string; name: string } | null;
|
||||
unit: { id: number; code: string; name_fa?: string | null };
|
||||
quantity: number;
|
||||
specs: Record<string, string>;
|
||||
specs_hash: string;
|
||||
note?: string | null;
|
||||
status: "in_transit" | "received" | "cancelled";
|
||||
transfer_date: string;
|
||||
transferred_by?: string | null;
|
||||
received_at?: string | null;
|
||||
received_by?: string | null;
|
||||
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectTransferCreateIn {
|
||||
from_warehouse_id: string;
|
||||
to_project_id: string;
|
||||
material_id: string;
|
||||
brand_id?: string | null;
|
||||
unit_id: number;
|
||||
quantity: number;
|
||||
specs?: Record<string, string>;
|
||||
note?: string | null;
|
||||
transfer_date?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectTransferUpdateIn {
|
||||
to_project_id?: string;
|
||||
quantity?: number;
|
||||
note?: string | null;
|
||||
transfer_date?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectTransferReceiveIn {
|
||||
allocations: { warehouse_id: string; quantity: number }[];
|
||||
received_at?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
@@ -33,6 +33,8 @@ export function LoginPage() {
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const setDevUser = useAuthStore((s) => s.setDevUser);
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const returnUrl = params.get("returnUrl");
|
||||
|
||||
// ---- فرم اصلی (Casdoor) ----
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -89,22 +91,27 @@ export function LoginPage() {
|
||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
||||
track("login_success", { role: user.role });
|
||||
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||
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");
|
||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||
if (returnUrl) {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
} else {
|
||||
navigate("/");
|
||||
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||
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) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||
@@ -146,8 +153,9 @@ export function LoginPage() {
|
||||
}
|
||||
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
|
||||
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
|
||||
// ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار
|
||||
if (devRole === "admin") navigate("/admin");
|
||||
if (returnUrl) {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
} else if (devRole === "admin") navigate("/admin");
|
||||
else if (devRole === "pm") navigate("/inventory");
|
||||
else navigate("/");
|
||||
};
|
||||
|
||||
@@ -23,12 +23,134 @@ import {
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
|
||||
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||||
const TEENS = [
|
||||
"ده",
|
||||
"یازده",
|
||||
"دوازده",
|
||||
"سیزده",
|
||||
"چهارده",
|
||||
"پانزده",
|
||||
"شانزده",
|
||||
"هفده",
|
||||
"هجده",
|
||||
"نوزده",
|
||||
];
|
||||
const TENS = [
|
||||
"",
|
||||
"",
|
||||
"بیست",
|
||||
"سی",
|
||||
"چهل",
|
||||
"پنجاه",
|
||||
"شصت",
|
||||
"هفتاد",
|
||||
"هشتاد",
|
||||
"نود",
|
||||
];
|
||||
const HUNDREDS = [
|
||||
"",
|
||||
"صد",
|
||||
"دویست",
|
||||
"سیصد",
|
||||
"چهارصد",
|
||||
"پانصد",
|
||||
"ششصد",
|
||||
"هفتصد",
|
||||
"هشتصد",
|
||||
"نهصد",
|
||||
];
|
||||
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
|
||||
const DECIMAL_SCALES: Record<number, string> = {
|
||||
1: "دهم",
|
||||
2: "صدم",
|
||||
3: "هزارم",
|
||||
4: "دههزارم",
|
||||
};
|
||||
|
||||
function convertThreeDigits(n: number): string {
|
||||
if (n === 0) return "";
|
||||
const parts: string[] = [];
|
||||
const h = Math.floor(n / 100);
|
||||
const rem = n % 100;
|
||||
if (h > 0) parts.push(HUNDREDS[h]);
|
||||
if (rem >= 10 && rem <= 19) {
|
||||
parts.push(TEENS[rem - 10]);
|
||||
} else {
|
||||
const t = Math.floor(rem / 10);
|
||||
const u = rem % 10;
|
||||
if (t > 0) parts.push(TENS[t]);
|
||||
if (u > 0) parts.push(ONES[u]);
|
||||
}
|
||||
return parts.join(" و ");
|
||||
}
|
||||
|
||||
function toEnglishDigits(str: string): string {
|
||||
return str
|
||||
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
|
||||
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
|
||||
}
|
||||
|
||||
function numberToPersianWords(val: number | string | null | undefined): string {
|
||||
if (val == null || val === "") return "";
|
||||
const numStr = toEnglishDigits(String(val).trim());
|
||||
const num = parseFloat(numStr);
|
||||
if (isNaN(num)) return "";
|
||||
if (num === 0) return "صفر";
|
||||
|
||||
const isNeg = num < 0;
|
||||
const parts = Math.abs(num).toString().split(".");
|
||||
const intStr = parts[0];
|
||||
const decStr = parts[1];
|
||||
|
||||
let intNum = parseInt(intStr, 10);
|
||||
let intResult = "";
|
||||
|
||||
if (intNum === 0) {
|
||||
intResult = "صفر";
|
||||
} else {
|
||||
const chunks: number[] = [];
|
||||
while (intNum > 0) {
|
||||
chunks.push(intNum % 1000);
|
||||
intNum = Math.floor(intNum / 1000);
|
||||
}
|
||||
const chunkWords: string[] = [];
|
||||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||
const c = chunks[i];
|
||||
if (c > 0) {
|
||||
const w = convertThreeDigits(c);
|
||||
const s = SCALES[i];
|
||||
chunkWords.push(s ? `${w} ${s}` : w);
|
||||
}
|
||||
}
|
||||
intResult = chunkWords.join(" و ");
|
||||
}
|
||||
|
||||
if (decStr && parseInt(decStr, 10) > 0) {
|
||||
const cleanDecStr = decStr.slice(0, 4);
|
||||
const decNum = parseInt(cleanDecStr, 10);
|
||||
const decWord = numberToPersianWords(decNum);
|
||||
const scale = DECIMAL_SCALES[cleanDecStr.length];
|
||||
if (scale) {
|
||||
if (intNum === 0) {
|
||||
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
|
||||
}
|
||||
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
|
||||
}
|
||||
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
|
||||
}
|
||||
|
||||
return isNeg ? `منفی ${intResult}` : intResult;
|
||||
}
|
||||
|
||||
interface CardDraft {
|
||||
domId: string;
|
||||
isNew: boolean;
|
||||
milestones: Milestone[];
|
||||
label: string | null; // null = پیشفرض «گزینه N»
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface AcceptModalProps {
|
||||
@@ -51,6 +173,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||
const [itemAllocations, setItemAllocations] = useState<
|
||||
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
|
||||
>({});
|
||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
@@ -67,17 +192,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
setRemovedItems(rem);
|
||||
setAssignments({});
|
||||
const projSel: Record<string, string> = {};
|
||||
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
projSel[li.id] = li.target_project?.name
|
||||
const defaultProj = li.target_project?.name
|
||||
? li.target_project.name
|
||||
: names.length
|
||||
? names[0]
|
||||
: q.projects && q.projects.length
|
||||
? q.projects[0]
|
||||
: q.project || "";
|
||||
projSel[li.id] = defaultProj;
|
||||
if (li.allocations && li.allocations.length) {
|
||||
allocs[li.id] = li.allocations.map((a, idx) => ({
|
||||
rowId: `alloc-${li.id}-${idx}`,
|
||||
project_name: a.project_name,
|
||||
quantity: a.quantity,
|
||||
}));
|
||||
} else {
|
||||
allocs[li.id] = [
|
||||
{
|
||||
rowId: `alloc-${li.id}-0`,
|
||||
project_name: defaultProj,
|
||||
quantity: li.quantity ?? 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
setItemProjectsSel(projSel);
|
||||
setItemAllocations(allocs);
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
@@ -137,6 +280,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
||||
};
|
||||
|
||||
const addAllocationRow = (liId: string, totalQty: number) => {
|
||||
setItemAllocations((prev) => {
|
||||
const currentList = prev[liId] || [];
|
||||
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
|
||||
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
|
||||
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
|
||||
const newRow = {
|
||||
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
project_name: firstAvailable,
|
||||
quantity: remaining > 0 ? remaining : "",
|
||||
};
|
||||
return { ...prev, [liId]: [...currentList, newRow] };
|
||||
});
|
||||
};
|
||||
|
||||
const removeAllocationRow = (liId: string, rowId: string) => {
|
||||
setItemAllocations((prev) => {
|
||||
const currentList = prev[liId] || [];
|
||||
if (currentList.length <= 1) return prev;
|
||||
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
|
||||
});
|
||||
};
|
||||
|
||||
const updateAllocationRow = (
|
||||
liId: string,
|
||||
rowId: string,
|
||||
patch: Partial<{ project_name: string; quantity: number | string }>,
|
||||
) => {
|
||||
setItemAllocations((prev) => {
|
||||
const currentList = prev[liId] || [];
|
||||
return {
|
||||
...prev,
|
||||
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (!q) return;
|
||||
|
||||
@@ -162,6 +343,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
next.line_items.forEach((li) => {
|
||||
if (removedItems.has(li.id)) li.rejected = true;
|
||||
});
|
||||
|
||||
const active = activeItems(next);
|
||||
if (!active.length) {
|
||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||
@@ -206,13 +388,60 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
||||
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||
for (const li of active) {
|
||||
const allocs = itemAllocations[li.id] || [];
|
||||
const m = materialById(materials, li.material_id);
|
||||
const title = formatMaterialTitle(m);
|
||||
const targetQty = Number(li.quantity ?? 0);
|
||||
|
||||
if (!allocs.length) {
|
||||
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const a of allocs) {
|
||||
if (!a.project_name.trim()) {
|
||||
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیفهای تخصیص را مشخص کنید.`);
|
||||
return;
|
||||
}
|
||||
const qVal = parseFloat(String(a.quantity));
|
||||
if (isNaN(qVal) || qVal <= 0) {
|
||||
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگتر از صفر باشد.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pNames = allocs.map((a) => a.project_name.trim());
|
||||
if (new Set(pNames).size !== pNames.length) {
|
||||
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||
const diff = Math.abs(sumQty - targetQty);
|
||||
if (diff > 0.0001) {
|
||||
toast.error(
|
||||
`مجموع مقادیر تخصیصیافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const name = itemProjectsSel[li.id];
|
||||
if (name === undefined) return;
|
||||
li.target_project = name ? { id: "", name } : null;
|
||||
const allocs = itemAllocations[li.id];
|
||||
if (allocs && allocs.length) {
|
||||
li.allocations = allocs.map((a) => ({
|
||||
project_name: a.project_name.trim(),
|
||||
quantity: parseFloat(String(a.quantity)) || 0,
|
||||
}));
|
||||
li.target_project = { id: "", name: allocs[0].project_name.trim() };
|
||||
} else {
|
||||
const name = itemProjectsSel[li.id];
|
||||
if (name !== undefined) {
|
||||
li.target_project = name ? { id: "", name } : null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -232,7 +461,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||
<div className="flex flex-col max-h-[85vh] w-full">
|
||||
|
||||
{/* هدر ثابت و همتراز */}
|
||||
{/* هدر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||
<DialogHeader className="p-0 text-right">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
@@ -343,53 +572,196 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
||||
{/* بخش ۳: تخصیص اقلام به پروژهها */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-[12px] text-muted">
|
||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
||||
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده میشود. در صورت خرید یک کالا برای چند پروژه، میتوانید آن را به چند سطر تفکیک کنید.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
return (
|
||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
|
||||
{projectOptions.length === 0 ? (
|
||||
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
|
||||
⚠️ هیچ پروژهای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژهها، حداقل یک پروژه ایجاد کنید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const targetQty = Number(li.quantity ?? 0);
|
||||
const allocs = itemAllocations[li.id] || [];
|
||||
const allocSum = allocs.reduce(
|
||||
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
|
||||
0
|
||||
);
|
||||
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
|
||||
const isComplete = Math.abs(diff) < 0.0001;
|
||||
const isOver = diff < -0.0001;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
|
||||
>
|
||||
{/* هدر کارت قلم */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-[13.5px] font-bold text-slate-800">
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
|
||||
کل: {fmtNum(targetQty)} {li.unit}
|
||||
</span>
|
||||
|
||||
{isComplete ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
|
||||
</span>
|
||||
) : isOver ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
باقیمانده: {fmtNum(diff)} {li.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||||
<div className="flex flex-col gap-2.5 mb-3">
|
||||
{allocs.map((alloc) => {
|
||||
const otherUsedProjects = new Set(
|
||||
allocs
|
||||
.filter((a) => a.rowId !== alloc.rowId)
|
||||
.map((a) => a.project_name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const words = numberToPersianWords(alloc.quantity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.rowId}
|
||||
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||||
>
|
||||
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||||
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
|
||||
value={alloc.project_name}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
project_name: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">انتخاب پروژه...</option>
|
||||
{projectOptions.map((p) => {
|
||||
const isUsed = otherUsedProjects.has(p);
|
||||
return (
|
||||
<option key={p} value={p} disabled={isUsed}>
|
||||
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف موبایل */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={allocs.length <= 1}
|
||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
|
||||
<div className="flex flex-col w-full sm:w-44 shrink-0">
|
||||
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
placeholder="مقدار"
|
||||
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ MozAppearance: "textfield" }}
|
||||
dir="ltr"
|
||||
value={alloc.quantity}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
quantity: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
|
||||
title={`واحد: ${li.unit}`}
|
||||
>
|
||||
{li.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{words ? (
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
|
||||
{words} {li.unit}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپداون و اینپوت */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={allocs.length <= 1}
|
||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* فوتر کارت قلم */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addAllocationRow(li.id, targetQty)}
|
||||
disabled={
|
||||
allocs.length >= projectOptions.length && projectOptions.length > 0
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted">
|
||||
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{projectOptions.length ? (
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
value={itemProjectsSel[li.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||
}
|
||||
>
|
||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
||||
<option key={i} value="">
|
||||
انتخاب پروژه...
|
||||
</option>
|
||||
))}
|
||||
{projectOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||
@@ -441,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
|
||||
</div>
|
||||
|
||||
{/* فوتر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
{/* فوتر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
||||
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react";
|
||||
import {
|
||||
useMaterials,
|
||||
useProjects,
|
||||
@@ -94,6 +94,15 @@ export function DashboardPage() {
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
actions={
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{/* داشبورد تحلیلی — دکمهی مستقل، همسبک با «پروژهها» و «ضرایب تبدیل» */}
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||
>
|
||||
<ChartColumnBig className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||
<span>داشبورد</span>
|
||||
</Link>
|
||||
|
||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
تأمینکنندهها
|
||||
|
||||
@@ -438,7 +438,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
li.accepted_ts_id = null;
|
||||
li.target_project = null;
|
||||
li.target_project_id = null;
|
||||
li.allocations = undefined;
|
||||
});
|
||||
|
||||
// ادغام سطرهای تفکیکشده حاصل از تخصیص چندپروژهای (الزام ۲)
|
||||
const mergedMap = new Map<string, LineItem>();
|
||||
for (const li of next.line_items) {
|
||||
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
|
||||
const existing = mergedMap.get(key);
|
||||
if (existing) {
|
||||
const q1 = Number(existing.quantity ?? 0);
|
||||
const q2 = Number(li.quantity ?? 0);
|
||||
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
|
||||
if (existing.unit_price != null && existing.quantity != null) {
|
||||
existing.total_price = existing.unit_price * existing.quantity;
|
||||
}
|
||||
} else {
|
||||
mergedMap.set(key, { ...li });
|
||||
}
|
||||
}
|
||||
next.line_items = Array.from(mergedMap.values());
|
||||
} else if (next.status === "accepted") {
|
||||
next.arrived_date = null;
|
||||
next.line_items.forEach((li) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// ============================================================
|
||||
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
|
||||
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابهجایی
|
||||
// بین تبها حفظ میشود). تمام ویجتها مستقیم به API واقعی وصلاند.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { BarChart3, Warehouse } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
|
||||
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
|
||||
import { FlowChart } from "../../components/dashboard/FlowChart";
|
||||
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
|
||||
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
|
||||
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
|
||||
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
|
||||
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
|
||||
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type Tab = "warehouse" | "procurement";
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
|
||||
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
|
||||
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
|
||||
];
|
||||
|
||||
export function MvpDashboardPage() {
|
||||
const [tab, setTab] = useState<Tab>("warehouse");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-20 md:pb-6">
|
||||
<Header
|
||||
title="داشبورد تحلیلی"
|
||||
sub="انبار و تدارکات — فیلتر جنس روی همهی ویجتها اعمال میشود"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
actions={
|
||||
/* تبهای نما — سگمنتکنترل هماهنگ با دیزاینسیستم */
|
||||
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
|
||||
{TABS.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setTab(key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
|
||||
tab === key
|
||||
? "bg-panel text-accent shadow-sm"
|
||||
: "text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
|
||||
<DashboardFilterBar />
|
||||
|
||||
{tab === "warehouse" ? (
|
||||
/* ——— نمای A: انبار ——— */
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
|
||||
<DrilldownMatrix />
|
||||
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<FlowChart />
|
||||
<DailyControlPanel />
|
||||
</div>
|
||||
{/* ردیف سوم: موجودی راکد */}
|
||||
<DeadStockTable />
|
||||
</div>
|
||||
) : (
|
||||
/* ——— نمای B: بازرگانی و لجستیک ——— */
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* KPI + قیف */}
|
||||
<div className="grid gap-3 xl:grid-cols-3">
|
||||
<LeadTimeKpiCard />
|
||||
<div className="xl:col-span-2">
|
||||
<ProcurementFunnel />
|
||||
</div>
|
||||
</div>
|
||||
{/* تراکم پروژهها + سفارشهای در راه */}
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<ProjectDensityChart />
|
||||
<InTransitGrid />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { refreshProcurementStore } from "../../hooks";
|
||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
||||
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
|
||||
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
||||
@@ -27,24 +27,31 @@ export function ArrivalsPage() {
|
||||
const canAccess = isAdmin || isPM || isSupervisor;
|
||||
|
||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
||||
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
||||
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
|
||||
const [arr, whs] = await Promise.all([
|
||||
const [arr, whs, incoming] = await Promise.all([
|
||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM / سرپرست را بررسی میکند
|
||||
apiV26.pm.onTheWay(activeProject),
|
||||
apiV26.warehouses.list(activeProject),
|
||||
apiV26.projectTransfers.onTheWay(activeProject),
|
||||
]);
|
||||
setItems(arr);
|
||||
setWarehouses(whs);
|
||||
setItems(Array.isArray(arr) ? arr : []);
|
||||
setWarehouses(Array.isArray(whs) ? whs : []);
|
||||
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||
setItems([]);
|
||||
setIncomingTransfers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -214,7 +221,55 @@ export function ArrivalsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- transfer helpers ----------
|
||||
|
||||
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
|
||||
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
|
||||
return transferDrafts[t.id];
|
||||
}
|
||||
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
|
||||
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
|
||||
};
|
||||
|
||||
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
|
||||
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
|
||||
};
|
||||
|
||||
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
|
||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||
const targetQty = parseFloat(String(transfer.quantity));
|
||||
|
||||
if (allocSum !== targetQty) {
|
||||
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
|
||||
return;
|
||||
}
|
||||
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
|
||||
toast.error("لطفاً برای تمامی ردیفهای دارای مقدار، انبار مقصد را انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTransferSaving(transfer.id);
|
||||
try {
|
||||
const payloadAllocs = allocs
|
||||
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||
|
||||
await apiV26.projectTransfers.receive(transfer.id, {
|
||||
allocations: payloadAllocs,
|
||||
}, activeProject);
|
||||
|
||||
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
|
||||
} finally {
|
||||
setTransferSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!canAccess) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||
@@ -235,19 +290,171 @@ export function ArrivalsPage() {
|
||||
/>
|
||||
<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">
|
||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
||||
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
||||
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
||||
ماندهی سفارش بیشتر نشود.
|
||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژهها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده میشوند؛ سفارشهای خرید نیز قابلیت رسید کامل یا جزیی دارند.
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
||||
) : !orders.length ? (
|
||||
) : !(orders || []).length && !(incomingTransfers || []).length ? (
|
||||
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
||||
) : (
|
||||
orders.map(([quoteId, its]) => {
|
||||
<>
|
||||
{/* بخش انتقالهای بین کارگاهی در راه */}
|
||||
{(incomingTransfers || []).length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقالهای بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
|
||||
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژهها</span>
|
||||
</div>
|
||||
|
||||
{(incomingTransfers || []).map((t) => {
|
||||
const draftAllocs = transferDraftOf(t);
|
||||
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||
const targetQty = parseFloat(String(t.quantity));
|
||||
const isExactMatch = allocSum === targetQty;
|
||||
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
|
||||
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
|
||||
const days = daysElapsed(t.transfer_date);
|
||||
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
|
||||
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
|
||||
|
||||
return (
|
||||
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||
{/* سربرگ انتقال */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
|
||||
<Badge variant="offered">در راه</Badge>
|
||||
{days !== null && (
|
||||
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||
روز گذشته: {faNum(days)} روز
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
|
||||
<span>ارسالکننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
|
||||
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
|
||||
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[13px] font-bold">{t.material?.name}</span>
|
||||
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
|
||||
<Badge variant="outline">
|
||||
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
|
||||
</Badge>
|
||||
{t.specs && Object.keys(t.specs).length > 0 && (
|
||||
<span className="text-[11.5px] text-muted">
|
||||
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{t.note && (
|
||||
<div className="mt-1 text-[11.5px] text-muted">
|
||||
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
|
||||
<div className="text-[12px] font-semibold text-muted mb-2">
|
||||
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{draftAllocs.map((a) => (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="تعداد"
|
||||
value={a.qty}
|
||||
onChange={(e) => {
|
||||
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={a.whId}
|
||||
onChange={(e) => {
|
||||
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
>
|
||||
<option value="">— انتخاب انبار پروژه مقصد —</option>
|
||||
{destWhOptions.map((w) => (
|
||||
<option key={w.id} value={w.id}>{w.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{draftAllocs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 text-muted hover:text-red transition-colors"
|
||||
onClick={() => {
|
||||
const next = draftAllocs.filter((x) => x.key !== a.key);
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
|
||||
onClick={() => {
|
||||
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
>
|
||||
+ افزودن انبار دیگر برای تقسیم بار
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
|
||||
<div>
|
||||
{isExactMatch ? (
|
||||
<span className="font-semibold text-emerald-600">
|
||||
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-bold text-rose-600">
|
||||
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
|
||||
</span>
|
||||
)}
|
||||
{hasEmptyWh && (
|
||||
<span className="ms-2 font-bold text-rose-600">
|
||||
— لطفاً انبار مقصد را انتخاب کنید
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="green"
|
||||
disabled={!isValid || transferSaving === t.id}
|
||||
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
|
||||
>
|
||||
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* بخش سفارشهای خرید در راه */}
|
||||
{orders.map(([quoteId, its]) => {
|
||||
const first = its[0];
|
||||
const open = expanded.has(quoteId);
|
||||
const hasSubmittable = orderHasSubmittable(quoteId);
|
||||
@@ -403,7 +610,8 @@ export function ArrivalsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -131,9 +131,10 @@ export function DailyControlPage() {
|
||||
const wGroups = useMemo(() => {
|
||||
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
|
||||
withdrawals.forEach((t) => {
|
||||
const g = gmap.get(t.inventory_id);
|
||||
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
|
||||
const g = gmap.get(gKey);
|
||||
if (!g) {
|
||||
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
|
||||
} else {
|
||||
g.q += Math.abs(t.quantity_number || 0);
|
||||
if (t.transaction_date > g.last) g.last = t.transaction_date;
|
||||
@@ -330,6 +331,12 @@ export function DailyControlPage() {
|
||||
whName(data.warehouses, g.tx.warehouse_id)
|
||||
].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
{g.tx.delivered_to && (
|
||||
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
|
||||
<span className="text-slate-500 font-normal">تحویل به:</span>
|
||||
<span>{g.tx.delivered_to}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
||||
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// ============================================================
|
||||
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||
// ============================================================
|
||||
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine, type RowDraft } from "./StockTable";
|
||||
|
||||
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||
|
||||
interface FoundTableProps {
|
||||
items: LossItem[];
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function parseLocal(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||
}
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">متریال</th>
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||
<th className="py-2.5 px-3">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{items.map(({ tx, remaining }, i) => {
|
||||
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||
return (
|
||||
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{items.map(({ tx, remaining }, i) => {
|
||||
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||
return (
|
||||
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||
</div>
|
||||
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="تعداد پیداشده"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// ============================================================
|
||||
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine, type StockRecord } from "./StockTable";
|
||||
|
||||
interface ProjectTransferListProps {
|
||||
records: StockRecord[];
|
||||
qtyOf: (id: string) => string;
|
||||
onQty: (id: string, v: string) => void;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||
errors: Record<string, string>;
|
||||
emptyMsg: string;
|
||||
}
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[600px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">متریال</th>
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||
<th className="py-2.5 px-3">تعداد انتقال</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{records.map((r) => {
|
||||
const err = errors[r.id];
|
||||
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||
return (
|
||||
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="۰"
|
||||
value={qtyOf(r.id)}
|
||||
onChange={(e) => onQty(r.id, e.target.value)}
|
||||
/>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد میشود.</span>
|
||||
<span>{faNum(records.length)} قلم کالا</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{records.map((r) => {
|
||||
const err = errors[r.id];
|
||||
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||
return (
|
||||
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||
</div>
|
||||
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">موجودی</div>
|
||||
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="تعداد انتقال"
|
||||
value={qtyOf(r.id)}
|
||||
onChange={(e) => onQty(r.id, e.target.value)}
|
||||
/>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// ============================================================
|
||||
// جدول «اقلام ثبتشده در این نشست» برای موجودی اولیه
|
||||
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظهی همان صفحه.
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { specsLine } from "./StockTable";
|
||||
|
||||
export interface PurchaseSessionEntry {
|
||||
material: string;
|
||||
brand: string;
|
||||
specs: Record<string, string>;
|
||||
qty: number;
|
||||
unitLabel: string;
|
||||
time: string; // HH:MM آمادهی نمایش
|
||||
whName: string;
|
||||
}
|
||||
|
||||
interface PurchaseSessionLogProps {
|
||||
entries: PurchaseSessionEntry[];
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
|
||||
if (!entries.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||
<h3 className="text-[13px] font-bold">
|
||||
🧾 اقلام ثبتشده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
|
||||
>
|
||||
پاک کردن لیست
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* دسکتاپ: جدول فشرده */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2 px-3">متریال</th>
|
||||
<th className="py-2 px-3">برند</th>
|
||||
<th className="py-2 px-3">مشخصهها</th>
|
||||
<th className="py-2 px-3">تعداد</th>
|
||||
<th className="py-2 px-3">ساعت</th>
|
||||
<th className="py-2 px-3">انبار</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{[...entries].reverse().map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
|
||||
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
|
||||
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
|
||||
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
|
||||
<td className="py-1.5 px-3 text-muted">{e.time}</td>
|
||||
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: ردیفهای فشرده */}
|
||||
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
|
||||
{[...entries].reverse().map((e, i) => (
|
||||
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
|
||||
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">
|
||||
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ============================================================
|
||||
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||
// ============================================================
|
||||
|
||||
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||
|
||||
interface ReturnFilterBarProps {
|
||||
preset: DatePreset;
|
||||
onPreset: (p: DatePreset) => void;
|
||||
customFrom: string | null;
|
||||
customTo: string | null;
|
||||
onCustomFrom: (iso: string | null) => void;
|
||||
onCustomTo: (iso: string | null) => void;
|
||||
}
|
||||
|
||||
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onPreset(p.id)}
|
||||
className={cn(
|
||||
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||
preset === p.id
|
||||
? "bg-accent font-bold text-white shadow-xs"
|
||||
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user