Compare commits
106
Commits
7ead60cd5c
...
dashboard
| 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 | ||
|
|
df3ff4ea14 | ||
|
|
4b84844b54 | ||
|
|
ea6a377023 | ||
|
|
5400588751 | ||
|
|
0ea0322967 | ||
|
|
a84b33dde3 | ||
|
|
fbd34277c0 | ||
|
|
8c5f4ab770 | ||
|
|
af092fe4c8 | ||
|
|
07643dbdf5 | ||
|
|
e9fef5a104 | ||
|
|
4a3c226518 | ||
|
|
0a7db5ebe9 | ||
|
|
da68fc683f | ||
|
|
17458d8697 | ||
|
|
1b5fc589fd | ||
|
|
a2e7c27e85 | ||
|
|
09245cdc2c | ||
|
|
dc31f837fd | ||
|
|
57e0e51c16 | ||
|
|
d85ede7d11 | ||
|
|
c7d860f431 | ||
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
da883a28c0 | ||
|
|
b6a1ce2ae7 | ||
|
|
64d80e15f8 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
9e19be8354 | ||
|
|
4cfb9ec6f6 | ||
|
|
6ee0822a96 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f | ||
|
|
d69ec7f7a9 | ||
|
|
34290b9371 | ||
|
|
cff35e3660 | ||
|
|
b469238153 | ||
|
|
2fd72fecf7 | ||
|
|
ae62a0391b | ||
|
|
7057ac4f96 | ||
|
|
36414863d4 | ||
|
|
baa32a2652 | ||
|
|
14a4a29431 | ||
|
|
d2b9af5278 | ||
|
|
e637d80b76 | ||
|
|
3c7ae80b02 | ||
|
|
de177f3520 | ||
|
|
b6cbcc30a5 | ||
|
|
08ec341c52 | ||
|
|
b5dc3e585a | ||
|
|
65758b5e78 | ||
|
|
b1edf6eed3 | ||
|
|
a181a8753d | ||
|
|
8adfd2267f | ||
|
|
ca55192311 | ||
|
|
948727d73b | ||
|
|
e1495afee9 | ||
|
|
e03d94207b | ||
|
|
8eddb74951 | ||
|
|
9364915be0 | ||
|
|
32323565e6 | ||
|
|
e5d6c77c3c | ||
|
|
e17d1f0883 | ||
|
|
f7b1f7f751 | ||
|
|
43e317eeba | ||
|
|
5a9ef408ff | ||
|
|
43ff048899 | ||
|
|
c395fba8dd | ||
|
|
66216c2b2f | ||
|
|
435e6cbb21 | ||
|
|
7b3ae3cb60 | ||
|
|
ae977e5e66 | ||
|
|
b65938ef06 | ||
|
|
5084623be5 | ||
|
|
9e8a2368ee | ||
|
|
17c7ae63c8 |
@@ -0,0 +1,71 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ERP stack configuration — copy to .env and fill in real values.
|
||||||
|
# .env is git-ignored; NEVER commit real credentials.
|
||||||
|
# Local dev: cp .env.example .env (defaults work as-is)
|
||||||
|
# Production VPS: /srv/erp/.env with STRONG values for every entry —
|
||||||
|
# docker-compose.prod.yml refuses to boot without them.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Public hosts (production; local defaults live in docker-compose.yml) ---
|
||||||
|
APP_HOST=aramesh.bennu-api-bahrestan.ir
|
||||||
|
API_HOST=api.bennu-api-bahrestan.ir
|
||||||
|
AUTH_HOST=casdoor.bennu-api-bahrestan.ir
|
||||||
|
S3_HOST=s3.bennu-api-bahrestan.ir
|
||||||
|
STORAGE_HOST=storage.bennu-api-bahrestan.ir
|
||||||
|
|
||||||
|
# --- Traefik / Let's Encrypt ---
|
||||||
|
TRAEFIK_ACME_EMAIL=you@example.com
|
||||||
|
|
||||||
|
# --- Environment: "prod" enables the fail-fast startup checks in the API ---
|
||||||
|
ENVIRONMENT=prod
|
||||||
|
|
||||||
|
# --- PostgreSQL (app + Casdoor share the instance) ---
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_strong_db_password
|
||||||
|
|
||||||
|
# --- MinIO object storage root credentials ---
|
||||||
|
MINIO_ROOT_USER=CHANGE_ME_minio_user
|
||||||
|
MINIO_ROOT_PASSWORD=CHANGE_ME_long_random_minio_password
|
||||||
|
|
||||||
|
# --- Casdoor OIDC application (must match the Casdoor application record) ---
|
||||||
|
CASDOOR_CLIENT_SECRET=CHANGE_ME_casdoor_client_secret
|
||||||
|
CASDOOR_ADMIN_PASSWORD=CHANGE_ME_casdoor_console_admin_password
|
||||||
|
|
||||||
|
# --- CORS: exact browser origins allowed to call the API (comma-separated) ---
|
||||||
|
CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
|
||||||
|
|
||||||
|
# --- Developer Mode (X-User-* header identity + login panel) ---
|
||||||
|
# MUST stay false in production. Only for local debugging without tokens.
|
||||||
|
DEV_LOGIN_ENABLED=false
|
||||||
|
|
||||||
|
# --- Deploy webhook (Gitea push → rebuild; see infra/deploy-webhook/) ---
|
||||||
|
GITEA_WEBHOOK_SECRET=CHANGE_ME_long_random_webhook_secret
|
||||||
|
|
||||||
|
# --- Backups (db-backup sidecar; see docker-compose.yml) ---
|
||||||
|
BACKUP_DIR=/srv/erp/backups
|
||||||
|
BACKUP_INTERVAL_SECONDS=86400
|
||||||
|
BACKUP_KEEP_DAYS=14
|
||||||
|
|
||||||
|
# --- Monitoring stack (see monitoring/README.md) ---
|
||||||
|
# API log format for Loki ingestion ("text" locally, "json" in production)
|
||||||
|
LOG_FORMAT=json
|
||||||
|
# GlitchTip (error tracking). GLITCHTIP_HOST must be a public DNS name —
|
||||||
|
# the browser SDK posts errors there.
|
||||||
|
GLITCHTIP_SECRET_KEY=CHANGE_ME_random_django_secret
|
||||||
|
GLITCHTIP_DOMAIN=https://errors.bennu-api-bahrestan.ir
|
||||||
|
GLITCHTIP_HOST=errors.bennu-api-bahrestan.ir
|
||||||
|
GLITCHTIP_DB_PASSWORD=CHANGE_ME_glitchtip_db_password
|
||||||
|
GLITCHTIP_FROM_EMAIL=noreply@bennu-api-bahrestan.ir
|
||||||
|
GLITCHTIP_EMAIL_URL=consolemail://
|
||||||
|
# Grafana (dashboards) — UI is loopback-only (:3001) unless you add Traefik labels
|
||||||
|
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_password
|
||||||
|
GRAFANA_ROOT_URL=http://localhost:3001/
|
||||||
|
# OpenPanel (product analytics) — client id/secret from the OpenPanel project
|
||||||
|
OPENPANEL_API_URL=http://openpanel-api:3333
|
||||||
|
OPENPANEL_CLIENT_ID=
|
||||||
|
OPENPANEL_CLIENT_SECRET=
|
||||||
|
# Public origin of the OpenPanel dashboard/API the BROWSER talks to
|
||||||
|
# (e.g. https://stats.bennu-api-bahrestan.ir — see monitoring/README.md)
|
||||||
|
OPENPANEL_PUBLIC_URL=
|
||||||
|
# Sentry/GlitchTip DSNs created in the GlitchTip UI after first boot
|
||||||
|
SENTRY_DSN_BACKEND=
|
||||||
|
SENTRY_DSN_FRONTEND=
|
||||||
+13
@@ -11,13 +11,26 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
|
|
||||||
# ---- Env / secrets ----
|
# ---- Env / secrets ----
|
||||||
|
# All credentials live in git-ignored .env files (server-side and local).
|
||||||
|
# NEVER commit .env or database dumps — see docs/SECRET-MANAGEMENT.md.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!*.env.example
|
||||||
|
backend/.env
|
||||||
frontend/.env
|
frontend/.env
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# ---- Database dumps (contain user rows + signing material) ----
|
||||||
|
*.dump
|
||||||
|
*.sql.gz
|
||||||
|
|
||||||
# ---- Local session / backups ----
|
# ---- Local session / backups ----
|
||||||
.zcode/
|
.zcode/
|
||||||
|
.zcodeignore
|
||||||
|
scratch/
|
||||||
v1.2 - Copy/
|
v1.2 - Copy/
|
||||||
inv dash/
|
inv dash/
|
||||||
|
voice/
|
||||||
|
|
||||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
|
||||||
|
|
||||||
Self-contained ship package: everything needed to run the stack is in this
|
Self-contained ship package: everything needed to run the stack is in this
|
||||||
@@ -12,19 +13,22 @@ folder — no external files or sibling folders required.
|
|||||||
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
|
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
|
||||||
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
|
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Browser ──► Traefik :80 (edge)
|
Browser ──► Traefik :80 (edge)
|
||||||
│
|
│
|
||||||
├── Host(localhost) ──► frontend (nginx SPA :80)
|
├── Host(localhost) ──► frontend (nginx SPA :80)
|
||||||
│ │
|
│ │
|
||||||
│ └── /api/* proxy ──► api :8000 (/api/v1)
|
│ └── /api/* proxy ──► api :8000 (/api/v1)
|
||||||
│
|
│
|
||||||
├── Host(api.localhost) ──► api :8000
|
├── Host(api.localhost) ──► api :8000
|
||||||
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
|
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
|
||||||
└── Host(s3/storage.localhost) ──► minio (API / console)
|
└── Host(s3/storage.localhost) ──► minio (API / console)
|
||||||
│
|
│
|
||||||
├── postgres :5432 (procurement + casdoor DBs)
|
├── postgres :5432 (procurement + casdoor DBs)
|
||||||
└── minio :9000 (invoice/proforma files)
|
└── minio :9000 (invoice/proforma files)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,6 +78,7 @@ v1.3/
|
|||||||
├── Projects/ (projects)
|
├── Projects/ (projects)
|
||||||
├── Units/ (unit conversions)
|
├── Units/ (unit conversions)
|
||||||
└── Inventory/ (warehouse management panel — React port)
|
└── Inventory/ (warehouse management panel — React port)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,12 +91,12 @@ the form (username / password) is sent to
|
|||||||
(password grant), verifies the returned JWT, and starts the session —
|
(password grant), verifies the returned JWT, and starts the session —
|
||||||
no browser redirect to Casdoor.
|
no browser redirect to Casdoor.
|
||||||
|
|
||||||
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
|
||||||
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
|
||||||
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
|
||||||
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
|
||||||
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
«کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses).
|
||||||
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
The «انبارها» and «تغییر موجودی» sections are PM-only.
|
||||||
|
|
||||||
**Developer Mode** (top-left button on the login page) is a 3-step flow for
|
**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) →
|
the admin (super user): ① superuser auth gate (admin credentials only) →
|
||||||
@@ -103,19 +108,19 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
|
|||||||
for production builds).
|
for production builds).
|
||||||
|
|
||||||
| What | Where | Credentials |
|
| What | Where | Credentials |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| App | http://localhost (via Traefik) | — |
|
| App | http://localhost (via Traefik) | — |
|
||||||
| Casdoor console | http://localhost:8080 | `admin` / `123` |
|
| 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 `123456` |
|
| 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`) |
|
||||||
|
|
||||||
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
|
||||||
Casdoor organization, application and users (idempotent). Full details —
|
Casdoor organization, application and users (idempotent). Full details —
|
||||||
addresses, credentials, role model, failure scenarios and the checklist —
|
addresses, credentials, role model, failure scenarios and the checklist —
|
||||||
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
|
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
|
||||||
|
|
||||||
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
> single admin — `ali.mohaghegh` («علی محقق») — see
|
||||||
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
|
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,13 +130,13 @@ The production deployment of this stack runs on the server and is reachable
|
|||||||
at these public addresses (HTTPS):
|
at these public addresses (HTTPS):
|
||||||
|
|
||||||
| What | Address |
|
| What | Address |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
|
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
|
||||||
| **Casdoor admin panel** | https://casdoor.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
|
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||||
assigned inside the app (`users.role` — see «Login» above and
|
assigned inside the app (`users.role` — see «Login» above and
|
||||||
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
|
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
|
||||||
|
|
||||||
### MinIO console on the server (SSH tunnel)
|
### MinIO console on the server (SSH tunnel)
|
||||||
|
|
||||||
@@ -140,51 +145,49 @@ The MinIO console is deliberately **loopback-only** on the server
|
|||||||
is an SSH tunnel from your machine:
|
is an SSH tunnel from your machine:
|
||||||
|
|
||||||
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||||
use the panel):
|
use the panel):
|
||||||
|
```powershell
|
||||||
|
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
||||||
|
|
||||||
```powershell
|
```
|
||||||
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
|
|
||||||
```
|
|
||||||
|
|
||||||
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**
|
2. In your browser, open: **http://localhost:9001**
|
||||||
|
3. Log in with the production MinIO root credentials from the server's
|
||||||
3. Log in with:
|
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
|
||||||
|
**never** written in this repo or its history. See
|
||||||
| Username | Password |
|
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
|
||||||
|---|---|
|
|
||||||
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
|
|
||||||
|
|
||||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||||
ERP stores, browsable and downloadable from there.
|
ERP stores, browsable and downloadable from there.
|
||||||
|
|
||||||
⚠️ These credentials are **full admin access to the file storage**. Treat
|
⚠️ Those credentials are **full admin access to the file storage**. They live
|
||||||
them as a production secret (this README is inside the repo, so anyone with
|
only in the server-side `.env`; if one ever leaks, rotate it immediately
|
||||||
repo access can read them — rotate them on the server if they ever leak).
|
(checklist in `docs/SECRET-MANAGEMENT.md`).
|
||||||
|
|
||||||
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
|
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
|
||||||
tunnel.
|
tunnel.
|
||||||
|
|
||||||
Notes from the server setup:
|
Notes from the server setup:
|
||||||
|
|
||||||
- The panel is published only on the server's loopback, so the internet
|
* The panel is published only on the server's loopback, so the internet
|
||||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||||
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
* Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||||
why the console uses `9001`.
|
why the console uses `9001`.
|
||||||
- If you ever need the raw S3 API from your machine, or a clickable URL for
|
* If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||||
with a password prompt on top), that can be arranged on request — the
|
with a password prompt on top), that can be arranged on request — the
|
||||||
tunnel remains the safer default.
|
tunnel remains the safer default.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites (target machine)
|
## Prerequisites (target machine)
|
||||||
|
|
||||||
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
|
||||||
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
|
||||||
- Internet access on first build (image + `npm ci` downloads)
|
* Internet access on first build (image + `npm ci` downloads)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,6 +196,7 @@ Notes from the server setup:
|
|||||||
```powershell
|
```powershell
|
||||||
cd <this-folder>
|
cd <this-folder>
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
First run downloads base images and builds the frontend (takes a few minutes).
|
First run downloads base images and builds the frontend (takes a few minutes).
|
||||||
@@ -202,6 +206,7 @@ Wait until all services are up:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
All should show `Up` (postgres also `(healthy)`).
|
All should show `Up` (postgres also `(healthy)`).
|
||||||
@@ -211,30 +216,39 @@ All should show `Up` (postgres also `(healthy)`).
|
|||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
| What | Where |
|
| What | Where |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| Frontend UI | http://localhost |
|
| Frontend UI | http://localhost |
|
||||||
| API docs (Swagger) | http://localhost:8000/docs |
|
| API docs (Swagger) | http://localhost:8000/docs |
|
||||||
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
|
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
|
||||||
|
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready |
|
||||||
|
| Metrics | http://localhost:8000/metrics |
|
||||||
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) |
|
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) |
|
||||||
| Database | `localhost:5433`, db `procurement`, user/pass `postgres`/`postgres` (Casdoor uses the same instance, db `casdoor`) |
|
| Database | `localhost:5433`, db `procurement`, user/pass `postgres`/`postgres` (Casdoor uses the same instance, db `casdoor`) |
|
||||||
|
|
||||||
Quick end-to-end check (Traefik → frontend proxy → API → DB):
|
Automated end-to-end checks (authenticates via `SMOKE_USER` /
|
||||||
|
`SMOKE_PASSWORD` — the seeder users):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl http://localhost/api/v1/quotes?expand=supplier,project,line_items,termsheets,events,decision,receipt
|
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`](https://www.google.com/search?q=docs/OPERATIONS.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MinIO (object storage for invoice / proforma files)
|
## MinIO (object storage for invoice / proforma files)
|
||||||
|
|
||||||
| Item | Value |
|
| Item | Value |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
|
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
|
||||||
| Web console | http://localhost:9001 |
|
| Web console | http://localhost:9001 |
|
||||||
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
|
||||||
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
|
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
|
||||||
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
|
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
|
||||||
|
|
||||||
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
|
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
|
||||||
|
|
||||||
@@ -242,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\mc.cmd ls --recursive
|
.\mc.cmd ls --recursive
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -254,55 +269,45 @@ Port: 5433
|
|||||||
Database: procurement
|
Database: procurement
|
||||||
Username: postgres
|
Username: postgres
|
||||||
Password: postgres
|
Password: postgres
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker exec -it procurement-postgres psql -U postgres -d procurement
|
docker exec -it procurement-postgres psql -U postgres -d procurement
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Restoring the committed seed data
|
## Recovery / seeding
|
||||||
|
|
||||||
The databases are **not** in git — Docker volumes hold the data. The repo
|
The databases are **not** in git — Docker volumes hold the data, and
|
||||||
ships recovery dumps in `backups/علی محقق/`:
|
database dumps must never be committed (they contain user rows and signing
|
||||||
|
material). Two supported ways to bring a fresh stack up:
|
||||||
| File | Contents |
|
|
||||||
|---|---|
|
|
||||||
| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` |
|
|
||||||
| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) |
|
|
||||||
|
|
||||||
⚠️ This **replaces all data** in the `procurement` and `casdoor` databases.
|
|
||||||
On a running stack:
|
|
||||||
|
|
||||||
|
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|
||||||
|
`postgres_data` volume; then run the seeders:
|
||||||
```powershell
|
```powershell
|
||||||
docker compose stop api casdoor
|
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
||||||
docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
|
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||||
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
|
# Demo dataset (optional):
|
||||||
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
|
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||||
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
|
|
||||||
docker compose start casdoor api
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
|
|
||||||
in usernames, so «ali mohaghegh» is not a valid login name).
|
|
||||||
|
|
||||||
Want demo data instead? Two seeders are still available (they create
|
2. **Restore your own dump** — create dumps on the server (kept server-side,
|
||||||
different users — see the Login table above):
|
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
|
||||||
|
|
||||||
1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
|
```
|
||||||
```powershell
|
|
||||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
|
||||||
```
|
|
||||||
2. **Casdoor seeder** (creates org `inventory` + app + users razaei /
|
|
||||||
mousavi / mohaghegh / tester — run once after a fresh volume):
|
|
||||||
```powershell
|
|
||||||
python backend/tools/seed_casdoor.py # from the host, or from the api container
|
|
||||||
```
|
|
||||||
|
|
||||||
To create a fresh dump of your own current state: stop `api` + `casdoor`,
|
|
||||||
`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc`
|
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
||||||
(+ the `casdoor` DB) into `backups/`, then start them again.
|
first — this **replaces all data**). The scheduled backup sidecar
|
||||||
|
(see `docs/OPERATIONS.md`) automates this.
|
||||||
|
|
||||||
## Common operations
|
## Common operations
|
||||||
|
|
||||||
@@ -311,39 +316,44 @@ docker compose logs -f api # see logs
|
|||||||
docker compose down # stop (keep data)
|
docker compose down # stop (keep data)
|
||||||
docker compose up -d --build # rebuild after code changes
|
docker compose up -d --build # rebuild after code changes
|
||||||
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known behavior
|
## Known behavior
|
||||||
|
|
||||||
- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`,
|
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
||||||
`/inventory` and `/pm` are protected (401 without a token, 403 without a role).
|
every API route** except `/health`, `/health/ready` and `/auth/login`
|
||||||
The procurement CRUD endpoints (quotes, suppliers, materials, …) are still
|
(401 without a token, 403 without a role). The former dev-header fallback
|
||||||
open — dev placeholder, do not expose to the public internet.
|
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
||||||
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
||||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||||
the procurement manager can only assign projects to project managers.
|
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||||
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
the procurement manager can only assign projects to project managers.
|
||||||
grant/revoke «مدیر تدارکات»).
|
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
|
||||||
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
grant/revoke «مدیر تدارکات»).
|
||||||
at least one item with a valid quantity is enough to submit; other items
|
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
|
||||||
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
at least one item with a valid quantity is enough to submit; other items
|
||||||
badges and a «رسید جزیی» badge on the quote.
|
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
|
||||||
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
badges and a «رسید جزیی» badge on the quote.
|
||||||
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
|
||||||
dumped/restored; see `docs/casdoor-auth.md`).
|
(`casdoor`) — the separate casdoor-postgres container is gone (data was
|
||||||
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
dumped/restored; see `docs/casdoor-auth.md`).
|
||||||
serves the SPA and proxies `/api` internally. Production (real domains,
|
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
|
||||||
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
serves the SPA and proxies `/api` internally. Production (real domains,
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
|
||||||
(see the prod file header for the required env vars).
|
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
||||||
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
(see the prod file header for the required env vars).
|
||||||
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
|
||||||
for the units-dashboard tables and `quote_attachments` at startup.
|
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
|
||||||
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
for the units-dashboard tables and `quote_attachments` at startup.
|
||||||
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
|
||||||
invoice/proforma download links from MinIO.
|
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
|
||||||
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
|
invoice/proforma download links from MinIO.
|
||||||
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
|
* **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامینکنندهای برای این متریال ثبت نشده است») if none exist.
|
||||||
reachable; check `docker compose ps` and `docker compose logs api`.
|
* 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`.
|
||||||
|
|
||||||
|
```
|
||||||
|
|||||||
+98
-16
@@ -26,8 +26,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import AsyncSessionLocal
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
ServiceUnavailableError,
|
ServiceUnavailableError,
|
||||||
@@ -106,6 +106,10 @@ class UserContext:
|
|||||||
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
||||||
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
||||||
display_name: str
|
display_name: str
|
||||||
|
# لایه پنلها (جایگزین نقشها): {'procurement': 'full'|'limited', 'inventory': ...}
|
||||||
|
panels: dict | None = None
|
||||||
|
# عضویت M:N در پروژهها — محدوده داده سطح limited (جدا از مسئولیت PM)
|
||||||
|
member_project_ids: list | None = None
|
||||||
|
|
||||||
|
|
||||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||||
@@ -119,10 +123,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
if not casdoor_id:
|
if not casdoor_id:
|
||||||
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
|
||||||
|
|
||||||
|
username = str(claims.get("name") or claims.get("preferred_username") or "")
|
||||||
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
|
||||||
if user is None:
|
if user is None:
|
||||||
user = User(
|
user = User(
|
||||||
casdoor_id=casdoor_id,
|
casdoor_id=casdoor_id,
|
||||||
|
username=username or None,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
claims.get("firstName")
|
claims.get("firstName")
|
||||||
or claims.get("firstname")
|
or claims.get("firstname")
|
||||||
@@ -135,17 +141,42 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
elif username and not user.username:
|
||||||
|
user.username = username
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
project_ids: list = []
|
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
||||||
if user.role == "pm":
|
panels: dict = {}
|
||||||
projects = (
|
panel_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for row in panel_rows:
|
||||||
|
panels[row.panel] = row.level
|
||||||
|
|
||||||
|
member_project_ids: list = [
|
||||||
|
row.project_id
|
||||||
|
for row in (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(UserProjectMembership)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(UserProjectMembership.user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(UserProjectMembership.created_at)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
project_ids = [p.id for p in projects]
|
]
|
||||||
|
|
||||||
|
pm_projects = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.pm_user_id == user.id)
|
||||||
|
.order_by(Project.created_at, Project.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
pm_project_ids_list = [p.id for p in pm_projects]
|
||||||
|
|
||||||
|
# ترکیب پروژههای تحت مدیریت مستقیم و پروژههای عضویت با حفظ اولویت و بدون تکرار
|
||||||
|
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
|
||||||
project_id = project_ids[0] if project_ids else None
|
project_id = project_ids[0] if project_ids else None
|
||||||
|
|
||||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||||
@@ -155,26 +186,40 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
project_ids=project_ids,
|
project_ids=project_ids,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
|
panels=panels,
|
||||||
|
member_project_ids=member_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||||
"""Every project id managed by the given PM user (oldest first)."""
|
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||||
rows = (
|
pm_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project.id)
|
select(Project.id)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(Project.pm_user_id == user.id)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return list(rows)
|
member_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership.project_id)
|
||||||
|
.where(UserProjectMembership.user_id == user.id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
|
||||||
|
|
||||||
|
|
||||||
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
|
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||||
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
|
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
|
||||||
|
|
||||||
Raises UnauthorizedError for a present-but-invalid token; callers decide
|
Raises UnauthorizedError for a present-but-invalid token; callers decide
|
||||||
their own fallback only for the no-token case (e.g. dev headers).
|
their own fallback only for the no-token case (e.g. dev headers).
|
||||||
|
|
||||||
|
IMPORTANT: when ``db`` is omitted (pure auth-dependency usage), a
|
||||||
|
short-lived session of its own is used — querying the REQUEST session
|
||||||
|
here would auto-begin its transaction and break endpoints that open
|
||||||
|
explicit ``async with db.begin()`` blocks afterwards.
|
||||||
"""
|
"""
|
||||||
auth = request.headers.get("authorization", "")
|
auth = request.headers.get("authorization", "")
|
||||||
if not auth.lower().startswith("bearer "):
|
if not auth.lower().startswith("bearer "):
|
||||||
@@ -182,13 +227,22 @@ async def get_optional_user(request: Request, db: AsyncSession) -> UserContext |
|
|||||||
token = auth.split(" ", 1)[1].strip()
|
token = auth.split(" ", 1)[1].strip()
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
if db is None:
|
||||||
|
async with AsyncSessionLocal() as own_session:
|
||||||
|
claims = await _verify_token(token)
|
||||||
|
return await context_from_claims(claims, own_session)
|
||||||
claims = await _verify_token(token)
|
claims = await _verify_token(token)
|
||||||
return await context_from_claims(claims, db)
|
return await context_from_claims(claims, db)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
|
async def get_current_user(request: Request) -> UserContext:
|
||||||
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned)."""
|
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned).
|
||||||
ctx = await get_optional_user(request, db)
|
|
||||||
|
Resolves the user in its own session (see get_optional_user) so the
|
||||||
|
global auth dependency never interferes with an endpoint's explicit
|
||||||
|
transaction handling.
|
||||||
|
"""
|
||||||
|
ctx = await get_optional_user(request)
|
||||||
if ctx is None:
|
if ctx is None:
|
||||||
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
||||||
if ctx.role is None:
|
if ctx.role is None:
|
||||||
@@ -203,3 +257,31 @@ def require_role(*roles: str):
|
|||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
return _dep
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def require_procurement_writer():
|
||||||
|
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
|
||||||
|
(دوره مهاجرت از مدل نقش به مدل پنل)."""
|
||||||
|
|
||||||
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
|
return ctx
|
||||||
|
if (ctx.panels or {}).get("procurement") == "full":
|
||||||
|
return ctx
|
||||||
|
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
def allow_quote_create():
|
||||||
|
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
|
||||||
|
(محدود به پروژههای عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام میشود)."""
|
||||||
|
|
||||||
|
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
|
return ctx
|
||||||
|
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
|
||||||
|
return ctx
|
||||||
|
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
|
||||||
|
|
||||||
|
return _dep
|
||||||
|
|||||||
+50
-8
@@ -1,7 +1,14 @@
|
|||||||
"""Application configuration.
|
"""Application configuration.
|
||||||
|
|
||||||
All settings can be overridden with environment variables prefixed with
|
All settings can be overridden with environment variables prefixed with
|
||||||
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` file.
|
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env``
|
||||||
|
file. The docker-compose files inject them from the git-ignored ``.env`` —
|
||||||
|
real credentials never live in this repository.
|
||||||
|
|
||||||
|
``ENVIRONMENT`` is the safety switch: when it is ``"prod"``, the API runs
|
||||||
|
fail-fast startup checks (see app.main._verify_production_settings) that
|
||||||
|
refuse to boot with wildcard CORS, default credentials or dev login
|
||||||
|
enabled.
|
||||||
"""
|
"""
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
@@ -14,7 +21,10 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
# PostgreSQL DSN — the schema (migration.sql + seed.sql) must already exist.
|
# "dev" (local defaults allowed) | "prod" (fail-fast checks on startup).
|
||||||
|
ENVIRONMENT: str = "dev"
|
||||||
|
|
||||||
|
# PostgreSQL DSN — the schema (migration.sql) must already exist.
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement"
|
||||||
|
|
||||||
# Echo SQL statements to the console (dev aid).
|
# Echo SQL statements to the console (dev aid).
|
||||||
@@ -22,12 +32,17 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# MinIO object storage — invoice / proforma attachments (S3-compatible).
|
# MinIO object storage — invoice / proforma attachments (S3-compatible).
|
||||||
# Endpoint is the compose service name (`minio:9000`) inside Docker.
|
# Endpoint is the compose service name (`minio:9000`) inside Docker.
|
||||||
|
# Defaults are LOCAL DEV ONLY — production values come from .env and the
|
||||||
|
# prod startup checks reject them.
|
||||||
MINIO_ENDPOINT: str = "localhost:9000"
|
MINIO_ENDPOINT: str = "localhost:9000"
|
||||||
MINIO_ACCESS_KEY: str = "minioadmin"
|
MINIO_ACCESS_KEY: str = "minioadmin"
|
||||||
MINIO_SECRET_KEY: str = "minioadmin"
|
MINIO_SECRET_KEY: str = "minioadmin"
|
||||||
MINIO_BUCKET: str = "procurement-files"
|
MINIO_BUCKET: str = "procurement-files"
|
||||||
MINIO_SECURE: bool = False
|
MINIO_SECURE: bool = False
|
||||||
|
|
||||||
|
# Security token for standalone wizard review URL (must match bot)
|
||||||
|
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
|
||||||
# Background auto-expiry of offered quotes whose expiry_date has passed.
|
# Background auto-expiry of offered quotes whose expiry_date has passed.
|
||||||
AUTO_EXPIRE_ENABLED: bool = True
|
AUTO_EXPIRE_ENABLED: bool = True
|
||||||
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
AUTO_EXPIRE_INTERVAL_SECONDS: int = 3600
|
||||||
@@ -36,27 +51,54 @@ class Settings(BaseSettings):
|
|||||||
DEFAULT_PAGE_SIZE: int = 20
|
DEFAULT_PAGE_SIZE: int = 20
|
||||||
MAX_PAGE_SIZE: int = 100
|
MAX_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
# CORS (dev-friendly default; the frontend is a static site).
|
# CORS — comma-separated exact browser origins (NOT "*" with
|
||||||
CORS_ORIGINS: list[str] = ["*"]
|
# credentials; the prod checks reject wildcard). Example:
|
||||||
|
# PROCUREMENT_CORS_ORIGINS=https://aramesh.bennu-api-bahrestan.ir
|
||||||
|
CORS_ORIGINS: str = "http://localhost,http://localhost:3000,http://localhost:5173"
|
||||||
|
|
||||||
|
# Developer Mode: in-page dev login panel (frontend build arg) AND the
|
||||||
|
# X-User-Role / X-User-Name-B64 header identity fallback in the
|
||||||
|
# inventory/pm routers. MUST stay false in production.
|
||||||
|
DEV_LOGIN_ENABLED: bool = False
|
||||||
|
|
||||||
# Casdoor (OIDC) — JWT verification against the issuer's JWKS.
|
# Casdoor (OIDC) — JWT verification against the issuer's JWKS.
|
||||||
CASDOOR_ENDPOINT: str = "http://localhost:8080"
|
CASDOOR_ENDPOINT: str = "http://localhost:8080"
|
||||||
CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT)
|
CASDOOR_ISSUER: str = "" # expected token iss claim (default: CASDOOR_ENDPOINT)
|
||||||
CASDOOR_CLIENT_ID: str = "inventory-app-client"
|
CASDOOR_CLIENT_ID: str = "inventory-app-client"
|
||||||
CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
|
CASDOOR_CLIENT_SECRET: str = "" # no default secret — set via env/.env
|
||||||
CASDOOR_ORGANIZATION: str = "inventory"
|
CASDOOR_ORGANIZATION: str = "inventory"
|
||||||
CASDOOR_APP_NAME: str = "inventory-app"
|
CASDOOR_APP_NAME: str = "inventory-app"
|
||||||
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
|
CASDOOR_JWKS_URL: str = "" # default: {endpoint}/api/public_jwks
|
||||||
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
|
CASDOOR_AUDIENCE: str = "inventory-app-client" # expected token aud (falls back to client_id)
|
||||||
|
|
||||||
# Casdoor console admin — used by POST /users/sync to mirror Casdoor
|
# Casdoor console admin — used by POST /users/sync to mirror Casdoor
|
||||||
# identities into the app's users table (dev defaults: admin / 123).
|
# identities into the app's users table. No default password.
|
||||||
CASDOOR_ADMIN_USER: str = "admin"
|
CASDOOR_ADMIN_USER: str = "admin"
|
||||||
CASDOOR_ADMIN_PASSWORD: str = "123"
|
CASDOOR_ADMIN_PASSWORD: str = ""
|
||||||
|
|
||||||
# Inventory dev fallback (only when no Authorization header is sent).
|
# Inventory dev fallback defaults (only used when DEV_LOGIN_ENABLED).
|
||||||
INVENTORY_DEFAULT_ROLE: str = "pm"
|
INVENTORY_DEFAULT_ROLE: str = "pm"
|
||||||
INVENTORY_DEFAULT_PROJECT_ID: str = ""
|
INVENTORY_DEFAULT_PROJECT_ID: str = ""
|
||||||
|
|
||||||
|
# ---- Observability (Phase 2) -------------------------------------------
|
||||||
|
# "text" (human console) | "json" (JSON lines for Loki/Promtail).
|
||||||
|
LOG_FORMAT: str = "text"
|
||||||
|
# Sentry/GlitchTip DSN — backend exception capture (empty = disabled).
|
||||||
|
SENTRY_DSN: str = ""
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE: float = 0.0
|
||||||
|
# OpenPanel server-side event ingestion (empty = disabled).
|
||||||
|
OPENPANEL_API_URL: str = "" # e.g. http://openpanel-api:8080 (self-hosted)
|
||||||
|
OPENPANEL_CLIENT_ID: str = ""
|
||||||
|
OPENPANEL_CLIENT_SECRET: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
"""Comma-separated CORS_ORIGINS → list for CORSMiddleware."""
|
||||||
|
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_prod(self) -> bool:
|
||||||
|
return self.ENVIRONMENT.lower() == "prod"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+260
-26
@@ -18,28 +18,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_DASHBOARD_DDL = [
|
_DASHBOARD_DDL = [
|
||||||
# One reference unit per category (units.html «واحد مرجع هر دسته»)
|
# One reference unit per category (units.html «واحد مرجع هر دسته»)
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS category_reference_units (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE,
|
|
||||||
ref_unit TEXT NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
|
# Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit.
|
||||||
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
|
# UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct).
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS unit_conversions (
|
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
||||||
category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE,
|
|
||||||
brand TEXT,
|
|
||||||
from_unit TEXT NOT NULL,
|
|
||||||
to_unit TEXT NOT NULL,
|
|
||||||
factor NUMERIC(18,6) NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
"""
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion
|
||||||
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
|
ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit)
|
||||||
""",
|
""",
|
||||||
@@ -193,7 +174,7 @@ _V26_DDL = [
|
|||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
|
CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
|
||||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
@@ -266,6 +247,7 @@ _V26_DDL = [
|
|||||||
physical_qty NUMERIC(18,6),
|
physical_qty NUMERIC(18,6),
|
||||||
transfer_pair_id UUID,
|
transfer_pair_id UUID,
|
||||||
performed_by TEXT,
|
performed_by TEXT,
|
||||||
|
delivered_to TEXT,
|
||||||
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
@@ -277,6 +259,9 @@ _V26_DDL = [
|
|||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
|
||||||
""",
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
|
||||||
|
""",
|
||||||
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
|
||||||
"""
|
"""
|
||||||
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
|
||||||
@@ -345,6 +330,18 @@ BASE_UNITS: list[dict] = [
|
|||||||
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
||||||
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
||||||
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
||||||
|
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
|
||||||
|
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
# تبدیلهای ثابت جهانی («تبدیلهای ثابت — همه متریالها») — ضرایب استاندارد و
|
||||||
|
# فراگیر که در هر استارتآپ بهصورت idempotent ذخیره میشوند. کلید یکتا:
|
||||||
|
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
|
||||||
|
# قالبی که فرانتاند در standard_conversions مینویسد.
|
||||||
|
STANDARD_CONVERSIONS: list[dict] = [
|
||||||
|
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
|
||||||
|
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
|
||||||
|
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -379,16 +376,253 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
|||||||
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
||||||
u,
|
u,
|
||||||
)
|
)
|
||||||
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
|
# Seed the global fixed conversions (idempotent — unique key from/to unit).
|
||||||
|
for c in STANDARD_CONVERSIONS:
|
||||||
|
await run_statement(
|
||||||
|
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
|
||||||
|
"VALUES (:from_unit, :to_unit, :factor) "
|
||||||
|
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
# 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 TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||||
await run_statement("""
|
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
|
||||||
AFTER INSERT ON supplier_materials
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
|
||||||
""")
|
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||||
await run_statement("""
|
await run_statement("""
|
||||||
CREATE TRIGGER trg_item_decisions_sync_price
|
CREATE TRIGGER trg_item_decisions_sync_price
|
||||||
AFTER INSERT OR UPDATE OF status ON item_decisions
|
AFTER INSERT OR UPDATE OF status ON item_decisions
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_drop_category_tables(engine: AsyncEngine) -> None:
|
||||||
|
"""v2.7 follow-up: the category taxonomy is reduced to a supplier label.
|
||||||
|
|
||||||
|
These three tables backed features that were never populated (0 rows on every
|
||||||
|
volume): per-category reference units and conversion factors (the
|
||||||
|
per-reference-unit price line) and the category -> attribute mapping. Dropping
|
||||||
|
them is idempotent and loses no data. material_categories itself stays — every
|
||||||
|
supplier carries one as its label.
|
||||||
|
"""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(
|
||||||
|
"DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE"
|
||||||
|
))
|
||||||
|
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 = """
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
actor TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity TEXT NOT NULL,
|
||||||
|
entity_id TEXT,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
indexes = (
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity, entity_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audit_log_occurred ON audit_log(occurred_at DESC)",
|
||||||
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
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)")
|
||||||
|
|||||||
+148
-4
@@ -7,30 +7,60 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
import httpx
|
||||||
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from sqlalchemy import text as sql_text
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import AsyncSessionLocal, engine
|
from app.db.engine import AsyncSessionLocal, engine
|
||||||
from app.db.init_db import (
|
from app.db.init_db import (
|
||||||
ensure_attachment_table,
|
ensure_attachment_table,
|
||||||
|
ensure_audit_table,
|
||||||
|
ensure_bot_drafts_table,
|
||||||
|
ensure_dashboard_schema,
|
||||||
|
ensure_drop_category_tables,
|
||||||
ensure_dashboard_tables,
|
ensure_dashboard_tables,
|
||||||
ensure_material_topic_column,
|
ensure_material_topic_column,
|
||||||
ensure_quote_projects,
|
ensure_quote_projects,
|
||||||
|
ensure_rbac_schema,
|
||||||
|
ensure_project_transfers_table,
|
||||||
ensure_v26,
|
ensure_v26,
|
||||||
)
|
)
|
||||||
|
from app.observability import RequestContextMiddleware, setup_logging
|
||||||
from app.routers import ROUTERS
|
from app.routers import ROUTERS
|
||||||
from app.services.quote_service import auto_expire_quotes
|
from app.services.quote_service import auto_expire_quotes
|
||||||
from app.utils.errors import AppError
|
from app.utils.errors import AppError
|
||||||
|
from app.utils.ratelimit import limiter
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
|
setup_logging(settings.LOG_FORMAT)
|
||||||
logger = logging.getLogger("procurement")
|
logger = logging.getLogger("procurement")
|
||||||
|
|
||||||
|
if settings.SENTRY_DSN:
|
||||||
|
# GlitchTip/Sentry exception capture — after setup_logging so the SDK's
|
||||||
|
# own logs go through our formatter too. Disabled entirely without a DSN.
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=settings.SENTRY_DSN,
|
||||||
|
environment=settings.ENVIRONMENT,
|
||||||
|
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
|
||||||
|
integrations=[FastApiIntegration()],
|
||||||
|
send_default_pii=False,
|
||||||
|
)
|
||||||
|
logger.info("Sentry/GlitchTip exception capture enabled")
|
||||||
|
|
||||||
API_V1_PREFIX = "/api/v1"
|
API_V1_PREFIX = "/api/v1"
|
||||||
|
|
||||||
|
|
||||||
@@ -45,13 +75,46 @@ async def _auto_expire_loop() -> None:
|
|||||||
logger.exception("Auto-expiry pass failed")
|
logger.exception("Auto-expiry pass failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_production_settings() -> None:
|
||||||
|
"""Fail-fast guard: refuse to boot in prod with dev defaults.
|
||||||
|
|
||||||
|
These exact misconfigurations are what previously shipped (wildcard CORS
|
||||||
|
with credentials, default MinIO/Postgres/Casdoor credentials, and the
|
||||||
|
always-on X-User-Role identity fallback). Ops fix: set the real values
|
||||||
|
in the server-side .env — see docs/SECRET-MANAGEMENT.md.
|
||||||
|
"""
|
||||||
|
problems: list[str] = []
|
||||||
|
if not settings.cors_origin_list or "*" in settings.cors_origin_list:
|
||||||
|
problems.append("PROCUREMENT_CORS_ORIGINS must list exact origins (no '*')")
|
||||||
|
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
|
||||||
|
problems.append("MinIO credentials are still the default minioadmin")
|
||||||
|
if not settings.CASDOOR_CLIENT_SECRET:
|
||||||
|
problems.append("PROCUREMENT_CASDOOR_CLIENT_SECRET is not set")
|
||||||
|
if not settings.CASDOOR_ADMIN_PASSWORD:
|
||||||
|
problems.append("PROCUREMENT_CASDOOR_ADMIN_PASSWORD is not set")
|
||||||
|
if settings.DEV_LOGIN_ENABLED:
|
||||||
|
problems.append("PROCUREMENT_DEV_LOGIN_ENABLED must be false in production")
|
||||||
|
if problems:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Refusing to start in production — fix the environment:\n - " + "\n - ".join(problems)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
|
if settings.is_prod:
|
||||||
|
_verify_production_settings()
|
||||||
await ensure_dashboard_tables(engine)
|
await ensure_dashboard_tables(engine)
|
||||||
await ensure_attachment_table(engine)
|
await ensure_attachment_table(engine)
|
||||||
await ensure_material_topic_column(engine)
|
await ensure_material_topic_column(engine)
|
||||||
await ensure_quote_projects(engine)
|
await ensure_quote_projects(engine)
|
||||||
await ensure_v26(engine)
|
await ensure_v26(engine)
|
||||||
|
await ensure_rbac_schema(engine)
|
||||||
|
await ensure_dashboard_schema(engine)
|
||||||
|
await ensure_audit_table(engine)
|
||||||
|
await ensure_drop_category_tables(engine)
|
||||||
|
await ensure_bot_drafts_table(engine)
|
||||||
|
await ensure_project_transfers_table(engine)
|
||||||
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -74,11 +137,21 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS,
|
allow_origins=settings.cors_origin_list,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(RequestContextMiddleware)
|
||||||
|
|
||||||
|
# Rate limiting — login brute-force brake (edge rate limit lives in Traefik).
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
# Prometheus /metrics (requests, latency, errors) — mounted at app level,
|
||||||
|
# outside the authenticated /api/v1 prefix; scrape target for the
|
||||||
|
# monitoring stack.
|
||||||
|
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -119,6 +192,7 @@ def _fa_validation_msg(msg: str) -> str:
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
||||||
|
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
||||||
first = exc.errors()[0]
|
first = exc.errors()[0]
|
||||||
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
||||||
field = ".".join(loc) if loc else None
|
field = ".".join(loc) if loc else None
|
||||||
@@ -157,11 +231,81 @@ async def unhandled_error_handler(_request: Request, exc: Exception):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
# JWT auth is required on EVERY router; the auth router stays public
|
||||||
|
# (POST /auth/login issues the tokens) and /health lives below, outside the
|
||||||
|
# /api/v1 prefix. Routers with their own finer-grained scopes (inventory,
|
||||||
|
# pm, users) keep them — the global dependency guarantees nothing is open
|
||||||
|
# by omission.
|
||||||
|
from app.routers.auth import router as _auth_router # noqa: E402
|
||||||
|
# The files router enforces its own access check per route: a browser cannot
|
||||||
|
# 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:
|
for router in ROUTERS:
|
||||||
app.include_router(router, prefix=API_V1_PREFIX)
|
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)])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", tags=["system"])
|
@app.get("/health", tags=["system"])
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "service": "procurement-api"}
|
return {"status": "ok", "service": "procurement-api"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_db() -> bool:
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
await session.execute(sql_text("SELECT 1"))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("readiness: database check failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_minio() -> bool:
|
||||||
|
try:
|
||||||
|
from app.services.storage import get_client
|
||||||
|
|
||||||
|
get_client().bucket_exists(settings.MINIO_BUCKET)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
logger.exception("readiness: MinIO check failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_casdoor() -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
settings.CASDOOR_JWKS_URL or f"{settings.CASDOOR_ENDPOINT}/.well-known/jwks"
|
||||||
|
)
|
||||||
|
return resp.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
logger.exception("readiness: Casdoor JWKS check failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/ready", tags=["system"])
|
||||||
|
async def health_ready():
|
||||||
|
"""Readiness: verifies the dependencies the API actually needs.
|
||||||
|
|
||||||
|
The deploy webhook polls this after a rebuild, so a deploy that breaks
|
||||||
|
the DB/MinIO/Casdoor connectivity is visible immediately.
|
||||||
|
"""
|
||||||
|
checks = {
|
||||||
|
"database": await _check_db(),
|
||||||
|
"minio": await _check_minio(),
|
||||||
|
"casdoor": await _check_casdoor(),
|
||||||
|
}
|
||||||
|
ok = all(checks.values())
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200 if ok else 503,
|
||||||
|
content={"status": "ok" if ok else "degraded", "service": "procurement-api", "checks": checks},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""ORM models — import order matters only for registration in Base.metadata."""
|
"""ORM models — import order matters only for registration in Base.metadata."""
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
from app.models.audit import AuditLog
|
||||||
from app.models.catalog import (
|
from app.models.catalog import (
|
||||||
AttributeDefinition,
|
AttributeDefinition,
|
||||||
Brand,
|
Brand,
|
||||||
@@ -31,6 +32,8 @@ from app.models.procurement import (
|
|||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.models.project_transfer import ProjectTransfer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"Unit",
|
"Unit",
|
||||||
@@ -59,4 +62,5 @@ __all__ = [
|
|||||||
"ItemDecision",
|
"ItemDecision",
|
||||||
"ItemReceipt",
|
"ItemReceipt",
|
||||||
"QuoteAttachment",
|
"QuoteAttachment",
|
||||||
|
"ProjectTransfer",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Audit log model — general activity trail (v2.7)."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, DateTime, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
occurred_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
actor = Column(Text, nullable=True) # display name of the authenticated user
|
||||||
|
action = Column(String(64), nullable=False) # e.g. "user.role_changed"
|
||||||
|
entity = Column(String(64), nullable=False) # e.g. "user", "inventory_tx"
|
||||||
|
entity_id = Column(Text, nullable=True)
|
||||||
|
details = Column(JSONB, nullable=False, server_default="{}")
|
||||||
@@ -130,16 +130,18 @@ class CategoryAttribute(Base):
|
|||||||
|
|
||||||
class Material(Base):
|
class Material(Base):
|
||||||
__tablename__ = "materials"
|
__tablename__ = "materials"
|
||||||
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
|
# 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"),)
|
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||||
__mapper_args__ = {"eager_defaults": True}
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
ForeignKey("material_categories.id", ondelete="RESTRICT")
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
|
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("material_categories.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
canonical_unit_id: Mapped[int] = mapped_column(
|
canonical_unit_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||||
)
|
)
|
||||||
@@ -149,8 +151,8 @@ class Material(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
|
||||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||||
|
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||||
back_populates="material", lazy="raise", passive_deletes=True
|
back_populates="material", lazy="raise", passive_deletes=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
|
||||||
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
||||||
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
|
||||||
|
delivered_to: Mapped[str | None] = mapped_column(Text)
|
||||||
transaction_date: Mapped[datetime] = mapped_column(
|
transaction_date: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.db.base import Base
|
|||||||
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
|
||||||
|
|
||||||
user_role_enum = Enum(
|
user_role_enum = Enum(
|
||||||
"pm", "procurement_manager", "admin",
|
"pm", "procurement_manager", "admin", "supervisor",
|
||||||
name="user_role", native_enum=True,
|
name="user_role", native_enum=True,
|
||||||
)
|
)
|
||||||
quote_status_enum = Enum(
|
quote_status_enum = Enum(
|
||||||
@@ -58,6 +58,7 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
username: Mapped[str | None] = mapped_column(Text)
|
||||||
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
first_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
last_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
phone: Mapped[str | None] = mapped_column(Text)
|
phone: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -65,6 +66,47 @@ class User(Base):
|
|||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanel(Base):
|
||||||
|
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
|
||||||
|
|
||||||
|
panel: 'procurement' | 'inventory' | 'pm'
|
||||||
|
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژهها
|
||||||
|
(بهجز تغییر موجودی)
|
||||||
|
'limited' — تدارکات: فقط افزودن استعلام برای پروژههای عضویت؛
|
||||||
|
انبار: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_panels"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
panel: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
level: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProjectMembership(Base):
|
||||||
|
"""user_project_members — عضویت M:N کاربر در پروژهها، جدا از مسئولیت
|
||||||
|
پروژه (projects.pm_user_id). محدودهی دادهی سطح limited را تعیین میکند."""
|
||||||
|
|
||||||
|
__tablename__ = "user_project_members"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
|
||||||
|
|
||||||
|
|
||||||
class Supplier(Base):
|
class Supplier(Base):
|
||||||
__tablename__ = "suppliers"
|
__tablename__ = "suppliers"
|
||||||
|
|
||||||
@@ -148,12 +190,23 @@ class Project(Base):
|
|||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
|
project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
|
||||||
start_date: Mapped[date | None] = mapped_column(Date)
|
start_date: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
planned_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
actual_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
|
||||||
|
progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
|
||||||
# v2.6: the single PM responsible for this project (confirms arrivals).
|
# v2.6: the single PM responsible for this project (confirms arrivals).
|
||||||
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="SET NULL")
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = _timestamptz()
|
created_at: Mapped[datetime] = _timestamptz()
|
||||||
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Quote(Base):
|
class Quote(Base):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Observability plumbing — logging format, request IDs, request log.
|
||||||
|
|
||||||
|
- ``setup_logging()``: human-readable text by default, JSON lines when
|
||||||
|
``PROCUREMENT_LOG_FORMAT=json`` (what Promtail/Loki prefers).
|
||||||
|
- ``request_context`` middleware: assigns an X-Request-ID to every request
|
||||||
|
(honours one sent by the edge), echoes it in the response, and logs one
|
||||||
|
structured line per request (method, path, status, duration, request id)
|
||||||
|
— this is what makes access logs useful in Loki.
|
||||||
|
"""
|
||||||
|
import contextvars
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
|
||||||
|
|
||||||
|
_SKIP_ACCESS_LOG = {"/health", "/health/ready", "/metrics"}
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
"""Minimal JSON-lines formatter for Loki/Promtail ingestion."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload = {
|
||||||
|
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"request_id": request_id_var.get(),
|
||||||
|
}
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
for key in ("http_method", "http_path", "http_status", "duration_ms"):
|
||||||
|
value = getattr(record, key, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[key] = value
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(log_format: str = "text", level: int = logging.INFO) -> None:
|
||||||
|
formatter = JsonFormatter() if log_format.lower() == "json" else logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)s %(name)s — %(message)s"
|
||||||
|
)
|
||||||
|
root = logging.getLogger()
|
||||||
|
for handler in root.handlers[:]:
|
||||||
|
root.removeHandler(handler)
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""X-Request-ID + one access-log line per request."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
rid = request.headers.get("x-request-id") or uuid.uuid4().hex
|
||||||
|
token = request_id_var.set(rid)
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||||
|
logging.getLogger("procurement.access").exception(
|
||||||
|
"unhandled exception",
|
||||||
|
extra={"http_method": request.method, "http_path": request.url.path,
|
||||||
|
"http_status": 500, "duration_ms": duration_ms},
|
||||||
|
)
|
||||||
|
request_id_var.reset(token)
|
||||||
|
raise
|
||||||
|
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||||
|
response.headers["X-Request-ID"] = rid
|
||||||
|
request_id_var.reset(token)
|
||||||
|
if request.url.path not in _SKIP_ACCESS_LOG:
|
||||||
|
logging.getLogger("procurement.access").info(
|
||||||
|
"%s %s -> %s",
|
||||||
|
request.method, request.url.path, response.status_code,
|
||||||
|
extra={"http_method": request.method, "http_path": request.url.path,
|
||||||
|
"http_status": response.status_code, "duration_ms": duration_ms},
|
||||||
|
)
|
||||||
|
return response
|
||||||
@@ -4,8 +4,8 @@ from app.routers import (
|
|||||||
auth,
|
auth,
|
||||||
brand_materials,
|
brand_materials,
|
||||||
brands,
|
brands,
|
||||||
category_attributes,
|
dashboard,
|
||||||
category_ref_units,
|
drafts,
|
||||||
files,
|
files,
|
||||||
inventory,
|
inventory,
|
||||||
item_decisions,
|
item_decisions,
|
||||||
@@ -16,6 +16,7 @@ from app.routers import (
|
|||||||
material_categories,
|
material_categories,
|
||||||
materials,
|
materials,
|
||||||
pm,
|
pm,
|
||||||
|
project_transfers,
|
||||||
projects,
|
projects,
|
||||||
quote_events,
|
quote_events,
|
||||||
quotes,
|
quotes,
|
||||||
@@ -24,7 +25,6 @@ from app.routers import (
|
|||||||
supplier_materials,
|
supplier_materials,
|
||||||
suppliers,
|
suppliers,
|
||||||
termsheets,
|
termsheets,
|
||||||
unit_conversions,
|
|
||||||
units,
|
units,
|
||||||
users,
|
users,
|
||||||
)
|
)
|
||||||
@@ -35,7 +35,6 @@ ROUTERS = [
|
|||||||
brands.router,
|
brands.router,
|
||||||
material_categories.router,
|
material_categories.router,
|
||||||
attribute_definitions.router,
|
attribute_definitions.router,
|
||||||
category_attributes.router,
|
|
||||||
materials.router,
|
materials.router,
|
||||||
brand_materials.router,
|
brand_materials.router,
|
||||||
suppliers.router,
|
suppliers.router,
|
||||||
@@ -51,10 +50,11 @@ ROUTERS = [
|
|||||||
item_receipts.router,
|
item_receipts.router,
|
||||||
quote_events.router,
|
quote_events.router,
|
||||||
files.router,
|
files.router,
|
||||||
category_ref_units.router,
|
|
||||||
unit_conversions.router,
|
|
||||||
standard_conversions.router,
|
standard_conversions.router,
|
||||||
inventory.router,
|
inventory.router,
|
||||||
pm.router,
|
pm.router,
|
||||||
|
project_transfers.router,
|
||||||
|
dashboard.router,
|
||||||
users.router,
|
users.router,
|
||||||
|
drafts.router,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||||
from app.models.procurement import LineItemSpec
|
from app.models.procurement import LineItemSpec
|
||||||
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
||||||
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_attribute_definition(
|
||||||
|
payload: AttributeDefinitionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||||
@@ -102,7 +108,10 @@ async def get_attribute_definition(
|
|||||||
|
|
||||||
@router.patch("/{definition_id}")
|
@router.patch("/{definition_id}")
|
||||||
async def update_attribute_definition(
|
async def update_attribute_definition(
|
||||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
definition_id,
|
||||||
|
payload: AttributeDefinitionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
@@ -136,7 +145,11 @@ async def update_attribute_definition(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{definition_id}")
|
@router.delete("/{definition_id}")
|
||||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
async def delete_attribute_definition(
|
||||||
|
definition_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ad = await db.get(AttributeDefinition, definition_id)
|
ad = await db.get(AttributeDefinition, definition_id)
|
||||||
if ad is None:
|
if ad is None:
|
||||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Error mapping (documented in docs/casdoor-auth.md):
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -21,7 +21,9 @@ from app.auth import _verify_token, context_from_claims
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.routers.users import _user_projects, user_to_dict
|
from app.routers.users import _user_projects, user_to_dict
|
||||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
from app.services import analytics
|
||||||
|
from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
|
||||||
|
from app.utils.ratelimit import limiter
|
||||||
|
|
||||||
logger = logging.getLogger("procurement.auth")
|
logger = logging.getLogger("procurement.auth")
|
||||||
|
|
||||||
@@ -34,7 +36,8 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
|
@limiter.limit("10/minute", error_message="تلاشهای ورود بیش از حد مجاز — کمی بعد دوباره امتحان کنید")
|
||||||
|
async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""Exchange username/password for a Casdoor JWT (OAuth password grant)."""
|
"""Exchange username/password for a Casdoor JWT (OAuth password grant)."""
|
||||||
url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
|
url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
|
||||||
form = {
|
form = {
|
||||||
@@ -75,11 +78,23 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
claims = await _verify_token(access_token)
|
claims = await _verify_token(access_token)
|
||||||
ctx = await context_from_claims(claims, db)
|
ctx = await context_from_claims(claims, db)
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
|
||||||
|
if ctx.role == "supervisor" and not has_projects:
|
||||||
|
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||||
|
await analytics.track(
|
||||||
|
"login_success",
|
||||||
|
{"username": payload.username, "role": ctx.role},
|
||||||
|
profile_id=ctx.user.casdoor_id,
|
||||||
|
)
|
||||||
|
await analytics.identify(
|
||||||
|
ctx.user.casdoor_id,
|
||||||
|
{"name": ctx.display_name, "role": ctx.role, "username": payload.username},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"expires_in": body.get("expires_in"),
|
"expires_in": body.get("expires_in"),
|
||||||
"user": user_to_dict(ctx.user, projects),
|
"user": await user_to_dict(ctx.user, projects, db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material
|
from app.models.catalog import Brand, BrandMaterial, Material
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||||
@@ -70,7 +72,11 @@ async def list_brand_materials(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand_material(
|
||||||
|
payload: BrandMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
if await db.get(Brand, payload.brand_id) is None:
|
if await db.get(Brand, payload.brand_id) is None:
|
||||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||||
if await db.get(Material, payload.material_id) is None:
|
if await db.get(Material, payload.material_id) is None:
|
||||||
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{bm_id}")
|
@router.patch("/{bm_id}")
|
||||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand_material(
|
||||||
|
bm_id,
|
||||||
|
payload: BrandMaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{bm_id}")
|
@router.delete("/{bm_id}")
|
||||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand_material(
|
||||||
|
bm_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
bm = await db.get(BrandMaterial, bm_id)
|
bm = await db.get(BrandMaterial, bm_id)
|
||||||
if bm is None:
|
if bm is None:
|
||||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Brand, BrandMaterial
|
from app.models.catalog import Brand, BrandMaterial
|
||||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def brand_to_dict(brand: Brand) -> dict:
|
def brand_to_dict(brand: Brand) -> dict:
|
||||||
@@ -42,7 +44,11 @@ async def list_brands(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
async def create_brand(
|
||||||
|
payload: BrandCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||||
if any(b.country == payload.country for b in rows):
|
if any(b.country == payload.country for b in rows):
|
||||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||||
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{brand_id}")
|
@router.patch("/{brand_id}")
|
||||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_brand(
|
||||||
|
brand_id,
|
||||||
|
payload: BrandUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{brand_id}")
|
@router.delete("/{brand_id}")
|
||||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
async def delete_brand(
|
||||||
|
brand_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
brand = await db.get(Brand, brand_id)
|
brand = await db.get(Brand, brand_id)
|
||||||
if brand is None:
|
if brand is None:
|
||||||
raise NotFoundError("برند یافت نشد")
|
raise NotFoundError("برند یافت نشد")
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
"""Category attributes — scoped attribute definitions per category (P2 set).
|
|
||||||
|
|
||||||
Identified by composite key {category_id}/{attribute_definition_id}.
|
|
||||||
The nested `attribute` object is ALWAYS included (not expand-based).
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.engine import get_db
|
|
||||||
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
|
|
||||||
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
|
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
|
||||||
from app.utils.pagination import paginate
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/material-categories/{category_id}/attributes",
|
|
||||||
tags=["category-attributes"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
|
|
||||||
return {
|
|
||||||
"id": str(ca.id),
|
|
||||||
"category_id": str(ca.category_id),
|
|
||||||
"attribute_definition_id": str(ca.attribute_definition_id),
|
|
||||||
"is_required": ca.is_required,
|
|
||||||
"is_filterable": ca.is_filterable,
|
|
||||||
"conditional_rules": ca.conditional_rules,
|
|
||||||
"sort_order": ca.sort_order,
|
|
||||||
"attribute": {
|
|
||||||
"attribute_key": attribute.attribute_key,
|
|
||||||
"label": attribute.label,
|
|
||||||
"data_type": attribute.data_type,
|
|
||||||
"unit_id": attribute.unit_id,
|
|
||||||
"enum_values": attribute.enum_values,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
|
|
||||||
category = await db.get(MaterialCategory, category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
|
||||||
return category
|
|
||||||
|
|
||||||
|
|
||||||
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
|
|
||||||
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
|
|
||||||
return ca_to_dict(ca, attribute)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_category_attributes(
|
|
||||||
category_id,
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
await _category_or_404(db, category_id)
|
|
||||||
stmt = (
|
|
||||||
select(CategoryAttribute)
|
|
||||||
.where(CategoryAttribute.category_id == category_id)
|
|
||||||
.order_by(CategoryAttribute.sort_order.asc())
|
|
||||||
)
|
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
|
||||||
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
|
||||||
async def create_category_attribute(
|
|
||||||
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
await _category_or_404(db, category_id)
|
|
||||||
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
|
|
||||||
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
|
|
||||||
duplicate = await db.scalar(
|
|
||||||
select(CategoryAttribute.id).where(
|
|
||||||
CategoryAttribute.category_id == category_id,
|
|
||||||
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if duplicate is not None:
|
|
||||||
raise ConflictError("این ویژگی قبلاً به این دستهبندی اضافه شده است", field="attribute_definition_id")
|
|
||||||
ca = CategoryAttribute(
|
|
||||||
category_id=category_id,
|
|
||||||
attribute_definition_id=payload.attribute_definition_id,
|
|
||||||
is_required=payload.is_required,
|
|
||||||
is_filterable=payload.is_filterable,
|
|
||||||
conditional_rules=payload.conditional_rules,
|
|
||||||
sort_order=payload.sort_order,
|
|
||||||
)
|
|
||||||
db.add(ca)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": await _with_attribute(db, ca)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{attribute_definition_id}")
|
|
||||||
async def update_category_attribute(
|
|
||||||
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
ca = await db.scalar(
|
|
||||||
select(CategoryAttribute).where(
|
|
||||||
CategoryAttribute.category_id == category_id,
|
|
||||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if ca is None:
|
|
||||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
|
||||||
if payload.is_required is not None:
|
|
||||||
ca.is_required = payload.is_required
|
|
||||||
if payload.is_filterable is not None:
|
|
||||||
ca.is_filterable = payload.is_filterable
|
|
||||||
if payload.conditional_rules is not None:
|
|
||||||
ca.conditional_rules = payload.conditional_rules
|
|
||||||
if payload.sort_order is not None:
|
|
||||||
ca.sort_order = payload.sort_order
|
|
||||||
await db.flush()
|
|
||||||
return {"data": await _with_attribute(db, ca)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{attribute_definition_id}")
|
|
||||||
async def delete_category_attribute(
|
|
||||||
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
ca = await db.scalar(
|
|
||||||
select(CategoryAttribute).where(
|
|
||||||
CategoryAttribute.category_id == category_id,
|
|
||||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if ca is None:
|
|
||||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
|
||||||
await db.delete(ca)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": {"deleted": True}}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Category reference units — units.html dashboard (P2-26/27).
|
|
||||||
|
|
||||||
One reference unit per category (UNIQUE category_id); PUT is an upsert.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.engine import get_db
|
|
||||||
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
|
|
||||||
from app.schemas.catalog import CategoryReferenceUnitPut
|
|
||||||
from app.services.expand_service import category_brief
|
|
||||||
from app.utils.errors import NotFoundError
|
|
||||||
from app.utils.pagination import paginate
|
|
||||||
from app.utils.serialization import to_iso
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
|
|
||||||
|
|
||||||
|
|
||||||
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
|
|
||||||
d = {
|
|
||||||
"id": str(cru.id),
|
|
||||||
"category_id": str(cru.category_id),
|
|
||||||
"ref_unit": cru.ref_unit,
|
|
||||||
"updated_at": to_iso(cru.updated_at),
|
|
||||||
}
|
|
||||||
if "category" in expand and hasattr(cru, "_category"):
|
|
||||||
d["category"] = category_brief(cru._category)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_category_reference_units(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
category_id=None,
|
|
||||||
expand: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
stmt = select(CategoryReferenceUnit)
|
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
|
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
|
||||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
|
||||||
if "category" in tokens:
|
|
||||||
cat_ids = {cru.category_id for cru in items}
|
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
for cru in items:
|
|
||||||
cru._category = cats.get(cru.category_id)
|
|
||||||
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{category_id}")
|
|
||||||
async def upsert_category_reference_unit(
|
|
||||||
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
|
|
||||||
):
|
|
||||||
category = await db.get(MaterialCategory, category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
|
||||||
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
|
|
||||||
if cru is None:
|
|
||||||
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
|
|
||||||
db.add(cru)
|
|
||||||
else:
|
|
||||||
cru.ref_unit = payload.ref_unit
|
|
||||||
await db.flush()
|
|
||||||
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
|
|
||||||
@@ -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)}
|
||||||
+103
-16
@@ -17,16 +17,23 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Request, Response, UploadFile
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import UserContext, get_current_user, get_optional_user, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote
|
from app.models.procurement import Quote
|
||||||
from app.services import file_service
|
from app.services import file_service
|
||||||
from app.utils.errors import NotFoundError, ValidationError
|
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
|
||||||
|
from app.utils.preview_token import make_preview_token, verify_preview_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
|
# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
|
||||||
|
# برای هر کاربر واردشده آزاد است.
|
||||||
|
_attachment_deleter = require_role("procurement_manager", "admin")
|
||||||
|
_attachment_writer = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||||
}
|
}
|
||||||
@@ -36,13 +43,17 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
|
|||||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
def _content_disposition(file_name: str) -> str:
|
def _content_disposition(file_name: str, inline: bool = False) -> str:
|
||||||
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
|
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
|
||||||
|
|
||||||
HTTP headers are encoded as latin-1, so a Persian name raises
|
HTTP headers are encoded as latin-1, so a Persian name raises
|
||||||
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
|
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
|
||||||
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
|
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
|
||||||
in ``filename`` for old clients.
|
in ``filename`` for old clients.
|
||||||
|
|
||||||
|
``inline=True`` makes the browser render the file (PDF in a viewer,
|
||||||
|
image in the tab) instead of downloading it — required by the preview
|
||||||
|
modal, whose <iframe> would otherwise trigger a download.
|
||||||
"""
|
"""
|
||||||
raw = file_name.encode("ascii", "ignore").decode("ascii")
|
raw = file_name.encode("ascii", "ignore").decode("ascii")
|
||||||
# keep the fallback header-safe: no quotes, no control characters
|
# keep the fallback header-safe: no quotes, no control characters
|
||||||
@@ -51,9 +62,37 @@ def _content_disposition(file_name: str) -> str:
|
|||||||
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
|
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
|
||||||
if not stem or not any(ch.isalnum() for ch in stem):
|
if not stem or not any(ch.isalnum() for ch in stem):
|
||||||
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
|
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
|
||||||
# name): use a generic attachment name that keeps the extension
|
# name): use a generic name that keeps the extension
|
||||||
fallback = f"attachment{ext}" if ext else "attachment"
|
fallback = f"{'preview' if inline else 'attachment'}{ext}" if ext else (
|
||||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
"preview" if inline else "attachment"
|
||||||
|
)
|
||||||
|
kind = "inline" if inline else "attachment"
|
||||||
|
return f"{kind}; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_file_access(
|
||||||
|
request: Request,
|
||||||
|
token: str | None,
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
kind: str,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Authorise a file read by session header OR a signed ``?t=`` token.
|
||||||
|
|
||||||
|
These two GET routes sit outside the global Bearer dependency because a
|
||||||
|
browser cannot attach an Authorization header to <iframe>/<img> loads.
|
||||||
|
A caller is accepted when it presents either a valid Casdoor session
|
||||||
|
(resolved by get_optional_user) or a signed token scoped to exactly
|
||||||
|
this file, which expires within minutes.
|
||||||
|
"""
|
||||||
|
if request is not None and request.headers.get("authorization"):
|
||||||
|
user = await get_optional_user(request, db)
|
||||||
|
if user is not None and user.role is not None:
|
||||||
|
return
|
||||||
|
if token and verify_preview_token(token, quote_id, kind, attachment_id):
|
||||||
|
return
|
||||||
|
raise UnauthorizedError("دسترسی به این فایل مجاز نیست — ابتدا وارد شوید")
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
||||||
@@ -87,7 +126,7 @@ async def _attachment_or_404(
|
|||||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/invoice", status_code=201)
|
@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -98,29 +137,67 @@ async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
|||||||
return {"data": stored.to_dict()}
|
return {"data": stored.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/invoice")
|
@router.get("/{quote_id}/invoice", dependencies=[Depends(get_current_user)])
|
||||||
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachments = await file_service.list_for(db, quote_id, "invoice")
|
attachments = await file_service.list_for(db, quote_id, "invoice")
|
||||||
return {"data": [a.to_dict() for a in attachments]}
|
return {"data": [a.to_dict() for a in attachments]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview URL — a real, short-lived URL the browser can load in an
|
||||||
|
# <iframe>/<img> (which cannot send an Authorization header). Using a real
|
||||||
|
# URL instead of a blob: avoids download managers that hijack blob URLs and
|
||||||
|
# leave 0-byte files behind.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/{quote_id}/preview-url/{kind}/{attachment_id}")
|
||||||
|
async def preview_url(
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
kind: str,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_ctx: UserContext = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if kind not in ("invoice", "proforma"):
|
||||||
|
raise ValidationError("نوع پیوست نامعتبر است", field="kind")
|
||||||
|
await _quote_or_404(db, quote_id)
|
||||||
|
await _attachment_or_404(db, quote_id, attachment_id, kind)
|
||||||
|
token = make_preview_token(quote_id, kind, attachment_id)
|
||||||
|
# مسیر نسبی برگردانده میشود؛ فرانت با BASE_URL خودش کاملش میکند
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"url": f"/quotes/{quote_id}/{kind}/{attachment_id}?disposition=inline&t={token}",
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/invoice/{attachment_id}")
|
@router.get("/{quote_id}/invoice/{attachment_id}")
|
||||||
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def download_invoice(
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
disposition: str = "attachment",
|
||||||
|
t: str | None = None,
|
||||||
|
request: Request = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||||
|
await _require_file_access(request, t, quote_id, "invoice", attachment_id, db)
|
||||||
content = await file_service.read_bytes(attachment)
|
content = await file_service.read_bytes(attachment)
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=attachment.mime_type,
|
media_type=attachment.mime_type,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
"Content-Disposition": _content_disposition(
|
||||||
|
attachment.file_name, inline=disposition == "inline"
|
||||||
|
),
|
||||||
"X-File-Id": str(attachment.id),
|
"X-File-Id": str(attachment.id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{quote_id}/invoice/{attachment_id}")
|
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||||
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||||
@@ -132,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
|
|||||||
# Proforma — no status restriction
|
# Proforma — no status restriction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{quote_id}/proforma", status_code=201)
|
@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
|
||||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
mime = _validate_upload(file)
|
mime = _validate_upload(file)
|
||||||
@@ -143,7 +220,7 @@ async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db:
|
|||||||
return {"data": stored.to_dict()}
|
return {"data": stored.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/proforma")
|
@router.get("/{quote_id}/proforma", dependencies=[Depends(get_current_user)])
|
||||||
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachments = await file_service.list_for(db, quote_id, "proforma")
|
attachments = await file_service.list_for(db, quote_id, "proforma")
|
||||||
@@ -151,21 +228,31 @@ async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{quote_id}/proforma/{attachment_id}")
|
@router.get("/{quote_id}/proforma/{attachment_id}")
|
||||||
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def download_proforma(
|
||||||
|
quote_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
disposition: str = "attachment",
|
||||||
|
t: str | None = None,
|
||||||
|
request: Request = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||||
|
await _require_file_access(request, t, quote_id, "proforma", attachment_id, db)
|
||||||
content = await file_service.read_bytes(attachment)
|
content = await file_service.read_bytes(attachment)
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
media_type=attachment.mime_type,
|
media_type=attachment.mime_type,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": _content_disposition(attachment.file_name),
|
"Content-Disposition": _content_disposition(
|
||||||
|
attachment.file_name, inline=disposition == "inline"
|
||||||
|
),
|
||||||
"X-File-Id": str(attachment.id),
|
"X-File-Id": str(attachment.id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{quote_id}/proforma/{attachment_id}")
|
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter), Depends(get_current_user)])
|
||||||
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
await _quote_or_404(db, quote_id)
|
await _quote_or_404(db, quote_id)
|
||||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||||
|
|||||||
+131
-116
@@ -12,6 +12,7 @@ import base64
|
|||||||
import uuid
|
import uuid
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
@@ -43,6 +44,7 @@ from app.utils.errors import (
|
|||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
|
from app.utils.audit import record as audit_record
|
||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ class Scope:
|
|||||||
role: str
|
role: str
|
||||||
project_id: uuid.UUID | None
|
project_id: uuid.UUID | None
|
||||||
actor: str | None
|
actor: str | None
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def inventory_scope(
|
async def inventory_scope(
|
||||||
@@ -74,30 +77,48 @@ async def inventory_scope(
|
|||||||
if request.headers.get("authorization"):
|
if request.headers.get("authorization"):
|
||||||
user: UserContext = await get_optional_user(request, db)
|
user: UserContext = await get_optional_user(request, db)
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if user.role is None:
|
panels = user.panels or {}
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
if user.role is None and not panels:
|
||||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
|
||||||
if user.role == "procurement_manager":
|
# ادمین: دستنخورده — همهچیز از جمله تغییر موجودی
|
||||||
|
if user.role == "admin":
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
if user.role not in ("pm", "admin"):
|
# لایه پنلها (جایگزین نقشها): پنل انبار اولویت دارد
|
||||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
if "inventory" in panels:
|
||||||
if user.role == "pm":
|
if panels["inventory"] == "full":
|
||||||
allowed = list(user.project_ids or [])
|
# کل سیستم و تمام پروژهها — بهجز تغییر موجودی
|
||||||
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
|
||||||
|
# limited: فقط پروژههای عضویت + امکان تغییر موجودی
|
||||||
|
allowed = list(user.member_project_ids or [])
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("پروژهای به شما تخصیص داده نشده است")
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="pm", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||||
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||||
|
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||||
|
if user.role in ("procurement_manager", "pm"):
|
||||||
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
|
||||||
|
if user.role == "supervisor":
|
||||||
|
allowed = list(user.project_ids or [])
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ValidationError(
|
raise ForbiddenError("پروژه تخصیص داده نشده است")
|
||||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
if project_id is not None:
|
||||||
)
|
if project_id not in allowed:
|
||||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||||
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||||
|
if user.role == "admin":
|
||||||
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
|
raise ValidationError("نقش نامعتبر است", field="role")
|
||||||
|
|
||||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||||
# when an explicit X-User-Role header is sent. No auth info at all → 401.
|
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
||||||
if not request.headers.get("x-user-role"):
|
# Casdoor). Disabled by default: unauthenticated header identity in
|
||||||
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
|
# production would hand admin access to anyone who can reach the API.
|
||||||
|
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 settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
|
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقطخواندنی)
|
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقطخواندنی)
|
||||||
@@ -125,9 +146,9 @@ async def inventory_scope(
|
|||||||
return Scope(role=role, project_id=project_id, actor=actor)
|
return Scope(role=role, project_id=project_id, actor=actor)
|
||||||
|
|
||||||
|
|
||||||
def _require_pm(scope: Scope) -> None:
|
def _require_inventory_modifier(scope: Scope) -> None:
|
||||||
if scope.role != ROLE_PM:
|
if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
|
||||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -194,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,
|
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||||
"performed_by": tx.performed_by,
|
"performed_by": tx.performed_by,
|
||||||
|
"delivered_to": tx.delivered_to,
|
||||||
"transaction_date": to_iso(tx.transaction_date),
|
"transaction_date": to_iso(tx.transaction_date),
|
||||||
"note": tx.note,
|
"note": tx.note,
|
||||||
}
|
}
|
||||||
@@ -212,8 +234,16 @@ async def list_warehouses(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(Warehouse)
|
stmt = select(Warehouse)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
stmt = stmt.order_by(Warehouse.name)
|
stmt = stmt.order_by(Warehouse.name)
|
||||||
@@ -245,9 +275,11 @@ async def create_warehouse(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if payload.project_id not in allowed:
|
||||||
|
raise ValidationError("شما فقط میتوانید برای پروژه تخصیصیافته به خود انبار بسازید", field="project_id")
|
||||||
if await db.get(Project, payload.project_id) is None:
|
if await db.get(Project, payload.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||||
@@ -264,19 +296,21 @@ async def update_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if scope.project_id != wh.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
wh.name = payload.name
|
wh.name = payload.name
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
wh.description = payload.description
|
wh.description = payload.description
|
||||||
|
# پروژهی انبار تغییرناپذیر است — انبار برای همیشه به پروژهی والدش تعلق
|
||||||
|
# دارد و فقط با حذف و ساخت دوباره میتواند جابهجا شود.
|
||||||
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
||||||
if await db.get(Project, payload.project_id) is None:
|
raise ValidationError(
|
||||||
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
|
"پروژهی انبار قابل تغییر نیست — انبار برای همیشه به پروژهی والدش تعلق دارد",
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
field="project_id",
|
||||||
raise ForbiddenError("PM فقط میتواند انبار را در پروژه خودش جابهجا کند", field="project_id")
|
)
|
||||||
wh.project_id = payload.project_id
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||||
|
|
||||||
@@ -288,8 +322,10 @@ async def delete_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||||
# inventory cascade — block deletion if any stock history exists.
|
# inventory cascade — block deletion if any stock history exists.
|
||||||
linked = await db.scalar(
|
linked = await db.scalar(
|
||||||
@@ -310,7 +346,6 @@ async def delete_warehouse(
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_inventory(
|
async def list_inventory(
|
||||||
project_id: uuid.UUID | None = None,
|
project_id: uuid.UUID | None = None,
|
||||||
category_id=None,
|
|
||||||
warehouse_id=None,
|
warehouse_id=None,
|
||||||
material_id=None,
|
material_id=None,
|
||||||
sort: str | None = None,
|
sort: str | None = None,
|
||||||
@@ -320,18 +355,26 @@ async def list_inventory(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(InventoryItem)
|
stmt = select(InventoryItem)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
Warehouse.project_id == scope.project_id
|
if project_id is not None:
|
||||||
)
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == project_id
|
||||||
|
)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id.in_(allowed)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == scope.project_id
|
||||||
|
)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
Warehouse.project_id == project_id
|
Warehouse.project_id == project_id
|
||||||
)
|
)
|
||||||
if category_id is not None:
|
|
||||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
|
||||||
Material.category_id == category_id
|
|
||||||
)
|
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||||
if material_id is not None:
|
if material_id is not None:
|
||||||
@@ -353,10 +396,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
unit_ids = {i.unit_id for i in items}
|
unit_ids = {i.unit_id for i in items}
|
||||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
categories = {}
|
|
||||||
if materials:
|
|
||||||
cat_ids = {m.category_id for m in materials.values()}
|
|
||||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
brands = {}
|
brands = {}
|
||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||||
@@ -366,7 +405,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
return [
|
return [
|
||||||
inventory_to_dict(
|
inventory_to_dict(
|
||||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
None, brands.get(i.brand_id), units.get(i.unit_id),
|
||||||
)
|
)
|
||||||
for i in items
|
for i in items
|
||||||
]
|
]
|
||||||
@@ -393,8 +432,16 @@ async def list_transactions(
|
|||||||
stmt = select(InventoryTransaction).join(
|
stmt = select(InventoryTransaction).join(
|
||||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
@@ -402,9 +449,18 @@ async def list_transactions(
|
|||||||
if transaction_type is not None:
|
if transaction_type is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||||
if date_from is not None:
|
if date_from is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||||
|
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||||
|
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||||
|
)
|
||||||
if date_to is not None:
|
if date_to is not None:
|
||||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
stmt = stmt.where(
|
||||||
|
InventoryTransaction.transaction_date
|
||||||
|
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
|
|
||||||
@@ -459,8 +515,10 @@ async def inventory_history(
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise NotFoundError("موجودی یافت نشد")
|
raise NotFoundError("موجودی یافت نشد")
|
||||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||||
txs = (await db.execute(
|
txs = (await db.execute(
|
||||||
select(InventoryTransaction)
|
select(InventoryTransaction)
|
||||||
.where(InventoryTransaction.inventory_id == inventory_id)
|
.where(InventoryTransaction.inventory_id == inventory_id)
|
||||||
@@ -479,19 +537,31 @@ async def create_transaction(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
_require_pm(scope)
|
_require_inventory_modifier(scope)
|
||||||
# No explicit db.begin(): the session may already be inside an implicit
|
# No explicit db.begin(): the session may already be inside an implicit
|
||||||
# transaction (inventory_scope read from it in the JWT path); get_db
|
# transaction (inventory_scope read from it in the JWT path); get_db
|
||||||
# commits on success / rolls back on failure.
|
# commits on success / rolls back on failure.
|
||||||
with nullcontext():
|
with nullcontext():
|
||||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||||
if wh.project_id != scope.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if payload.dest_warehouse_id is not None:
|
if wh.project_id not in allowed:
|
||||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if dest.project_id != scope.project_id:
|
if payload.dest_warehouse_id is not None:
|
||||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||||
|
if dest.project_id not in allowed:
|
||||||
|
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||||
|
await audit_record(
|
||||||
|
db,
|
||||||
|
actor=scope.actor,
|
||||||
|
action="inventory.transaction_created",
|
||||||
|
entity="inventory",
|
||||||
|
entity_id=str(txs[0].inventory_id) if txs else None,
|
||||||
|
details={"type": payload.transaction_type, "quantity": str(payload.quantity),
|
||||||
|
"warehouse_id": str(payload.warehouse_id),
|
||||||
|
"tx_ids": [str(tx.id) for tx in txs]},
|
||||||
|
)
|
||||||
return {"data": [transaction_to_dict(tx) for tx in txs]}
|
return {"data": [transaction_to_dict(tx) for tx in txs]}
|
||||||
|
|
||||||
|
|
||||||
@@ -518,58 +588,3 @@ async def transaction_types():
|
|||||||
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
||||||
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
||||||
)]}
|
)]}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Material selector — filtered by Category + Warehouse (transaction forms)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@router.get("/materials/options")
|
|
||||||
async def material_options(
|
|
||||||
category_id: uuid.UUID,
|
|
||||||
warehouse_id: uuid.UUID | None = None,
|
|
||||||
scope: Scope = Depends(inventory_scope),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Materials for the transaction form.
|
|
||||||
|
|
||||||
Always filtered by category. When `warehouse_id` is given, only materials
|
|
||||||
that already exist in that warehouse's inventory are returned (so the PM
|
|
||||||
works with the actual stock of that warehouse); otherwise the full
|
|
||||||
category list is returned (e.g. first purchase of a new material).
|
|
||||||
"""
|
|
||||||
category = await db.get(MaterialCategory, category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
|
|
||||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
|
||||||
if warehouse_id is not None:
|
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
|
||||||
stmt = stmt.where(
|
|
||||||
Material.id.in_(
|
|
||||||
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
stmt = stmt.order_by(Material.name)
|
|
||||||
materials = (await db.execute(stmt)).scalars().all()
|
|
||||||
|
|
||||||
# Brands per material (from brand_materials catalog + brands actually used in stock)
|
|
||||||
brand_rows = (await db.execute(
|
|
||||||
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
|
||||||
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
|
||||||
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
|
|
||||||
)).all()
|
|
||||||
brands_by_material: dict[str, list[dict]] = {}
|
|
||||||
for mat_id, b_id, b_name in brand_rows:
|
|
||||||
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
|
|
||||||
|
|
||||||
return {"data": [
|
|
||||||
{
|
|
||||||
"id": str(m.id),
|
|
||||||
"name": m.name,
|
|
||||||
"brands": brands_by_material.get(str(m.id), []),
|
|
||||||
}
|
|
||||||
for m in materials
|
|
||||||
]}
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ State machine (P1 cross-endpoint rules):
|
|||||||
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
|
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
|
||||||
pending -> rejected
|
pending -> rejected
|
||||||
chosen/rejected -> pending/anything-else = 409
|
chosen/rejected -> pending/anything-else = 409
|
||||||
|
|
||||||
|
Auth: JWT required globally (main.py). The state-changing endpoints are a
|
||||||
|
purchase decision — restricted to procurement_manager / admin via
|
||||||
|
require_role; reads stay open to every authenticated role.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
|
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
|
||||||
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
|
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
|
||||||
@@ -21,6 +26,9 @@ router = APIRouter(
|
|||||||
tags=["item-decisions"],
|
tags=["item-decisions"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# خرید/رد اقلام = تصمیم تدارکات؛ ادمین و مدیر تدارکات
|
||||||
|
_decision_writer = require_role("procurement_manager", "admin")
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
li = await db.scalar(
|
li = await db.scalar(
|
||||||
@@ -59,7 +67,7 @@ async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db
|
|||||||
return {"data": decision_full(decision)}
|
return {"data": decision_full(decision)}
|
||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("", dependencies=[Depends(_decision_writer)])
|
||||||
async def put_decision(
|
async def put_decision(
|
||||||
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
|
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -93,7 +101,7 @@ async def put_decision(
|
|||||||
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
|
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/status")
|
@router.patch("/status", dependencies=[Depends(_decision_writer)])
|
||||||
async def patch_decision_status(
|
async def patch_decision_status(
|
||||||
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
|
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -118,7 +126,7 @@ async def patch_decision_status(
|
|||||||
return {"data": decision_full(decision)}
|
return {"data": decision_full(decision)}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("", dependencies=[Depends(_decision_writer)])
|
||||||
async def patch_decision(
|
async def patch_decision(
|
||||||
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
|
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||||
@@ -14,6 +15,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||||
tags=["item-receipts"],
|
tags=["item-receipts"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
async def create_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await _decision_or_404(db, line_item_id)
|
decision = await _decision_or_404(db, line_item_id)
|
||||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||||
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("")
|
||||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_receipt(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ReceiptUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
await _line_item_or_404(db, quote_id, line_item_id)
|
await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||||
if decision is None:
|
if decision is None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -23,6 +24,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||||
tags=["item-termsheets"],
|
tags=["item-termsheets"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_item_termsheet(
|
async def create_item_termsheet(
|
||||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: ItemTermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
@@ -93,7 +99,12 @@ async def create_item_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{item_termsheet_id}")
|
@router.patch("/{item_termsheet_id}")
|
||||||
async def update_item_termsheet(
|
async def update_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
payload: ItemTermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
@@ -114,7 +125,11 @@ async def update_item_termsheet(
|
|||||||
|
|
||||||
@router.delete("/{item_termsheet_id}")
|
@router.delete("/{item_termsheet_id}")
|
||||||
async def delete_item_termsheet(
|
async def delete_item_termsheet(
|
||||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
item_termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Unit
|
from app.models.catalog import AttributeDefinition, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||||
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
|
|||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||||
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
|
|||||||
|
|
||||||
|
|
||||||
@router.put("")
|
@router.put("")
|
||||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
async def put_line_item_specs(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: SpecsPutIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, Unit
|
from app.models.catalog import Material, Unit
|
||||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||||
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -50,7 +52,12 @@ async def list_line_items(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
async def create_line_item(
|
||||||
|
quote_id,
|
||||||
|
payload: LineItemCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -89,7 +96,11 @@ async def get_line_item(
|
|||||||
|
|
||||||
@router.patch("/{line_item_id}")
|
@router.patch("/{line_item_id}")
|
||||||
async def update_line_item(
|
async def update_line_item(
|
||||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
payload: LineItemUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
@@ -108,6 +119,7 @@ async def update_line_item(
|
|||||||
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
||||||
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
||||||
|
|
||||||
|
old_material_id = li.material_id
|
||||||
if payload.material_id is not None:
|
if payload.material_id is not None:
|
||||||
li.material_id = payload.material_id
|
li.material_id = payload.material_id
|
||||||
# v2.5: keep the supplier<->material catalog edge in sync.
|
# v2.5: keep the supplier<->material catalog edge in sync.
|
||||||
@@ -121,11 +133,21 @@ async def update_line_item(
|
|||||||
if payload.notes is not None:
|
if payload.notes is not None:
|
||||||
li.notes = payload.notes
|
li.notes = payload.notes
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if payload.material_id is not None and payload.material_id != old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{line_item_id}")
|
@router.delete("/{line_item_id}")
|
||||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
async def delete_line_item(
|
||||||
|
quote_id,
|
||||||
|
line_item_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -133,6 +155,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
|
|||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
||||||
if decision is not None and decision.status == "chosen":
|
if decision is not None and decision.status == "chosen":
|
||||||
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
||||||
|
old_material_id = li.material_id
|
||||||
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": {"id": str(line_item_id), "deleted": True}}
|
return {"data": {"id": str(line_item_id), "deleted": True}}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
|
from app.models.procurement import Supplier
|
||||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def category_to_dict(category: MaterialCategory) -> dict:
|
def category_to_dict(category: MaterialCategory) -> dict:
|
||||||
@@ -45,7 +48,11 @@ async def list_categories(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
async def create_category(
|
||||||
|
payload: MaterialCategoryCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||||
@@ -69,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{category_id}")
|
@router.patch("/{category_id}")
|
||||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_category(
|
||||||
|
category_id,
|
||||||
|
payload: MaterialCategoryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
@@ -91,14 +103,22 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{category_id}")
|
@router.delete("/{category_id}")
|
||||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
async def delete_category(
|
||||||
|
category_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
# v2.7: materials no longer carry a category, so what blocks deletion is the
|
||||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
|
||||||
|
# material of that category is reached through those suppliers.
|
||||||
|
linked = await db.scalar(
|
||||||
|
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
|
||||||
|
)
|
||||||
if linked:
|
if linked:
|
||||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
raise ConflictError(f"این دستهبندی برای {linked} تأمینکننده ثبت شده و قابل حذف نیست")
|
||||||
await db.delete(category) # category_attributes cascade via DDL
|
await db.delete(category) # category_attributes cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": {"id": str(category_id), "deleted": True}}
|
return {"data": {"id": str(category_id), "deleted": True}}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import exists, select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import (
|
from app.models.catalog import (
|
||||||
AttributeDefinition,
|
AttributeDefinition,
|
||||||
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def _coerce_filter_value(data_type: str, raw: str):
|
def _coerce_filter_value(data_type: str, raw: str):
|
||||||
@@ -97,7 +99,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
|
|||||||
async def list_materials(
|
async def list_materials(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
category_id=None,
|
|
||||||
brand_id=None,
|
brand_id=None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -107,8 +108,6 @@ async def list_materials(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
stmt = select(Material)
|
stmt = select(Material)
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(Material.category_id == category_id)
|
|
||||||
if brand_id:
|
if brand_id:
|
||||||
# WHERE EXISTS brand_materials with that brand_id
|
# WHERE EXISTS brand_materials with that brand_id
|
||||||
sub = select(1).where(
|
sub = select(1).where(
|
||||||
@@ -128,7 +127,10 @@ async def list_materials(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_material(
|
async def create_material(
|
||||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
payload: MaterialCreate,
|
||||||
|
expand: str | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
material = await material_service.create_material(db, payload)
|
material = await material_service.create_material(db, payload)
|
||||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||||
@@ -143,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{material_id}")
|
@router.patch("/{material_id}")
|
||||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_material(
|
||||||
|
material_id,
|
||||||
|
payload: MaterialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
@@ -152,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}")
|
@router.delete("/{material_id}")
|
||||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material(
|
||||||
|
material_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
@@ -251,7 +262,10 @@ async def list_material_units(
|
|||||||
|
|
||||||
@router.post("/{material_id}/units", status_code=201)
|
@router.post("/{material_id}/units", status_code=201)
|
||||||
async def create_material_unit(
|
async def create_material_unit(
|
||||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialUnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
await _material_or_404(db, material_id)
|
await _material_or_404(db, material_id)
|
||||||
@@ -290,7 +304,11 @@ async def create_material_unit(
|
|||||||
|
|
||||||
@router.patch("/{material_id}/units/{mu_id}")
|
@router.patch("/{material_id}/units/{mu_id}")
|
||||||
async def update_material_unit(
|
async def update_material_unit(
|
||||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
payload: MaterialUnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
@@ -322,7 +340,12 @@ async def update_material_unit(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{material_id}/units/{mu_id}")
|
@router.delete("/{material_id}/units/{mu_id}")
|
||||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
async def delete_material_unit(
|
||||||
|
material_id,
|
||||||
|
mu_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
mu = await db.scalar(
|
mu = await db.scalar(
|
||||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||||
)
|
)
|
||||||
@@ -368,7 +391,10 @@ async def get_material_attribute_values(
|
|||||||
|
|
||||||
@router.put("/{material_id}/attribute-values")
|
@router.put("/{material_id}/attribute-values")
|
||||||
async def put_material_attribute_values(
|
async def put_material_attribute_values(
|
||||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
material_id,
|
||||||
|
payload: MaterialAttributeValuesPut,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
|
|||||||
+92
-36
@@ -19,8 +19,9 @@ Rules enforced here:
|
|||||||
the canonical unit for auto-posted purchases.
|
the canonical unit for auto-posted purchases.
|
||||||
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
||||||
|
|
||||||
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
|
Auth: Casdoor JWT (a global dependency in main.py) resolves the caller;
|
||||||
X-Project-Id for pm) when no Authorization header — same pattern as the
|
optional dev-header fallback (X-User-Role: pm|admin + X-Project-Id for pm)
|
||||||
|
exists only when PROCUREMENT_DEV_LOGIN_ENABLED=true — same pattern as the
|
||||||
inventory router.
|
inventory router.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
@@ -35,19 +36,22 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user
|
||||||
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
ItemReceipt,
|
ItemReceipt,
|
||||||
ItemReceiptAllocation,
|
ItemReceiptAllocation,
|
||||||
LineItem,
|
LineItem,
|
||||||
|
LineItemSpec,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
Supplier,
|
Supplier,
|
||||||
)
|
)
|
||||||
from app.schemas.procurement import PMReceiptCreate
|
from app.schemas.procurement import PMReceiptCreate
|
||||||
|
from app.services import analytics
|
||||||
from app.services.inventory_service import canonical_specs_hash
|
from app.services.inventory_service import canonical_specs_hash
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -68,16 +72,17 @@ class ArrivalScope:
|
|||||||
user_id: uuid.UUID | None
|
user_id: uuid.UUID | None
|
||||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||||
display_name: str
|
display_name: str
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def arrivals_scope(
|
async def arrivals_scope(
|
||||||
request: Request,
|
request: Request,
|
||||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای دادههای پروژه)"),
|
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: دادههای پروژه)"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ArrivalScope:
|
) -> ArrivalScope:
|
||||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||||
|
|
||||||
PM → scoped to the requested project, which must be one they manage
|
PM / supervisor → scoped to the requested project, which must be one they manage
|
||||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||||
Admin / procurement manager → all projects (param = optional filter).
|
Admin / procurement manager → all projects (param = optional filter).
|
||||||
"""
|
"""
|
||||||
@@ -86,26 +91,31 @@ async def arrivals_scope(
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
if ctx.role is None:
|
if ctx.role is None:
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
if ctx.role == "pm":
|
if ctx.role in ("pm", "supervisor"):
|
||||||
allowed = list(ctx.project_ids or [])
|
allowed = list(ctx.project_ids or [])
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
|
||||||
if ctx.role in ("admin", "procurement_manager"):
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
|
||||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
|
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
|
||||||
if not request.headers.get("x-user-role"):
|
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||||
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
|
# Disabled by default: an ungated header identity in production would
|
||||||
|
# let anyone confirm receipts as PM.
|
||||||
|
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()
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = "admin"
|
role = "admin"
|
||||||
|
elif role == "supervisor":
|
||||||
|
role = "pm"
|
||||||
if role not in ("pm", "admin"):
|
if role not in ("pm", "admin"):
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||||
# The explicit query param wins; otherwise the dev header carries it.
|
# The explicit query param wins; otherwise the dev header carries it.
|
||||||
if project_id is None and role == "pm":
|
if project_id is None and role == "pm":
|
||||||
raw_project = request.headers.get("x-project-id")
|
raw_project = request.headers.get("x-project-id")
|
||||||
@@ -173,20 +183,35 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
|||||||
async def pm_on_the_way(
|
async def pm_on_the_way(
|
||||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
|
||||||
):
|
):
|
||||||
stmt = (
|
stmt = (
|
||||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
|
||||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(Material, Material.id == LineItem.material_id)
|
.join(Material, Material.id == LineItem.material_id)
|
||||||
.join(Supplier, Supplier.id == Quote.supplier_id)
|
.join(Supplier, Supplier.id == Quote.supplier_id)
|
||||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
|
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
|
||||||
|
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
|
||||||
.where(ItemDecision.status == "chosen")
|
.where(ItemDecision.status == "chosen")
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if material_id is not None:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
stmt = stmt.where(Material.id == material_id)
|
||||||
if ctx.project_id is not None:
|
if brand_id is not None:
|
||||||
# PM: active project; admin/manager: optional project filter
|
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
|
||||||
|
if ctx.role == "pm":
|
||||||
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
if ctx.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||||
|
elif ctx.project_id is not None:
|
||||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||||
|
|
||||||
@@ -195,20 +220,29 @@ async def pm_on_the_way(
|
|||||||
# unit / category lookups for display
|
# unit / category lookups for display
|
||||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||||
mat_ids = {d.Material.id for d in decisions}
|
# frozen spec snapshot per line item (key = attribute label)
|
||||||
cats = {}
|
li_ids = [d.LineItem.id for d in decisions]
|
||||||
if mat_ids:
|
specs_by_line: dict = {}
|
||||||
rows = (
|
if li_ids:
|
||||||
|
spec_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Material.id, MaterialCategory)
|
select(LineItemSpec, AttributeDefinition)
|
||||||
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
|
||||||
.where(Material.id.in_(mat_ids))
|
.where(LineItemSpec.line_item_id.in_(li_ids))
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
cats = {m_id: c for m_id, c in rows}
|
for spec, ad in spec_rows:
|
||||||
|
value = spec.value_string
|
||||||
|
if value is None and spec.value_number is not None:
|
||||||
|
value = str(spec.value_number)
|
||||||
|
if value is None and spec.value_boolean is not None:
|
||||||
|
value = str(spec.value_boolean)
|
||||||
|
if value is None and spec.value_date is not None:
|
||||||
|
value = spec.value_date.isoformat()
|
||||||
|
if value is not None:
|
||||||
|
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m, sup, proj in decisions:
|
for d, li, q, m, sup, proj, bm, br in decisions:
|
||||||
received = received_map.get(d.id)
|
received = received_map.get(d.id)
|
||||||
out.append({
|
out.append({
|
||||||
"item_decision_id": str(d.id),
|
"item_decision_id": str(d.id),
|
||||||
@@ -219,8 +253,9 @@ async def pm_on_the_way(
|
|||||||
"supplier": {"id": str(sup.id), "name": sup.name},
|
"supplier": {"id": str(sup.id), "name": sup.name},
|
||||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name,
|
"material": {"id": str(m.id), "name": m.name},
|
||||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
"brand": {"id": str(br.id), "name": br.name} if br else None,
|
||||||
|
"specs": specs_by_line.get(li.id, {}),
|
||||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||||
"ordered_qty": str(li.quantity),
|
"ordered_qty": str(li.quantity),
|
||||||
@@ -250,8 +285,12 @@ async def pm_confirm_receipt(
|
|||||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||||
if decision is None or decision.status != "chosen":
|
if decision is None or decision.status != "chosen":
|
||||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if decision.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||||
|
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||||
|
|
||||||
li = await db.get(LineItem, decision.line_item_id)
|
li = await db.get(LineItem, decision.line_item_id)
|
||||||
quote = await db.get(Quote, li.quote_id)
|
quote = await db.get(Quote, li.quote_id)
|
||||||
@@ -424,6 +463,16 @@ async def pm_confirm_receipt(
|
|||||||
quote.arrived_date = payload.received_at
|
quote.arrived_date = payload.received_at
|
||||||
|
|
||||||
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
|
item_state = arrival_state(li.quantity, already_received + payload.received_qty)
|
||||||
|
await analytics.track(
|
||||||
|
"receipt_confirmed",
|
||||||
|
{
|
||||||
|
"receipt_id": str(receipt.id),
|
||||||
|
"quote_id": str(quote.id),
|
||||||
|
"quantity": str(payload.received_qty),
|
||||||
|
"state": item_state,
|
||||||
|
},
|
||||||
|
profile_id=ctx.display_name,
|
||||||
|
)
|
||||||
return {"data": {
|
return {"data": {
|
||||||
"receipt_id": str(receipt.id),
|
"receipt_id": str(receipt.id),
|
||||||
"state": item_state,
|
"state": item_state,
|
||||||
@@ -448,10 +497,17 @@ async def pm_receipts(
|
|||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(Project, Project.id == ItemDecision.project_id)
|
.join(Project, Project.id == ItemDecision.project_id)
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
if ctx.project_id is not None:
|
if not allowed:
|
||||||
# PM: active project; admin/manager: optional project filter
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
if ctx.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||||
|
elif ctx.project_id is not None:
|
||||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||||
receipts = [r.ItemReceipt for r in rows]
|
receipts = [r.ItemReceipt for r in rows]
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.engine import get_db
|
||||||
|
from app.routers.pm import ArrivalScope, arrivals_scope
|
||||||
|
from app.schemas.project_transfers import (
|
||||||
|
ProjectTransferCreate,
|
||||||
|
ProjectTransferOut,
|
||||||
|
ProjectTransferReceive,
|
||||||
|
ProjectTransferUpdate,
|
||||||
|
)
|
||||||
|
from app.services import project_transfer_service
|
||||||
|
from app.utils.errors import ForbiddenError
|
||||||
|
|
||||||
|
router = APIRouter(tags=["project-transfers"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_transfer_out(t) -> dict:
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"from_project": (
|
||||||
|
{"id": t.from_project.id, "name": t.from_project.name}
|
||||||
|
if t.from_project
|
||||||
|
else {"id": t.from_project_id, "name": ""}
|
||||||
|
),
|
||||||
|
"from_warehouse": (
|
||||||
|
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
|
||||||
|
if t.from_warehouse
|
||||||
|
else {"id": t.from_warehouse_id, "name": ""}
|
||||||
|
),
|
||||||
|
"to_project": (
|
||||||
|
{"id": t.to_project.id, "name": t.to_project.name}
|
||||||
|
if t.to_project
|
||||||
|
else {"id": t.to_project_id, "name": ""}
|
||||||
|
),
|
||||||
|
"to_warehouse": (
|
||||||
|
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
|
||||||
|
if t.to_warehouse
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"material": (
|
||||||
|
{"id": t.material.id, "name": t.material.name}
|
||||||
|
if t.material
|
||||||
|
else {"id": t.material_id, "name": ""}
|
||||||
|
),
|
||||||
|
"brand": (
|
||||||
|
{"id": t.brand.id, "name": t.brand.name}
|
||||||
|
if t.brand
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"unit": (
|
||||||
|
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
|
||||||
|
if t.unit
|
||||||
|
else {"id": t.unit_id, "code": "", "name_fa": None}
|
||||||
|
),
|
||||||
|
"quantity": t.quantity,
|
||||||
|
"specs": t.specs or {},
|
||||||
|
"specs_hash": t.specs_hash,
|
||||||
|
"note": t.note,
|
||||||
|
"status": t.status,
|
||||||
|
"transfer_date": t.transfer_date,
|
||||||
|
"transferred_by": t.transferred_by,
|
||||||
|
"received_at": t.received_at,
|
||||||
|
"received_by": t.received_by,
|
||||||
|
"receipt_allocations": t.receipt_allocations,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"updated_at": t.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /inventory/project-transfers — Create inter-project transfer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/inventory/project-transfers", status_code=201)
|
||||||
|
async def create_transfer(
|
||||||
|
payload: ProjectTransferCreate,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.create_project_transfer(
|
||||||
|
db=db,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
transferred_by=ctx.display_name,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /inventory/project-transfers — List transfers with direction and filters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/inventory/project-transfers")
|
||||||
|
async def list_transfers(
|
||||||
|
project_id: uuid.UUID | None = Query(default=None),
|
||||||
|
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
target_pid = project_id or ctx.project_id
|
||||||
|
transfers = await project_transfer_service.list_project_transfers(
|
||||||
|
db=db,
|
||||||
|
project_id=target_pid,
|
||||||
|
direction=direction,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /inventory/project-transfers/{id} — Single transfer details
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def get_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
|
||||||
|
if ctx.role == "pm" and ctx.project_id is not None:
|
||||||
|
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||||
|
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.patch("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def update_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferUpdate,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.update_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.delete("/inventory/project-transfers/{transfer_id}")
|
||||||
|
async def delete_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
res = await project_transfer_service.delete_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
)
|
||||||
|
return {"data": res}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/pm/transfers/on-the-way")
|
||||||
|
async def pm_incoming_transfers(
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if ctx.role == "pm" and ctx.project_id is None:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید")
|
||||||
|
|
||||||
|
transfers = await project_transfer_service.list_project_transfers(
|
||||||
|
db=db,
|
||||||
|
project_id=ctx.project_id,
|
||||||
|
direction="incoming",
|
||||||
|
status="in_transit",
|
||||||
|
)
|
||||||
|
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/inventory/project-transfers/{transfer_id}/receive")
|
||||||
|
async def receive_transfer(
|
||||||
|
transfer_id: uuid.UUID,
|
||||||
|
payload: ProjectTransferReceive,
|
||||||
|
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
transfer = await project_transfer_service.receive_project_transfer(
|
||||||
|
db=db,
|
||||||
|
transfer_id=transfer_id,
|
||||||
|
payload=payload,
|
||||||
|
caller_project_id=ctx.project_id,
|
||||||
|
caller_role=ctx.role,
|
||||||
|
received_by=ctx.display_name,
|
||||||
|
)
|
||||||
|
return {"data": _to_transfer_out(transfer)}
|
||||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user
|
from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, Quote, User
|
from app.models.procurement import Project, Quote, User
|
||||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||||
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_date, to_iso
|
from app.utils.serialization import to_date, to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||||
@@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
d = {
|
d = {
|
||||||
"id": str(project.id),
|
"id": str(project.id),
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"location": project.location or "",
|
||||||
|
"project_manager": project.project_manager or "",
|
||||||
|
"description": project.description or "",
|
||||||
"start_date": to_date(project.start_date),
|
"start_date": to_date(project.start_date),
|
||||||
|
"status": project.status or "ACTIVE",
|
||||||
|
"progress": float(project.progress) if project.progress is not None else 0.0,
|
||||||
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
||||||
"created_at": to_iso(project.created_at),
|
"created_at": to_iso(project.created_at),
|
||||||
}
|
}
|
||||||
@@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
|||||||
pm = await db.get(User, project.pm_user_id)
|
pm = await db.get(User, project.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif project.project_manager:
|
||||||
|
d["pm_name"] = project.project_manager
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +62,14 @@ async def list_projects(
|
|||||||
):
|
):
|
||||||
stmt = select(Project)
|
stmt = select(Project)
|
||||||
if q:
|
if q:
|
||||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Project.name.ilike(f"%{q}%"),
|
||||||
|
Project.description.ilike(f"%{q}%"),
|
||||||
|
Project.location.ilike(f"%{q}%"),
|
||||||
|
Project.project_manager.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
stmt = sort_query(stmt, Project, sort)
|
stmt = sort_query(stmt, Project, sort)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
pm_map = await _pm_map(db, items)
|
pm_map = await _pm_map(db, items)
|
||||||
@@ -64,24 +78,38 @@ async def list_projects(
|
|||||||
d = {
|
d = {
|
||||||
"id": str(p.id),
|
"id": str(p.id),
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"description": p.description,
|
"location": p.location or "",
|
||||||
|
"project_manager": p.project_manager or "",
|
||||||
|
"description": p.description or "",
|
||||||
"start_date": to_date(p.start_date),
|
"start_date": to_date(p.start_date),
|
||||||
|
"status": p.status or "ACTIVE",
|
||||||
|
"progress": float(p.progress) if p.progress is not None else 0.0,
|
||||||
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
||||||
"created_at": to_iso(p.created_at),
|
"created_at": to_iso(p.created_at),
|
||||||
}
|
}
|
||||||
pm = pm_map.get(p.pm_user_id)
|
pm = pm_map.get(p.pm_user_id)
|
||||||
if pm:
|
if pm:
|
||||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||||
|
elif p.project_manager:
|
||||||
|
d["pm_name"] = p.project_manager
|
||||||
data.append(d)
|
data.append(d)
|
||||||
return {"data": data, "meta": meta}
|
return {"data": data, "meta": meta}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
async def create_project(
|
||||||
|
payload: ProjectCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = Project(
|
project = Project(
|
||||||
name=payload.name,
|
name=payload.name.strip(),
|
||||||
description=payload.description,
|
location=payload.location.strip() if payload.location else "",
|
||||||
|
project_manager=payload.project_manager.strip() if payload.project_manager else "",
|
||||||
|
description=payload.description.strip() if payload.description else "",
|
||||||
start_date=payload.start_date,
|
start_date=payload.start_date,
|
||||||
|
planned_start=payload.start_date,
|
||||||
|
status="ACTIVE",
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -103,37 +131,51 @@ async def update_project(
|
|||||||
request: Request,
|
request: Request,
|
||||||
ctx: UserContext | None = Depends(_optional_ctx),
|
ctx: UserContext | None = Depends(_optional_ctx),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
project.name = payload.name
|
project.name = payload.name.strip()
|
||||||
|
if payload.location is not None:
|
||||||
|
project.location = payload.location.strip()
|
||||||
|
if payload.project_manager is not None:
|
||||||
|
project.project_manager = payload.project_manager.strip()
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
project.description = payload.description
|
project.description = payload.description.strip()
|
||||||
if payload.start_date is not None:
|
if payload.start_date is not None:
|
||||||
project.start_date = payload.start_date
|
project.start_date = payload.start_date
|
||||||
|
project.planned_start = payload.start_date
|
||||||
|
if payload.status is not None:
|
||||||
|
project.status = payload.status
|
||||||
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
||||||
# Admin / procurement manager only (same rule as the Organization panel).
|
# Admin / procurement manager only (same rule as the Organization panel).
|
||||||
if "pm_user_id" in payload.model_fields_set:
|
if "pm_user_id" in payload.model_fields_set:
|
||||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role not in ("admin", "procurement_manager"):
|
if role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
|
||||||
if payload.pm_user_id is None:
|
if payload.pm_user_id is None:
|
||||||
project.pm_user_id = None
|
project.pm_user_id = None
|
||||||
else:
|
else:
|
||||||
pm_user = await db.get(User, payload.pm_user_id)
|
pm_user = await db.get(User, payload.pm_user_id)
|
||||||
if pm_user is None:
|
if pm_user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||||
if pm_user.role != "pm":
|
if pm_user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
|
||||||
project.pm_user_id = pm_user.id
|
project.pm_user_id = pm_user.id
|
||||||
|
if not project.project_manager:
|
||||||
|
project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": await project_to_dict(db, project)}
|
return {"data": await project_to_dict(db, project)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
async def delete_project(
|
||||||
|
project_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Quote, QuoteEvent
|
from app.models.procurement import Quote, QuoteEvent
|
||||||
from app.schemas.procurement import NoteEventIn
|
from app.schemas.procurement import NoteEventIn
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -34,7 +36,12 @@ async def list_events(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
async def create_event(
|
||||||
|
quote_id,
|
||||||
|
payload: NoteEventIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await db.get(Quote, quote_id)
|
quote = await db.get(Quote, quote_id)
|
||||||
if quote is None:
|
if quote is None:
|
||||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||||
|
|||||||
@@ -8,9 +8,19 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import exists, func, or_, select
|
from sqlalchemy import exists, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material
|
from app.models.catalog import Material
|
||||||
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
|
from app.models.procurement import (
|
||||||
|
ItemDecision,
|
||||||
|
LineItem,
|
||||||
|
LineItemProjects,
|
||||||
|
Project,
|
||||||
|
Quote,
|
||||||
|
QuoteProjects,
|
||||||
|
Supplier,
|
||||||
|
Termsheet,
|
||||||
|
)
|
||||||
from app.schemas.procurement import (
|
from app.schemas.procurement import (
|
||||||
AcceptQuoteIn,
|
AcceptQuoteIn,
|
||||||
ArriveItemsIn,
|
ArriveItemsIn,
|
||||||
@@ -27,24 +37,18 @@ from app.services.expand_service import (
|
|||||||
serialize_quote_with_children,
|
serialize_quote_with_children,
|
||||||
serialize_quotes,
|
serialize_quotes,
|
||||||
)
|
)
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||||
from app.utils.pagination import sort_query
|
from app.utils.pagination import sort_query
|
||||||
from app.utils.security import get_actor
|
from app.utils.security import get_actor
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||||
|
|
||||||
|
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
# --- list filter helpers ---------------------------------------------------
|
# --- list filter helpers ---------------------------------------------------
|
||||||
|
|
||||||
def _quote_category_exists(category_id) -> exists:
|
|
||||||
return exists(
|
|
||||||
select(1).where(
|
|
||||||
LineItem.quote_id == Quote.id,
|
|
||||||
Material.id == LineItem.material_id,
|
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _quote_search(q: str) -> exists:
|
def _quote_search(q: str) -> exists:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
@@ -72,7 +76,6 @@ async def get_quote_stats(
|
|||||||
project_id=None,
|
project_id=None,
|
||||||
quote_date_from: datetime | None = None,
|
quote_date_from: datetime | None = None,
|
||||||
quote_date_to: datetime | None = None,
|
quote_date_to: datetime | None = None,
|
||||||
category_id=None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return {
|
return {
|
||||||
@@ -82,7 +85,6 @@ async def get_quote_stats(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
quote_date_from=quote_date_from,
|
quote_date_from=quote_date_from,
|
||||||
quote_date_to=quote_date_to,
|
quote_date_to=quote_date_to,
|
||||||
category_id=category_id,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +100,6 @@ async def list_quotes(
|
|||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
supplier_id=None,
|
supplier_id=None,
|
||||||
project_id=None,
|
project_id=None,
|
||||||
category_id=None,
|
|
||||||
quote_date_from: datetime | None = None,
|
quote_date_from: datetime | None = None,
|
||||||
quote_date_to: datetime | None = None,
|
quote_date_to: datetime | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
@@ -112,9 +113,23 @@ async def list_quotes(
|
|||||||
if supplier_id:
|
if supplier_id:
|
||||||
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
stmt = stmt.where(Quote.project_id == project_id)
|
# any relevant link: legacy mirror, quote-level junction, line-item
|
||||||
if category_id:
|
# candidates, or an accepted (decision) target — NOT just the mirror
|
||||||
stmt = stmt.where(_quote_category_exists(category_id))
|
stmt = stmt.where(
|
||||||
|
Quote.id.in_(
|
||||||
|
select(Quote.id)
|
||||||
|
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||||
|
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(
|
||||||
|
(Quote.project_id == project_id)
|
||||||
|
| (QuoteProjects.project_id == project_id)
|
||||||
|
| (LineItemProjects.project_id == project_id)
|
||||||
|
| (ItemDecision.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
if quote_date_from is not None:
|
if quote_date_from is not None:
|
||||||
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
||||||
if quote_date_to is not None:
|
if quote_date_to is not None:
|
||||||
@@ -145,7 +160,24 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def create_quote(
|
||||||
|
payload: QuoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
ctx: UserContext = Depends(allow_quote_create()),
|
||||||
|
_auth=None,
|
||||||
|
):
|
||||||
|
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||||
|
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||||
|
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||||
|
if ctx.project_id:
|
||||||
|
allowed.add(ctx.project_id)
|
||||||
|
allowed_str = {str(pid) for pid in allowed if pid}
|
||||||
|
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||||
|
if not wanted:
|
||||||
|
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||||
|
if not wanted <= allowed_str:
|
||||||
|
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||||
quote = await quote_service.create_quote(db, payload, actor)
|
quote = await quote_service.create_quote(db, payload, actor)
|
||||||
return {"data": await serialize_quote_with_children(db, quote)}
|
return {"data": await serialize_quote_with_children(db, quote)}
|
||||||
|
|
||||||
@@ -156,7 +188,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
|
|||||||
|
|
||||||
@router.patch("/{quote_id}")
|
@router.patch("/{quote_id}")
|
||||||
async def update_quote(
|
async def update_quote(
|
||||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await serialize_quote(db, quote, set())}
|
return {"data": await serialize_quote(db, quote, set())}
|
||||||
@@ -164,7 +200,11 @@ async def update_quote(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/children")
|
@router.patch("/{quote_id}/children")
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: QuoteChildrenReplaceIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||||
@@ -177,7 +217,11 @@ async def replace_quote_children(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/accept")
|
@router.patch("/{quote_id}/accept")
|
||||||
async def accept_quote(
|
async def accept_quote(
|
||||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: AcceptQuoteIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||||
return {"data": await _accept_response(db, quote)}
|
return {"data": await _accept_response(db, quote)}
|
||||||
@@ -213,7 +257,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/arrive")
|
@router.patch("/{quote_id}/arrive")
|
||||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def arrive_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -226,7 +275,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/arrive-items")
|
@router.patch("/{quote_id}/arrive-items")
|
||||||
async def arrive_items(
|
async def arrive_items(
|
||||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: ArriveItemsIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||||
return {
|
return {
|
||||||
@@ -248,14 +301,23 @@ async def arrive_items(
|
|||||||
|
|
||||||
@router.patch("/{quote_id}/reject")
|
@router.patch("/{quote_id}/reject")
|
||||||
async def reject_quote(
|
async def reject_quote(
|
||||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
quote_id,
|
||||||
|
payload: RejectQuoteIn | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{quote_id}/expire")
|
@router.patch("/{quote_id}/expire")
|
||||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
async def expire_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: str = Depends(get_actor),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||||
|
|
||||||
@@ -265,6 +327,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.delete("/{quote_id}")
|
@router.delete("/{quote_id}")
|
||||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
async def delete_quote(
|
||||||
|
quote_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
result = await quote_service.delete_quote(db, quote_id)
|
result = await quote_service.delete_quote(db, quote_id)
|
||||||
return {"data": result}
|
return {"data": result}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||||
@@ -15,6 +16,7 @@ router = APIRouter(
|
|||||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||||
tags=["schedule-steps"],
|
tags=["schedule-steps"],
|
||||||
)
|
)
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||||
@@ -57,7 +59,11 @@ async def list_schedule_steps(
|
|||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_schedule_step(
|
async def create_schedule_step(
|
||||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: ScheduleStepCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
step_no = payload.step_no
|
step_no = payload.step_no
|
||||||
@@ -84,7 +90,12 @@ async def create_schedule_step(
|
|||||||
|
|
||||||
@router.patch("/{step_id}")
|
@router.patch("/{step_id}")
|
||||||
async def update_schedule_step(
|
async def update_schedule_step(
|
||||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
payload: ScheduleStepUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||||
@@ -109,7 +120,13 @@ async def update_schedule_step(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{step_id}")
|
@router.delete("/{step_id}")
|
||||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
async def delete_schedule_step(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
step_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
step = await _step_or_404(db, termsheet_id, step_id)
|
step = await _step_or_404(db, termsheet_id, step_id)
|
||||||
count = await db.scalar(
|
count = await db.scalar(
|
||||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import StandardConversion
|
from app.models.catalog import StandardConversion
|
||||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||||
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||||
@@ -41,7 +43,11 @@ async def list_standard_conversions(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
async def create_standard_conversion(
|
||||||
|
payload: StandardConversionCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
select(StandardConversion.id).where(
|
select(StandardConversion.id).where(
|
||||||
StandardConversion.from_unit == payload.from_unit,
|
StandardConversion.from_unit == payload.from_unit,
|
||||||
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{sc_id}")
|
@router.patch("/{sc_id}")
|
||||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
payload: StandardConversionUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{sc_id}")
|
@router.delete("/{sc_id}")
|
||||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
async def delete_standard_conversion(
|
||||||
|
sc_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
sc = await db.get(StandardConversion, sc_id)
|
sc = await db.get(StandardConversion, sc_id)
|
||||||
if sc is None:
|
if sc is None:
|
||||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
||||||
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
||||||
GET /materials/{id}/suppliers — price-comparison data source.
|
GET /materials/{id}/suppliers — suppliers linked to a material.
|
||||||
"""
|
"""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import Material, MaterialCategory
|
||||||
from app.models.procurement import Supplier, SupplierMaterial
|
from app.models.procurement import Supplier, SupplierMaterial
|
||||||
@@ -18,31 +21,50 @@ from app.utils.pagination import paginate
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(tags=["supplier-materials"])
|
router = APIRouter(tags=["supplier-materials"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
def link_to_dict(
|
||||||
category: MaterialCategory | None = None,
|
link: SupplierMaterial,
|
||||||
supplier: Supplier | None = None) -> dict:
|
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 {
|
return {
|
||||||
"id": str(link.id),
|
"id": str(link.id),
|
||||||
"supplier_id": str(link.supplier_id),
|
"supplier_id": str(link.supplier_id),
|
||||||
"material_id": str(link.material_id),
|
"material_id": str(link.material_id),
|
||||||
"is_active": link.is_active,
|
"is_active": link.is_active,
|
||||||
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
|
"last_quoted_price": (
|
||||||
|
float(link.last_quoted_price) if link.last_quoted_price is not None else None
|
||||||
|
),
|
||||||
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
||||||
"created_at": to_iso(link.created_at),
|
"created_at": to_iso(link.created_at),
|
||||||
"material": {
|
"material": (
|
||||||
"id": str(material.id),
|
{
|
||||||
"name": material.name,
|
"id": str(material.id),
|
||||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
"name": material.name,
|
||||||
} if material else None,
|
"category": (
|
||||||
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
{"id": str(category.id), "name": category.name} if category else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if material
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"supplier": (
|
||||||
|
{"id": str(supplier.id), "name": supplier.name} if supplier else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /suppliers/{supplier_id}/materials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/suppliers/{supplier_id}/materials")
|
@router.get("/suppliers/{supplier_id}/materials")
|
||||||
async def list_supplier_materials(
|
async def list_supplier_materials(
|
||||||
supplier_id,
|
supplier_id: UUID,
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -56,49 +78,110 @@ async def list_supplier_materials(
|
|||||||
.order_by(SupplierMaterial.created_at)
|
.order_by(SupplierMaterial.created_at)
|
||||||
)
|
)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
mat_ids = {l.material_id for l in items}
|
mat_ids = [lnk.material_id for lnk in items]
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials: dict = {}
|
||||||
cat_ids = {m.category_id for m in materials.values() if m.category_id}
|
if mat_ids:
|
||||||
cats = {}
|
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
|
||||||
if cat_ids:
|
materials = {m.id: m for m in result.scalars()}
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
return {
|
||||||
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
|
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
|
||||||
|
"meta": meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /suppliers/{supplier_id}/materials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
async def create_supplier_material(
|
async def create_supplier_material(
|
||||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
supplier_id: UUID,
|
||||||
|
payload: SupplierMaterialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||||
material = await db.get(Material, payload.material_id)
|
material = await db.get(Material, payload.material_id)
|
||||||
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
|
return {"data": link_to_dict(link, material)}
|
||||||
return {"data": link_to_dict(link, material, category)}
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DELETE /suppliers/{supplier_id}/materials/{material_id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||||
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier_material(
|
||||||
|
supplier_id: UUID,
|
||||||
|
material_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
await unlink_supplier_material(db, supplier_id, material_id)
|
await unlink_supplier_material(db, supplier_id, material_id)
|
||||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/materials/{material_id}/suppliers")
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /materials/{material_id}/suppliers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/materials/{material_id}/suppliers",
|
||||||
|
summary="List all suppliers linked to a material",
|
||||||
|
)
|
||||||
async def list_material_suppliers(
|
async def list_material_suppliers(
|
||||||
material_id,
|
material_id: UUID,
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Return the suppliers linked to a material, straight from PostgreSQL.
|
||||||
|
|
||||||
|
The path parameter is typed as UUID so FastAPI validates and coerces it
|
||||||
|
before the handler runs; a malformed id is rejected with 422, an unknown
|
||||||
|
material with 404.
|
||||||
|
|
||||||
|
Atomic JOIN — a supplier is returned only through its supplier_materials
|
||||||
|
link, so the response can never contain a supplier that is not actually
|
||||||
|
linked to this material:
|
||||||
|
|
||||||
|
select(Supplier)
|
||||||
|
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(SupplierMaterial.material_id == material_id)
|
||||||
|
|
||||||
|
Response envelope (frontend MaterialSupplier binds these fields directly):
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "<supplier_uuid>",
|
||||||
|
"name": "فولاد ناب آریا",
|
||||||
|
"phone": "021-..." | null,
|
||||||
|
"contact_person": "علی محمدی" | null,
|
||||||
|
"location": "تهران" | null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
material = await db.get(Material, material_id)
|
material = await db.get(Material, material_id)
|
||||||
if material is None:
|
if material is None:
|
||||||
raise NotFoundError("متریال یافت نشد")
|
raise NotFoundError("متریال یافت نشد")
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(SupplierMaterial)
|
select(Supplier)
|
||||||
|
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
.where(SupplierMaterial.material_id == material_id)
|
.where(SupplierMaterial.material_id == material_id)
|
||||||
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
.order_by(Supplier.name)
|
||||||
)
|
)
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
suppliers = (await db.execute(stmt)).scalars().all()
|
||||||
sup_ids = {l.supplier_id for l in items}
|
|
||||||
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
|
return {
|
||||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
"data": [
|
||||||
|
{
|
||||||
|
"id": str(supplier.id),
|
||||||
|
"name": supplier.name,
|
||||||
|
"phone": supplier.phone,
|
||||||
|
"contact_person": supplier.contact_person,
|
||||||
|
"location": supplier.location,
|
||||||
|
}
|
||||||
|
for supplier in suppliers
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
from app.models.procurement import Quote, Supplier
|
from app.models.procurement import Quote, Supplier
|
||||||
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
|
|||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||||
@@ -71,7 +73,11 @@ async def list_suppliers(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
async def create_supplier(
|
||||||
|
payload: SupplierCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
category = await _category_or_404(db, payload.category_id)
|
category = await _category_or_404(db, payload.category_id)
|
||||||
supplier = Supplier(
|
supplier = Supplier(
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{supplier_id}")
|
@router.patch("/{supplier_id}")
|
||||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_supplier(
|
||||||
|
supplier_id,
|
||||||
|
payload: SupplierUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{supplier_id}")
|
@router.delete("/{supplier_id}")
|
||||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
async def delete_supplier(
|
||||||
|
supplier_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
supplier = await db.get(Supplier, supplier_id)
|
supplier = await db.get(Supplier, supplier_id)
|
||||||
if supplier is None:
|
if supplier is None:
|
||||||
raise NotFoundError("تأمینکننده یافت نشد")
|
raise NotFoundError("تأمینکننده یافت نشد")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||||
@@ -80,7 +82,12 @@ async def list_termsheets(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
async def create_termsheet(
|
||||||
|
quote_id,
|
||||||
|
payload: TermsheetCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
@@ -110,7 +117,11 @@ async def get_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}")
|
@router.patch("/{termsheet_id}")
|
||||||
async def update_termsheet(
|
async def update_termsheet(
|
||||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
if ts.status != "offered":
|
if ts.status != "offered":
|
||||||
@@ -127,7 +138,11 @@ async def update_termsheet(
|
|||||||
|
|
||||||
@router.patch("/{termsheet_id}/status")
|
@router.patch("/{termsheet_id}/status")
|
||||||
async def update_termsheet_status(
|
async def update_termsheet_status(
|
||||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
payload: TermsheetStatusUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
):
|
):
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
@@ -139,7 +154,12 @@ async def update_termsheet_status(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{termsheet_id}")
|
@router.delete("/{termsheet_id}")
|
||||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
async def delete_termsheet(
|
||||||
|
quote_id,
|
||||||
|
termsheet_id,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||||
quote = await _quote_or_404(db, quote_id)
|
quote = await _quote_or_404(db, quote_id)
|
||||||
if quote.status != "offered":
|
if quote.status != "offered":
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
|
|
||||||
|
|
||||||
Model: {category_id, brand?, from_unit, to_unit, factor}
|
|
||||||
1 from_unit = factor × to_unit
|
|
||||||
brand empty = all brands of the category; brand set = that brand only.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.engine import get_db
|
|
||||||
from app.models.catalog import MaterialCategory, UnitConversion
|
|
||||||
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
|
|
||||||
from app.services.expand_service import category_brief
|
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
|
||||||
from app.utils.pagination import paginate
|
|
||||||
from app.utils.serialization import to_iso
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
|
|
||||||
|
|
||||||
|
|
||||||
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
|
|
||||||
d = {
|
|
||||||
"id": str(uc.id),
|
|
||||||
"category_id": str(uc.category_id),
|
|
||||||
"brand": uc.brand,
|
|
||||||
"from_unit": uc.from_unit,
|
|
||||||
"to_unit": uc.to_unit,
|
|
||||||
"factor": float(uc.factor),
|
|
||||||
"updated_at": to_iso(uc.updated_at),
|
|
||||||
}
|
|
||||||
if "category" in expand and hasattr(uc, "_category"):
|
|
||||||
d["category"] = category_brief(uc._category)
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
|
|
||||||
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
|
|
||||||
where = (
|
|
||||||
UnitConversion.category_id == category_id,
|
|
||||||
UnitConversion.from_unit == from_unit,
|
|
||||||
UnitConversion.to_unit == to_unit,
|
|
||||||
)
|
|
||||||
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده میشود
|
|
||||||
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
|
|
||||||
if uc_or_none is not None:
|
|
||||||
where = where + (UnitConversion.id != uc_or_none.id,)
|
|
||||||
return where
|
|
||||||
|
|
||||||
|
|
||||||
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
|
|
||||||
duplicate = await db.scalar(
|
|
||||||
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
|
|
||||||
)
|
|
||||||
if duplicate is not None:
|
|
||||||
raise ConflictError("این تبدیل قبلاً ثبت شده است")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_unit_conversions(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
|
||||||
category_id=None,
|
|
||||||
brand: str | None = None,
|
|
||||||
from_unit: str | None = None,
|
|
||||||
to_unit: str | None = None,
|
|
||||||
expand: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
stmt = select(UnitConversion)
|
|
||||||
if category_id:
|
|
||||||
stmt = stmt.where(UnitConversion.category_id == category_id)
|
|
||||||
if brand:
|
|
||||||
stmt = stmt.where(UnitConversion.brand == brand)
|
|
||||||
if from_unit:
|
|
||||||
stmt = stmt.where(UnitConversion.from_unit == from_unit)
|
|
||||||
if to_unit:
|
|
||||||
stmt = stmt.where(UnitConversion.to_unit == to_unit)
|
|
||||||
items, meta = await paginate(stmt, page, page_size, db)
|
|
||||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
|
||||||
if "category" in tokens:
|
|
||||||
cat_ids = {uc.category_id for uc in items}
|
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
for uc in items:
|
|
||||||
uc._category = cats.get(uc.category_id)
|
|
||||||
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
|
||||||
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
|
|
||||||
category = await db.get(MaterialCategory, payload.category_id)
|
|
||||||
if category is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
|
|
||||||
uc = UnitConversion(
|
|
||||||
category_id=payload.category_id,
|
|
||||||
brand=payload.brand,
|
|
||||||
from_unit=payload.from_unit,
|
|
||||||
to_unit=payload.to_unit,
|
|
||||||
factor=payload.factor,
|
|
||||||
)
|
|
||||||
db.add(uc)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": uc_to_dict(uc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{uc_id}")
|
|
||||||
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
|
||||||
uc = await db.get(UnitConversion, uc_id)
|
|
||||||
if uc is None:
|
|
||||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
|
||||||
return {"data": uc_to_dict(uc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{uc_id}")
|
|
||||||
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
|
|
||||||
uc = await db.get(UnitConversion, uc_id)
|
|
||||||
if uc is None:
|
|
||||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
|
||||||
new_brand = payload.brand if payload.brand is not None else uc.brand
|
|
||||||
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
|
|
||||||
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
|
|
||||||
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
|
|
||||||
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
|
|
||||||
if payload.brand is not None:
|
|
||||||
uc.brand = payload.brand
|
|
||||||
if payload.from_unit is not None:
|
|
||||||
uc.from_unit = payload.from_unit
|
|
||||||
if payload.to_unit is not None:
|
|
||||||
uc.to_unit = payload.to_unit
|
|
||||||
if payload.factor is not None:
|
|
||||||
uc.factor = payload.factor
|
|
||||||
await db.flush()
|
|
||||||
return {"data": uc_to_dict(uc)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{uc_id}")
|
|
||||||
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
|
||||||
uc = await db.get(UnitConversion, uc_id)
|
|
||||||
if uc is None:
|
|
||||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
|
||||||
await db.delete(uc)
|
|
||||||
await db.flush()
|
|
||||||
return {"data": {"deleted": True}}
|
|
||||||
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import require_procurement_writer, require_role
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||||
from app.models.procurement import LineItem, LineItemSpec
|
from app.models.procurement import LineItem, LineItemSpec
|
||||||
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
|
|||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
|
|
||||||
router = APIRouter(prefix="/units", tags=["units"])
|
router = APIRouter(prefix="/units", tags=["units"])
|
||||||
|
_procurement_writer = require_procurement_writer()
|
||||||
|
|
||||||
|
|
||||||
def unit_full_to_dict(unit: Unit) -> dict:
|
def unit_full_to_dict(unit: Unit) -> dict:
|
||||||
@@ -45,7 +47,11 @@ async def list_units(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
async def create_unit(
|
||||||
|
payload: UnitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||||
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{unit_id}")
|
@router.patch("/{unit_id}")
|
||||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
async def update_unit(
|
||||||
|
unit_id: int,
|
||||||
|
payload: UnitUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}")
|
@router.delete("/{unit_id}")
|
||||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
async def delete_unit(
|
||||||
|
unit_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_auth=Depends(_procurement_writer),
|
||||||
|
):
|
||||||
unit = await db.get(Unit, unit_id)
|
unit = await db.get(Unit, unit_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
raise NotFoundError("واحد یافت نشد")
|
raise NotFoundError("واحد یافت نشد")
|
||||||
|
|||||||
+191
-32
@@ -17,14 +17,21 @@ import uuid
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, select, update, text, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
from app.auth import UserContext, get_current_user, require_role
|
||||||
|
from app.utils.audit import record as audit_record
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User, UserPanel, UserProjectMembership
|
||||||
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
from app.schemas.procurement import (
|
||||||
|
UserCreate,
|
||||||
|
UserPanelsUpdate,
|
||||||
|
UserPanelProjectsUpdate,
|
||||||
|
UserProjectUpdate,
|
||||||
|
UserRoleUpdate,
|
||||||
|
)
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ConflictError,
|
ConflictError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -40,17 +47,47 @@ USER_ROLE_LABELS = {
|
|||||||
"pm": "مدیر پروژه",
|
"pm": "مدیر پروژه",
|
||||||
"procurement_manager": "مدیر تدارکات",
|
"procurement_manager": "مدیر تدارکات",
|
||||||
"admin": "ادمین",
|
"admin": "ادمین",
|
||||||
|
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||||
|
"supervisor": "سرپرست پروژه",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
PANEL_LEVELS = ("full", "limited")
|
||||||
|
PANELS = ("procurement", "inventory", "pm")
|
||||||
|
|
||||||
|
|
||||||
|
async def _panels_of(db: AsyncSession, user_id) -> dict:
|
||||||
|
"""panel -> level map for the user (لایه پنلها — جایگزین نقشها)."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return {row.panel: row.level for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
|
||||||
|
"""عضویت M:N کاربر در پروژهها (جدا از مسئولیت) — رشته."""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(UserProjectMembership.project_id)
|
||||||
|
.where(UserProjectMembership.user_id == user_id)
|
||||||
|
.order_by(UserProjectMembership.created_at)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return [str(pid) for pid in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
|
||||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||||
project_id/project_name stay as the first (oldest) one for compatibility."""
|
project_id/project_name stay as the first (oldest) one for compatibility.
|
||||||
|
با db، پنلهای تخصیصی و عضویت پروژهای هم برمیگردند."""
|
||||||
projects = projects or []
|
projects = projects or []
|
||||||
first = projects[0] if projects else None
|
first = projects[0] if projects else None
|
||||||
return {
|
out = {
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"casdoor_id": user.casdoor_id,
|
"casdoor_id": user.casdoor_id,
|
||||||
|
"username": user.username,
|
||||||
"first_name": user.first_name,
|
"first_name": user.first_name,
|
||||||
"last_name": user.last_name,
|
"last_name": user.last_name,
|
||||||
"phone": user.phone,
|
"phone": user.phone,
|
||||||
@@ -61,23 +98,39 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
|||||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||||
"created_at": to_iso(user.created_at),
|
"created_at": to_iso(user.created_at),
|
||||||
}
|
}
|
||||||
|
if db is not None:
|
||||||
|
out["panels"] = await _panels_of(db, user.id)
|
||||||
|
member_pids = await _member_project_ids_of(db, user.id)
|
||||||
|
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
|
||||||
|
out["member_project_ids"] = all_pids
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
|
||||||
"""Admin OR procurement manager (the same person) — organization management."""
|
"""Admin, procurement manager, or project manager — organization management."""
|
||||||
if ctx.role not in ("admin", "procurement_manager"):
|
if ctx.role not in ("admin", "procurement_manager", "pm"):
|
||||||
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
"""Every project assigned to the user (supervisor or PM) (oldest first).
|
||||||
if user.role != "pm":
|
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
|
||||||
|
"""
|
||||||
|
if user.role not in ("supervisor", "pm"):
|
||||||
return []
|
return []
|
||||||
|
member_subq = select(UserProjectMembership.project_id).where(
|
||||||
|
UserProjectMembership.user_id == user.id
|
||||||
|
)
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id == user.id)
|
.where(
|
||||||
|
or_(
|
||||||
|
Project.pm_user_id == user.id,
|
||||||
|
Project.id.in_(member_subq),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -126,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
|
|||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
return {"data": user_to_dict(ctx.user, projects)}
|
return {"data": await user_to_dict(ctx.user, projects, db)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/projects")
|
@router.get("/me/projects")
|
||||||
@@ -137,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
|
|||||||
active project against this list and falls back gracefully when a
|
active project against this list and falls back gracefully when a
|
||||||
project is revoked."""
|
project is revoked."""
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
|
# عضویتهای M:N (لایه پنلها) هم به فهرست اضافه میشود
|
||||||
|
member_ids = set(ctx.member_project_ids or [])
|
||||||
|
have = {p.id for p in projects}
|
||||||
|
if member_ids:
|
||||||
|
extra = (
|
||||||
|
await db.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.id.in_(member_ids))
|
||||||
|
.order_by(Project.created_at, Project.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
projects = list(projects) + [p for p in extra if p.id not in have]
|
||||||
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,20 +212,20 @@ async def list_users(
|
|||||||
):
|
):
|
||||||
"""Every user (with role + assigned projects) — Organization panel."""
|
"""Every user (with role + assigned projects) — Organization panel."""
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in projs:
|
for p in projs:
|
||||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||||
return {
|
return {
|
||||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||||
"meta": {"total": len(users)},
|
"meta": {"total": len(users)},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +233,7 @@ async def list_users(
|
|||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
payload: UserCreate,
|
payload: UserCreate,
|
||||||
_: UserContext = Depends(_org_manager),
|
ctx: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Define a new user (manual identity — casdoor_id optional)."""
|
"""Define a new user (manual identity — casdoor_id optional)."""
|
||||||
@@ -185,28 +250,31 @@ async def create_user(
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user)}
|
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
||||||
|
return {"data": await user_to_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}")
|
@router.patch("/{user_id}")
|
||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
_: UserContext = Depends(require_role("admin")),
|
ctx: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
if payload.role == "admin" and ctx.role != "admin":
|
||||||
unassigns the user from their project."""
|
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
if user.role == "pm" and payload.role != "pm":
|
previous_role = user.role
|
||||||
|
if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
user.role = payload.role
|
user.role = payload.role
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
||||||
projects = await _user_projects(db, user)
|
projects = await _user_projects(db, user)
|
||||||
return {"data": user_to_dict(user, projects)}
|
return {"data": await user_to_dict(user, projects)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync")
|
@router.post("/sync")
|
||||||
@@ -227,8 +295,15 @@ async def sync_casdoor_users(
|
|||||||
continue
|
continue
|
||||||
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
by_uname = await db.scalar(select(User).where(User.username == uname))
|
||||||
|
if by_uname is not None:
|
||||||
|
by_uname.casdoor_id = cid
|
||||||
|
continue
|
||||||
db.add(User(
|
db.add(User(
|
||||||
casdoor_id=cid,
|
casdoor_id=cid,
|
||||||
|
username=uname,
|
||||||
first_name=str(
|
first_name=str(
|
||||||
cu.get("firstName")
|
cu.get("firstName")
|
||||||
or cu.get("displayName")
|
or cu.get("displayName")
|
||||||
@@ -239,23 +314,27 @@ async def sync_casdoor_users(
|
|||||||
phone=str(cu.get("phone") or "") or None,
|
phone=str(cu.get("phone") or "") or None,
|
||||||
))
|
))
|
||||||
created += 1
|
created += 1
|
||||||
|
elif not existing.username:
|
||||||
|
uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
|
||||||
|
if uname:
|
||||||
|
existing.username = uname
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
|
||||||
by_user: dict = {}
|
by_user: dict = {}
|
||||||
if pm_ids:
|
if supervisor_ids:
|
||||||
projs = (
|
projs = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Project)
|
select(Project)
|
||||||
.where(Project.pm_user_id.in_(pm_ids))
|
.where(Project.pm_user_id.in_(supervisor_ids))
|
||||||
.order_by(Project.created_at, Project.id)
|
.order_by(Project.created_at, Project.id)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
for p in projs:
|
for p in projs:
|
||||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||||
return {
|
return {
|
||||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
"data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
|
||||||
"meta": {"total": len(users), "created": created},
|
"meta": {"total": len(users), "created": created},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,17 +357,97 @@ async def assign_user_project(
|
|||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
if user.role != "pm":
|
if user.role not in ("supervisor", "pm"):
|
||||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
|
||||||
|
|
||||||
if payload.project_id is None:
|
if payload.project_id is None:
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
|
||||||
|
{"uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": await user_to_dict(user)}
|
||||||
|
|
||||||
project = await db.get(Project, payload.project_id)
|
project = await db.get(Project, payload.project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
project.pm_user_id = user.id
|
project.pm_user_id = user.id
|
||||||
|
if user.username:
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO project_assignments (id, user_id, project_id)
|
||||||
|
SELECT gen_random_uuid(), pu.id, :pid
|
||||||
|
FROM pm_users pu
|
||||||
|
WHERE pu.username = :uname
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project_assignments pa
|
||||||
|
WHERE pa.user_id = pu.id AND pa.project_id = :pid
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"pid": project.id, "uname": user.username}
|
||||||
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
return {"data": await user_to_dict(user, await _user_projects(db, user))}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# پنلها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/panels")
|
||||||
|
@router.patch("/{user_id}/panels")
|
||||||
|
async def set_user_panels(
|
||||||
|
user_id,
|
||||||
|
payload: UserPanelsUpdate,
|
||||||
|
_: UserContext = Depends(_org_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
for panel, level in payload.panels.items():
|
||||||
|
if panel not in PANELS:
|
||||||
|
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
|
||||||
|
if level not in PANEL_LEVELS:
|
||||||
|
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
|
||||||
|
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
|
||||||
|
for panel, level in payload.panels.items():
|
||||||
|
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
|
||||||
|
await db.flush()
|
||||||
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{user_id}/panel-projects")
|
||||||
|
@router.patch("/{user_id}/panel-projects")
|
||||||
|
async def set_user_panel_projects(
|
||||||
|
user_id,
|
||||||
|
payload: UserPanelProjectsUpdate,
|
||||||
|
_: UserContext = Depends(_org_manager),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
wanted = list(dict.fromkeys(payload.project_ids))
|
||||||
|
if wanted:
|
||||||
|
found = (
|
||||||
|
await db.execute(select(Project).where(Project.id.in_(wanted)))
|
||||||
|
).scalars().all()
|
||||||
|
found_ids = {p.id for p in found}
|
||||||
|
unknown = [str(pid) for pid in wanted if pid not in found_ids]
|
||||||
|
if unknown:
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
for pid in wanted:
|
||||||
|
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
|
||||||
|
await db.flush()
|
||||||
|
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaterialCreate(APIModel):
|
class MaterialCreate(APIModel):
|
||||||
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
|
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
canonical_unit_id: int
|
canonical_unit_id: int
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class WarehouseUpdate(APIModel):
|
class WarehouseUpdate(APIModel):
|
||||||
|
"""Rename/re-describe a warehouse.
|
||||||
|
|
||||||
|
`project_id` is accepted only to be rejected: a warehouse belongs
|
||||||
|
permanently to its parent project and cannot be reassigned (the router
|
||||||
|
answers 400 unless the value equals the current project).
|
||||||
|
"""
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
project_id: UUID | None = None
|
project_id: UUID | None = None
|
||||||
@@ -57,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
specs: dict | None = None
|
specs: dict | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
delivered_to: str | None = None
|
||||||
transaction_date: datetime | None = None
|
transaction_date: datetime | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@@ -37,21 +37,26 @@ class SupplierUpdate(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierMaterialCreate(APIModel):
|
class SupplierMaterialCreate(APIModel):
|
||||||
"""Link a global material to a supplier (derives materials.category_id)."""
|
"""Link a global material to a supplier."""
|
||||||
|
|
||||||
material_id: UUID
|
material_id: UUID
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(APIModel):
|
class ProjectCreate(APIModel):
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(APIModel):
|
class ProjectUpdate(APIModel):
|
||||||
name: str | None = Field(default=None, min_length=1)
|
name: str | None = Field(default=None, min_length=1)
|
||||||
|
location: str | None = None
|
||||||
|
project_manager: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
start_date: date | None = None
|
start_date: date | None = None
|
||||||
|
status: str | None = None
|
||||||
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
||||||
# Admin / procurement manager only; None unassigns this project's PM.
|
# Admin / procurement manager only; None unassigns this project's PM.
|
||||||
pm_user_id: UUID | None = None
|
pm_user_id: UUID | None = None
|
||||||
@@ -135,6 +140,11 @@ class AcceptTermsheetIn(APIModel):
|
|||||||
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAllocationIn(APIModel):
|
||||||
|
project_id: UUID
|
||||||
|
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
|
||||||
|
|
||||||
|
|
||||||
class ItemDecisionIn(APIModel):
|
class ItemDecisionIn(APIModel):
|
||||||
line_item_id: UUID
|
line_item_id: UUID
|
||||||
status: Literal["chosen", "rejected"]
|
status: Literal["chosen", "rejected"]
|
||||||
@@ -142,6 +152,7 @@ class ItemDecisionIn(APIModel):
|
|||||||
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
|
||||||
adjusted_notes: str | None = None
|
adjusted_notes: str | None = None
|
||||||
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
|
||||||
|
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
|
||||||
|
|
||||||
|
|
||||||
class ItemProjectsIn(APIModel):
|
class ItemProjectsIn(APIModel):
|
||||||
@@ -203,13 +214,13 @@ class UserCreate(APIModel):
|
|||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
phone: str | None = None
|
phone: str | None = None
|
||||||
casdoor_id: str | None = None
|
casdoor_id: str | None = None
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserRoleUpdate(APIModel):
|
class UserRoleUpdate(APIModel):
|
||||||
"""role=None revokes the role (and any project assignment)."""
|
"""role=None revokes the role (and any project assignment)."""
|
||||||
|
|
||||||
role: Literal["pm", "procurement_manager", "admin"] | None = None
|
role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
|
||||||
|
|
||||||
|
|
||||||
class UserProjectUpdate(APIModel):
|
class UserProjectUpdate(APIModel):
|
||||||
@@ -341,3 +352,16 @@ class ReceiptUpdate(APIModel):
|
|||||||
received_qty: Decimal | None = Field(default=None, gt=0)
|
received_qty: Decimal | None = Field(default=None, gt=0)
|
||||||
on_time: bool | None = None
|
on_time: bool | None = None
|
||||||
quality_note: str | None = None
|
quality_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanelsUpdate(APIModel):
|
||||||
|
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
|
||||||
|
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف میشود."""
|
||||||
|
|
||||||
|
panels: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPanelProjectsUpdate(APIModel):
|
||||||
|
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
|
||||||
|
|
||||||
|
project_ids: list[UUID] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -0,0 +1,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,77 @@
|
|||||||
|
"""Server-side product analytics — OpenPanel Track API.
|
||||||
|
|
||||||
|
Fire-and-forget business events (quote lifecycle, receipts, logins) so the
|
||||||
|
OpenPanel dashboards show what actually happens inside the ERP, not just
|
||||||
|
page views. Never raises: analytics must not break a business flow.
|
||||||
|
|
||||||
|
Configuration (all optional — disabled when any is missing):
|
||||||
|
PROCUREMENT_OPENPANEL_API_URL e.g. http://openpanel-api:3333 (self-hosted)
|
||||||
|
PROCUREMENT_OPENPANEL_CLIENT_ID project client id (write mode)
|
||||||
|
PROCUREMENT_OPENPANEL_CLIENT_SECRET project client secret
|
||||||
|
|
||||||
|
Contract (OpenPanel Track API): POST {API_URL}/track with headers
|
||||||
|
``openpanel-client-id`` / ``openpanel-client-secret`` and a JSON body
|
||||||
|
``{"type": "track"|"identify", "profileId": ..., "event"/"properties": ...}``.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger("procurement.analytics")
|
||||||
|
|
||||||
|
_TIMEOUT = httpx.Timeout(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured() -> bool:
|
||||||
|
return bool(
|
||||||
|
settings.OPENPANEL_API_URL
|
||||||
|
and settings.OPENPANEL_CLIENT_ID
|
||||||
|
and settings.OPENPANEL_CLIENT_SECRET
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def track(event: str, properties: dict[str, Any] | None = None, profile_id: str | None = None) -> None:
|
||||||
|
"""Record a named event; swallows every error."""
|
||||||
|
if not _configured():
|
||||||
|
return
|
||||||
|
# OpenPanel's Track API takes {type, payload:{…}} (same envelope the browser SDK
|
||||||
|
# sends) — a flat body is rejected with 400, which this module swallows by design.
|
||||||
|
# The payload field is "name" (ITrackPayload in packages/validation) — "event" is
|
||||||
|
# rejected with a 400 that this module swallows.
|
||||||
|
inner: dict[str, Any] = {"name": event, "properties": properties or {}}
|
||||||
|
if profile_id:
|
||||||
|
inner["profileId"] = profile_id
|
||||||
|
body = {"type": "track", "payload": inner}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
|
||||||
|
json=body,
|
||||||
|
headers={
|
||||||
|
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
|
||||||
|
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — analytics must never raise
|
||||||
|
logger.debug("analytics track failed (%s): %s", event, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def identify(profile_id: str, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Create/update a user profile (e.g. on login)."""
|
||||||
|
if not _configured():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
await client.post(
|
||||||
|
f"{settings.OPENPANEL_API_URL.rstrip('/')}/track",
|
||||||
|
json={"type": "identify", "payload": {"profileId": profile_id, "properties": properties or {}}},
|
||||||
|
headers={
|
||||||
|
"openpanel-client-id": settings.OPENPANEL_CLIENT_ID,
|
||||||
|
"openpanel-client-secret": settings.OPENPANEL_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("analytics identify failed: %s", exc)
|
||||||
@@ -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}}
|
||||||
@@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None:
|
|||||||
return {"id": str(project.id), "name": project.name}
|
return {"id": str(project.id), "name": project.name}
|
||||||
|
|
||||||
|
|
||||||
def decision_brief(d: ItemDecision) -> dict:
|
def decision_brief(d: ItemDecision, project: Project | None = None) -> dict:
|
||||||
"""Shape used inside expanded line items (P0-14)."""
|
"""Shape used inside expanded line items (P0-14)."""
|
||||||
return {
|
return {
|
||||||
"id": str(d.id),
|
"id": str(d.id),
|
||||||
"status": d.status,
|
"status": d.status,
|
||||||
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
|
||||||
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
|
||||||
|
"project": project_brief(project) if project is not None else None,
|
||||||
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
}
|
}
|
||||||
@@ -205,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
|
|||||||
material_ids = [m.id for m in materials]
|
material_ids = [m.id for m in materials]
|
||||||
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
||||||
|
|
||||||
if "category" in expand:
|
|
||||||
cat_ids = {m.category_id for m in materials}
|
|
||||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
|
||||||
maps["categories"] = {c.id: c for c in rows}
|
|
||||||
|
|
||||||
unit_ids: set[int] = set()
|
unit_ids: set[int] = set()
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
unit_ids |= {m.canonical_unit_id for m in materials}
|
unit_ids |= {m.canonical_unit_id for m in materials}
|
||||||
@@ -256,10 +252,12 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
|||||||
"created_at": to_iso(m.created_at),
|
"created_at": to_iso(m.created_at),
|
||||||
"updated_at": to_iso(m.updated_at),
|
"updated_at": to_iso(m.updated_at),
|
||||||
}
|
}
|
||||||
|
# Category is a supplier attribute now, not a material one — the key stays
|
||||||
|
# in the payload (null) so the contract shape is unchanged.
|
||||||
if "category" in expand:
|
if "category" in expand:
|
||||||
d["category"] = category_brief(maps["categories"].get(m.category_id))
|
d["category"] = None
|
||||||
else:
|
else:
|
||||||
d["category_id"] = str(m.category_id)
|
d["category_id"] = None
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
||||||
else:
|
else:
|
||||||
@@ -313,6 +311,7 @@ class _LineItemData:
|
|||||||
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
|
||||||
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
|
||||||
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
|
||||||
|
self.projects_by_id: dict[uuid.UUID, Project] = {}
|
||||||
|
|
||||||
|
|
||||||
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
|
||||||
@@ -324,9 +323,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
material_ids = {li.material_id for li in line_items}
|
material_ids = {li.material_id for li in line_items}
|
||||||
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
||||||
data.materials = {m.id: m for m in materials}
|
data.materials = {m.id: m for m in materials}
|
||||||
cat_ids = {m.category_id for m in materials}
|
|
||||||
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
|
||||||
data.categories = {c.id: c for c in cats}
|
|
||||||
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
||||||
brand_ids: set[uuid.UUID] = set()
|
brand_ids: set[uuid.UUID] = set()
|
||||||
for bm in bms:
|
for bm in bms:
|
||||||
@@ -375,6 +371,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
if "decision" in expand or "receipt" in expand:
|
if "decision" in expand or "receipt" in expand:
|
||||||
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
data.decisions = {d.line_item_id: d for d in decisions}
|
data.decisions = {d.line_item_id: d for d in decisions}
|
||||||
|
dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None}
|
||||||
|
missing_pids = dec_proj_ids - set(proj_map.keys())
|
||||||
|
if missing_pids:
|
||||||
|
extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all()
|
||||||
|
for p in extra_projs:
|
||||||
|
proj_map[p.id] = p
|
||||||
|
data.projects_by_id = proj_map
|
||||||
dec_ids = [d.id for d in decisions]
|
dec_ids = [d.id for d in decisions]
|
||||||
if dec_ids:
|
if dec_ids:
|
||||||
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
|
||||||
@@ -441,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
d["material"] = {
|
d["material"] = {
|
||||||
"id": str(m.id),
|
"id": str(m.id),
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
"category": category_brief(data.categories.get(m.category_id)),
|
"category": None,
|
||||||
"brands": [
|
"brands": [
|
||||||
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
||||||
for bm in data.material_brands.get(m.id, [])
|
for bm in data.material_brands.get(m.id, [])
|
||||||
@@ -469,8 +472,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
|
||||||
)
|
)
|
||||||
decision = data.decisions.get(li.id)
|
decision = data.decisions.get(li.id)
|
||||||
|
dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None
|
||||||
|
d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None
|
||||||
if "decision" in expand:
|
if "decision" in expand:
|
||||||
d["decision"] = decision_brief(decision) if decision is not None else None
|
d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None
|
||||||
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
|
||||||
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
# termsheet_ids در جلوی برنامه، id ترمشیت واقعی هم ارسال میشود.
|
||||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
@@ -319,6 +320,7 @@ async def apply_transaction(
|
|||||||
transaction_type=t,
|
transaction_type=t,
|
||||||
quantity=-quantity,
|
quantity=-quantity,
|
||||||
performed_by=performed_by,
|
performed_by=performed_by,
|
||||||
|
delivered_to=payload.delivered_to,
|
||||||
transaction_date=payload.transaction_date,
|
transaction_date=payload.transaction_date,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from app.models.catalog import (
|
|||||||
BrandMaterial,
|
BrandMaterial,
|
||||||
Material,
|
Material,
|
||||||
MaterialAttributeValue,
|
MaterialAttributeValue,
|
||||||
MaterialCategory,
|
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
||||||
@@ -110,9 +109,7 @@ async def validate_attributes(
|
|||||||
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
||||||
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
||||||
|
|
||||||
v2.5/2.6: category_id is OPTIONAL — a global material may be created
|
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
|
||||||
without a category (it gets derived automatically from the first linked
|
|
||||||
supplier). name is GLOBALLY unique.
|
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
|
|||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||||
|
|
||||||
if payload.category_id is not None:
|
|
||||||
if await db.get(MaterialCategory, payload.category_id) is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
if not await db.get(Unit, payload.canonical_unit_id):
|
if not await db.get(Unit, payload.canonical_unit_id):
|
||||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||||
|
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=payload.category_id,
|
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
topic=payload.topic,
|
topic=payload.topic,
|
||||||
canonical_unit_id=payload.canonical_unit_id,
|
canonical_unit_id=payload.canonical_unit_id,
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -4,8 +4,10 @@ Every mutation here runs in ONE ``async with db.begin()`` block; any failure
|
|||||||
rolls the whole operation back. All state changes log a quote_event in the
|
rolls the whole operation back. All state changes log a quote_event in the
|
||||||
same transaction.
|
same transaction.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
@@ -17,6 +19,7 @@ from app.models.procurement import (
|
|||||||
ItemTermsheet,
|
ItemTermsheet,
|
||||||
LineItem,
|
LineItem,
|
||||||
LineItemProjects,
|
LineItemProjects,
|
||||||
|
LineItemSpec,
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
QuoteEvent,
|
QuoteEvent,
|
||||||
@@ -33,6 +36,7 @@ from app.schemas.procurement import (
|
|||||||
QuoteUpdate,
|
QuoteUpdate,
|
||||||
RejectQuoteIn,
|
RejectQuoteIn,
|
||||||
)
|
)
|
||||||
|
from app.services import analytics
|
||||||
from app.services.material_service import ensure_brand_material_belong
|
from app.services.material_service import ensure_brand_material_belong
|
||||||
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||||
|
|
||||||
@@ -56,6 +60,19 @@ def utcnow() -> datetime:
|
|||||||
|
|
||||||
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
|
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
|
||||||
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
|
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
|
||||||
|
# Product analytics (fire-and-forget, never blocks or raises): the whole
|
||||||
|
# quote lifecycle funnels through here, so one hook covers every
|
||||||
|
# status transition for the OpenPanel dashboards.
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop().create_task(
|
||||||
|
analytics.track(
|
||||||
|
f"quote_{event_type}",
|
||||||
|
{"quote_id": str(quote_id), "actor": actor, **(payload or {})},
|
||||||
|
profile_id=actor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RuntimeError: # no running loop (defensive — callers are async)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote:
|
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote:
|
||||||
@@ -279,13 +296,89 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a
|
|||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
||||||
|
"""Returns True if any warehouse receipt has been posted for the quote's items."""
|
||||||
|
count = await db.scalar(
|
||||||
|
select(func.count(ItemReceipt.id))
|
||||||
|
.join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id)
|
||||||
|
.join(LineItem, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0)
|
||||||
|
)
|
||||||
|
return (count or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
|
||||||
|
"""Check candidate material_ids and purge any that have zero references.
|
||||||
|
|
||||||
|
A material is considered an orphan if:
|
||||||
|
1. It is not referenced by ANY line_items across all quotes.
|
||||||
|
2. It is not referenced by ANY inventory stock items in warehouses.
|
||||||
|
|
||||||
|
If count == 0:
|
||||||
|
- Auxiliary records in supplier_materials, brand_materials, material_units,
|
||||||
|
and material_attribute_values are cleaned up.
|
||||||
|
- The material row is deleted from the central materials table.
|
||||||
|
|
||||||
|
Returns the list of purged material UUIDs.
|
||||||
|
"""
|
||||||
|
purged: list[UUID] = []
|
||||||
|
unique_ids = {m for m in material_ids if m is not None}
|
||||||
|
if not unique_ids:
|
||||||
|
return purged
|
||||||
|
|
||||||
|
from app.models.catalog import (
|
||||||
|
BrandMaterial,
|
||||||
|
Material,
|
||||||
|
MaterialAttributeValue,
|
||||||
|
MaterialUnit,
|
||||||
|
)
|
||||||
|
from app.models.inventory import InventoryItem
|
||||||
|
from app.models.procurement import LineItem, SupplierMaterial
|
||||||
|
|
||||||
|
for mat_id in unique_ids:
|
||||||
|
# Check active usage in line_items across the whole system
|
||||||
|
line_item_count = await db.scalar(
|
||||||
|
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (line_item_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check active usage in warehouse inventory
|
||||||
|
inv_count = await db.scalar(
|
||||||
|
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (inv_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
|
||||||
|
await db.execute(
|
||||||
|
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(Material).where(Material.id == mat_id)
|
||||||
|
)
|
||||||
|
purged.append(mat_id)
|
||||||
|
logger.info("Purged orphan material %s from central catalog", mat_id)
|
||||||
|
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||||
|
|
||||||
Only allowed while the quote is still offered — acceptance data
|
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||||
(decisions/receipts) would otherwise be destroyed.
|
Automatically purges any materials detached during this replacement if they become orphans.
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
@@ -294,6 +387,16 @@ async def replace_quote_children(
|
|||||||
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
|
||||||
field="status",
|
field="status",
|
||||||
)
|
)
|
||||||
|
if await quote_has_receipts(db, quote.id):
|
||||||
|
raise ConflictError(
|
||||||
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
|
field="status",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
|
||||||
|
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
|
||||||
|
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
|
||||||
|
|
||||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
@@ -302,16 +405,105 @@ async def replace_quote_children(
|
|||||||
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 2. Identify detached materials: previous materials no longer present in the updated line items
|
||||||
|
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
|
||||||
|
detached_material_ids = old_material_ids - new_material_ids
|
||||||
|
|
||||||
|
# 3. Clean up any detached materials that are now orphans (0 usage)
|
||||||
|
if detached_material_ids:
|
||||||
|
await cleanup_orphan_materials(db, detached_material_ids)
|
||||||
|
|
||||||
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
|
||||||
|
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
|
||||||
|
exists = await db.scalar(
|
||||||
|
select(QuoteProjects).where(
|
||||||
|
QuoteProjects.quote_id == quote_id,
|
||||||
|
QuoteProjects.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
|
||||||
|
"""Consolidate split line items when reverting a quote back to offered.
|
||||||
|
|
||||||
|
Line items sharing the exact same material, brand_material, unit, notes,
|
||||||
|
and identical specs are merged into a single consolidated row whose quantity
|
||||||
|
is the sum of all split rows. Secondary split rows are safely deleted.
|
||||||
|
"""
|
||||||
|
line_items = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if len(line_items) <= 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load all specs for these line items to compare canonical signatures
|
||||||
|
specs_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
specs_by_li: dict[UUID, list[tuple]] = {}
|
||||||
|
for sp in specs_rows:
|
||||||
|
sig = (
|
||||||
|
str(sp.attribute_definition_id),
|
||||||
|
str(sp.value_number) if sp.value_number is not None else "",
|
||||||
|
(sp.value_string or "").strip(),
|
||||||
|
str(sp.value_boolean) if sp.value_boolean is not None else "",
|
||||||
|
str(sp.value_date) if sp.value_date is not None else "",
|
||||||
|
)
|
||||||
|
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
|
||||||
|
|
||||||
|
# Group line items by identity
|
||||||
|
groups: dict[tuple, list[LineItem]] = {}
|
||||||
|
for li in line_items:
|
||||||
|
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
|
||||||
|
key = (
|
||||||
|
li.material_id,
|
||||||
|
li.brand_material_id,
|
||||||
|
li.unit_id,
|
||||||
|
(li.notes or "").strip(),
|
||||||
|
specs_sig,
|
||||||
|
)
|
||||||
|
groups.setdefault(key, []).append(li)
|
||||||
|
|
||||||
|
for key, items in groups.items():
|
||||||
|
if len(items) > 1:
|
||||||
|
primary = items[0]
|
||||||
|
total_qty = sum(Decimal(str(it.quantity)) for it in items)
|
||||||
|
primary.quantity = total_qty
|
||||||
|
|
||||||
|
secondary_ids = [it.id for it in items[1:]]
|
||||||
|
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
|
||||||
|
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||||
current = quote.status
|
current = quote.status
|
||||||
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
|
||||||
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقالهای عقبگرد پذیرفته میشود", field="status")
|
||||||
|
|
||||||
|
if await quote_has_receipts(db, quote.id):
|
||||||
|
raise ConflictError(
|
||||||
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
|
field="status",
|
||||||
|
)
|
||||||
|
|
||||||
if new_status == "offered":
|
if new_status == "offered":
|
||||||
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
|
||||||
quote.accepted_date = None
|
quote.accepted_date = None
|
||||||
@@ -325,17 +517,20 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(ItemDecision)
|
update(ItemDecision)
|
||||||
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
|
.where(ItemDecision.line_item_id.in_(li_ids))
|
||||||
.values(
|
.values(
|
||||||
status="pending",
|
status="pending",
|
||||||
decided_at=None,
|
decided_at=None,
|
||||||
chosen_item_termsheet_id=None,
|
chosen_item_termsheet_id=None,
|
||||||
adjusted_unit_price=None,
|
adjusted_unit_price=None,
|
||||||
adjusted_notes=None,
|
adjusted_notes=None,
|
||||||
|
project_id=None,
|
||||||
)
|
)
|
||||||
.execution_options(synchronize_session=False)
|
.execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
# item_receipts are deliberately NOT deleted (contract P0-17).
|
# 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
|
elif new_status == "accepted": # arrived -> accepted
|
||||||
quote.arrived_date = None
|
quote.arrived_date = None
|
||||||
|
|
||||||
@@ -438,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
"برای قلم انتخابشده adjusted_unit_price الزامی است", field="item_decisions"
|
||||||
)
|
)
|
||||||
termsheet_id = ts_ids_by_index[index]
|
termsheet_id = ts_ids_by_index[index]
|
||||||
|
|
||||||
|
li_row = await db.scalar(
|
||||||
|
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
|
||||||
|
)
|
||||||
|
if li_row is None:
|
||||||
|
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
|
||||||
|
|
||||||
|
allocations = decision_in.allocations or []
|
||||||
|
if allocations:
|
||||||
|
# Requirement 3: Validate no duplicate projects within allocations of this item
|
||||||
|
alloc_pids = [a.project_id for a in allocations]
|
||||||
|
if len(alloc_pids) != len(set(alloc_pids)):
|
||||||
|
raise ValidationError(
|
||||||
|
"پروژه تکراری برای یک قلم کالا در ردیفهای تخصیص انتخاب شده است",
|
||||||
|
field="item_decisions.allocations",
|
||||||
|
)
|
||||||
|
# Requirement 5: Decimal precision check for sum of allocation quantities
|
||||||
|
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
|
||||||
|
target_qty = Decimal(str(li_row.quantity))
|
||||||
|
if alloc_sum != target_qty:
|
||||||
|
raise ValidationError(
|
||||||
|
f"مجموع مقادیر تخصیصیافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
|
||||||
|
field="item_decisions.allocations",
|
||||||
|
)
|
||||||
|
# Validate all projects exist
|
||||||
|
for a in allocations:
|
||||||
|
if await db.get(Project, a.project_id) is None:
|
||||||
|
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
|
||||||
|
|
||||||
|
if len(allocations) >= 2:
|
||||||
|
# 1. Update the original line item to the first allocation quantity
|
||||||
|
first_alloc = allocations[0]
|
||||||
|
li_row.quantity = first_alloc.quantity
|
||||||
|
decision_in.project_id = first_alloc.project_id
|
||||||
|
|
||||||
|
# Query all existing item_termsheets and line_item_specs for copying
|
||||||
|
orig_termsheets = (
|
||||||
|
await db.execute(
|
||||||
|
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
orig_specs = (
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
|
||||||
|
for split_alloc in allocations[1:]:
|
||||||
|
new_li = LineItem(
|
||||||
|
quote_id=quote.id,
|
||||||
|
material_id=li_row.material_id,
|
||||||
|
brand_material_id=li_row.brand_material_id,
|
||||||
|
unit_id=li_row.unit_id,
|
||||||
|
quantity=split_alloc.quantity,
|
||||||
|
notes=li_row.notes,
|
||||||
|
)
|
||||||
|
db.add(new_li)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Copy LineItemSpec
|
||||||
|
for sp in orig_specs:
|
||||||
|
db.add(
|
||||||
|
LineItemSpec(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
attribute_definition_id=sp.attribute_definition_id,
|
||||||
|
value_number=sp.value_number,
|
||||||
|
value_string=sp.value_string,
|
||||||
|
value_boolean=sp.value_boolean,
|
||||||
|
value_date=sp.value_date,
|
||||||
|
unit_id_at_time=sp.unit_id_at_time,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Requirement 1: Copy ALL payment options (ItemTermsheet)
|
||||||
|
chosen_new_it: ItemTermsheet | None = None
|
||||||
|
for it_orig in orig_termsheets:
|
||||||
|
price = (
|
||||||
|
decision_in.adjusted_unit_price
|
||||||
|
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
|
||||||
|
else it_orig.unit_price
|
||||||
|
)
|
||||||
|
copied_it = ItemTermsheet(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
termsheet_id=it_orig.termsheet_id,
|
||||||
|
unit_price=price,
|
||||||
|
min_order_qty=it_orig.min_order_qty,
|
||||||
|
)
|
||||||
|
db.add(copied_it)
|
||||||
|
if it_orig.termsheet_id == termsheet_id:
|
||||||
|
chosen_new_it = copied_it
|
||||||
|
|
||||||
|
if chosen_new_it is None:
|
||||||
|
chosen_new_it = ItemTermsheet(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
termsheet_id=termsheet_id,
|
||||||
|
unit_price=decision_in.adjusted_unit_price,
|
||||||
|
)
|
||||||
|
db.add(chosen_new_it)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Create ItemDecision for the split line item
|
||||||
|
new_decision = ItemDecision(
|
||||||
|
line_item_id=new_li.id,
|
||||||
|
status="chosen",
|
||||||
|
chosen_item_termsheet_id=chosen_new_it.id,
|
||||||
|
adjusted_unit_price=decision_in.adjusted_unit_price,
|
||||||
|
adjusted_notes=decision_in.adjusted_notes,
|
||||||
|
project_id=split_alloc.project_id,
|
||||||
|
decided_at=utcnow(),
|
||||||
|
)
|
||||||
|
db.add(new_decision)
|
||||||
|
|
||||||
|
# Requirement 4: sync QuoteProjects & LineItemProjects
|
||||||
|
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
|
||||||
|
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
|
||||||
|
elif len(allocations) == 1:
|
||||||
|
decision_in.project_id = allocations[0].project_id
|
||||||
|
|
||||||
it = await db.scalar(
|
it = await db.scalar(
|
||||||
select(ItemTermsheet).where(
|
select(ItemTermsheet).where(
|
||||||
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
ItemTermsheet.line_item_id == decision_in.line_item_id,
|
||||||
@@ -491,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
if decision.project_id is not None:
|
if decision.project_id is not None:
|
||||||
if await db.get(Project, decision.project_id) is None:
|
if await db.get(Project, decision.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
||||||
|
# Requirement 4: sync QuoteProjects
|
||||||
|
await _ensure_quote_project(db, quote.id, decision.project_id)
|
||||||
chosen_ts_ids.add(termsheet_id)
|
chosen_ts_ids.add(termsheet_id)
|
||||||
else: # rejected
|
else: # rejected
|
||||||
if decision is None:
|
if decision is None:
|
||||||
@@ -502,21 +818,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
decision.adjusted_notes = decision_in.adjusted_notes
|
decision.adjusted_notes = decision_in.adjusted_notes
|
||||||
decision.decided_at = utcnow()
|
decision.decided_at = utcnow()
|
||||||
|
|
||||||
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
|
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
|
||||||
for entry in payload.item_projects:
|
|
||||||
if entry.line_item_id not in li_ids:
|
|
||||||
raise NotFoundError(
|
|
||||||
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(LineItemProjects)
|
|
||||||
.where(LineItemProjects.line_item_id == entry.line_item_id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
for pid in entry.project_ids:
|
|
||||||
if await db.get(Project, pid) is None:
|
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
|
|
||||||
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
|
|
||||||
|
|
||||||
# termsheets referenced by at least one chosen item -> accepted
|
# termsheets referenced by at least one chosen item -> accepted
|
||||||
if chosen_ts_ids:
|
if chosen_ts_ids:
|
||||||
@@ -692,7 +994,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
|
|||||||
|
|
||||||
|
|
||||||
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
||||||
async with db.begin():
|
tx = db.begin_nested() if db.in_transaction() else db.begin()
|
||||||
|
async with tx:
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
if quote.status in ("accepted", "arrived"):
|
if quote.status in ("accepted", "arrived"):
|
||||||
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
raise ConflictError("پیشنهادهای پذیرفتهشده یا رسیده قابل حذف نیستند")
|
||||||
@@ -712,18 +1015,49 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||||
|
|
||||||
|
# collect candidate material_ids for orphan cleanup before deleting quote
|
||||||
|
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
|
||||||
|
|
||||||
|
# collect attachment keys before deleting quote row
|
||||||
|
from app.models.files import QuoteAttachment
|
||||||
|
from app.services import storage
|
||||||
|
|
||||||
|
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||||
|
att_keys = [att.object_key for att in attachments]
|
||||||
|
quote_prefix = f"{quote.id}/"
|
||||||
|
|
||||||
await db.delete(quote)
|
await db.delete(quote)
|
||||||
return {
|
await db.flush()
|
||||||
"id": str(quote.id),
|
|
||||||
"deleted": True,
|
# Purge any materials that no longer have any references in the system
|
||||||
"cascaded": {
|
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
|
||||||
"line_items": len(line_items),
|
|
||||||
"termsheets": len(termsheets),
|
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
|
||||||
"events": len(events),
|
for key in att_keys:
|
||||||
"decisions": len(decisions),
|
try:
|
||||||
"receipts": len(receipts),
|
await storage.remove_object(key)
|
||||||
},
|
except Exception:
|
||||||
}
|
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
|
||||||
|
try:
|
||||||
|
await storage.remove_prefix(quote_prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(quote_id),
|
||||||
|
"deleted": True,
|
||||||
|
"cascaded": {
|
||||||
|
"line_items": len(line_items),
|
||||||
|
"termsheets": len(termsheets),
|
||||||
|
"events": len(events),
|
||||||
|
"decisions": len(decisions),
|
||||||
|
"receipts": len(receipts),
|
||||||
|
"attachments": len(attachments),
|
||||||
|
"purged_orphan_materials": len(purged_material_ids),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -736,7 +1070,6 @@ async def quote_stats(
|
|||||||
project_id: UUID | None = None,
|
project_id: UUID | None = None,
|
||||||
quote_date_from=None,
|
quote_date_from=None,
|
||||||
quote_date_to=None,
|
quote_date_to=None,
|
||||||
category_id: UUID | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
conditions = []
|
conditions = []
|
||||||
if supplier_id is not None:
|
if supplier_id is not None:
|
||||||
@@ -747,8 +1080,6 @@ async def quote_stats(
|
|||||||
conditions.append(Quote.quote_date >= quote_date_from)
|
conditions.append(Quote.quote_date >= quote_date_from)
|
||||||
if quote_date_to is not None:
|
if quote_date_to is not None:
|
||||||
conditions.append(Quote.quote_date <= quote_date_to)
|
conditions.append(Quote.quote_date <= quote_date_to)
|
||||||
if category_id is not None:
|
|
||||||
conditions.append(_quote_has_category(category_id))
|
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -769,6 +1100,17 @@ async def quote_stats(
|
|||||||
*conditions,
|
*conditions,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Procurement funnel (cumulative, not the current-state counts above):
|
||||||
|
# a quote counts as accepted/arrived if it EVER reached that stage.
|
||||||
|
funnel_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
func.count().label("offered_total"),
|
||||||
|
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
|
||||||
|
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
|
||||||
|
).where(*conditions)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
return {
|
return {
|
||||||
"total": sum(counts.values()),
|
"total": sum(counts.values()),
|
||||||
"offered": counts["offered"],
|
"offered": counts["offered"],
|
||||||
@@ -777,41 +1119,54 @@ async def quote_stats(
|
|||||||
"rejected": counts["rejected"],
|
"rejected": counts["rejected"],
|
||||||
"expired": counts["expired"],
|
"expired": counts["expired"],
|
||||||
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
|
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
|
||||||
|
"funnel": {
|
||||||
|
"offered_total": funnel_row.offered_total,
|
||||||
|
"accepted_total": funnel_row.accepted_total,
|
||||||
|
"arrived_total": funnel_row.arrived_total,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _quote_has_category(category_id: UUID):
|
|
||||||
"""EXISTS: quote has a line item whose material belongs to category_id."""
|
|
||||||
from app.models.catalog import Material
|
|
||||||
|
|
||||||
sub = (
|
|
||||||
select(1)
|
|
||||||
.where(
|
|
||||||
LineItem.quote_id == Quote.id,
|
|
||||||
Material.id == LineItem.material_id,
|
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return func.exists(sub)
|
|
||||||
|
|
||||||
|
|
||||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||||
"""Counts + total value per quote status for one project.
|
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||||
|
|
||||||
total_value: SUM(quantity × unit_price) over line items using the chosen
|
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
|
||||||
termsheet link first, else the first link. Rejected line items are
|
least one non-rejected line item's decision targets this project
|
||||||
excluded from value sums (contract P1-14).
|
(item_decisions.project_id). A quote merely linked to the project as a
|
||||||
|
candidate (quote-level link, line-item candidates, or accepted for a
|
||||||
|
DIFFERENT project) stays in «offered» — this project never accepted it.
|
||||||
|
|
||||||
|
total_value: SUM(quantity × unit_price) over the bucket's line items,
|
||||||
|
using the chosen termsheet link first, else the first link. Rejected
|
||||||
|
line items are excluded from value sums (contract P1-14).
|
||||||
"""
|
"""
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
# the route passes the raw path string — normalize for the Python-side
|
||||||
|
# UUID comparisons below (decision.project_id == project_id etc.)
|
||||||
|
project_id = UUID(str(project_id))
|
||||||
|
|
||||||
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
|
# --- every quote RELEVANT to this project (any kind of link) ---
|
||||||
if not quotes:
|
quote_id_rows = await db.execute(
|
||||||
|
select(Quote.id)
|
||||||
|
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||||
|
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(
|
||||||
|
(Quote.project_id == project_id)
|
||||||
|
| (QuoteProjects.project_id == project_id)
|
||||||
|
| (LineItemProjects.project_id == project_id)
|
||||||
|
| (ItemDecision.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
quote_ids = [row[0] for row in quote_id_rows.all()]
|
||||||
|
if not quote_ids:
|
||||||
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
||||||
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
||||||
|
|
||||||
quote_ids = [q.id for q in quotes]
|
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
|
||||||
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
||||||
li_ids = [li.id for li in line_items]
|
li_ids = [li.id for li in line_items]
|
||||||
|
|
||||||
@@ -819,6 +1174,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
d.line_item_id: d
|
d.line_item_id: d
|
||||||
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
} if li_ids else {}
|
} if li_ids else {}
|
||||||
|
# line items explicitly linked to this project as candidates
|
||||||
|
candidate_li_ids = set(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemProjects.line_item_id).where(
|
||||||
|
LineItemProjects.line_item_id.in_(li_ids),
|
||||||
|
LineItemProjects.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
) if li_ids else set()
|
||||||
|
|
||||||
item_termsheets = (
|
item_termsheets = (
|
||||||
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
) if li_ids else []
|
) if li_ids else []
|
||||||
@@ -827,12 +1194,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
for it in item_termsheets:
|
for it in item_termsheets:
|
||||||
its_by_line.setdefault(it.line_item_id, []).append(it)
|
its_by_line.setdefault(it.line_item_id, []).append(it)
|
||||||
|
|
||||||
|
def _items_of(quote: Quote) -> list[LineItem]:
|
||||||
|
return [li for li in line_items if li.quote_id == quote.id]
|
||||||
|
|
||||||
|
def _accepted_for_project(quote: Quote) -> bool:
|
||||||
|
"""≥1 non-rejected line item whose decision targets THIS project."""
|
||||||
|
return any(
|
||||||
|
(decision := decisions.get(li.id)) is not None
|
||||||
|
and decision.status == "chosen"
|
||||||
|
and decision.project_id == project_id
|
||||||
|
for li in _items_of(quote)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_any_target(quote: Quote) -> bool:
|
||||||
|
"""Whether any decision of this quote carries a target project at all
|
||||||
|
(legacy quotes accepted before the decision-project era have none —
|
||||||
|
they keep the old global-status classification)."""
|
||||||
|
return any(
|
||||||
|
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
|
||||||
|
for li in _items_of(quote)
|
||||||
|
)
|
||||||
|
|
||||||
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
||||||
for q in quotes:
|
for q in quotes:
|
||||||
per_status[q.status]["count"] += 1
|
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
|
||||||
|
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
|
||||||
|
else:
|
||||||
|
per_status[q.status]["count"] += 1
|
||||||
|
|
||||||
for li in line_items:
|
for li in line_items:
|
||||||
|
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||||
decision = decisions.get(li.id)
|
decision = decisions.get(li.id)
|
||||||
|
|
||||||
|
# --- which status bucket does this line item's value belong to? ---
|
||||||
|
accepted_here = (
|
||||||
|
decision is not None
|
||||||
|
and decision.status == "chosen"
|
||||||
|
and decision.project_id == project_id
|
||||||
|
)
|
||||||
|
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
|
||||||
|
if accepted_here:
|
||||||
|
bucket = quote.status
|
||||||
|
elif li.id in candidate_li_ids:
|
||||||
|
# the quote is still just a proposal for this project — the
|
||||||
|
# item's candidate value stays in the offered bucket
|
||||||
|
bucket = "offered"
|
||||||
|
else:
|
||||||
|
continue # this item targets another project entirely
|
||||||
|
else:
|
||||||
|
bucket = quote.status
|
||||||
|
|
||||||
if decision is not None and decision.status == "rejected":
|
if decision is not None and decision.status == "rejected":
|
||||||
continue # rejected items excluded from value sums
|
continue # rejected items excluded from value sums
|
||||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||||
@@ -842,8 +1253,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
price_it = links[0] if links else None
|
price_it = links[0] if links else None
|
||||||
if price_it is None:
|
if price_it is None:
|
||||||
continue
|
continue
|
||||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||||
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
|
|
||||||
|
|
||||||
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
||||||
for status, values in per_status.items():
|
for status, values in per_status.items():
|
||||||
|
|||||||
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
|
|||||||
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_prefix(prefix: str) -> None:
|
||||||
|
"""Delete all objects matching a prefix in the bucket (best-effort)."""
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
def _sync_remove_prefix():
|
||||||
|
try:
|
||||||
|
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
|
||||||
|
for obj in objects:
|
||||||
|
try:
|
||||||
|
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_sync_remove_prefix)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
|
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
|
||||||
|
|
||||||
Linking a material to a supplier derives materials.category_id from the
|
Suppliers can supply any global material across categories.
|
||||||
supplier's category when the material has none (first-link-wins). The same
|
|
||||||
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
|
|
||||||
this service applies it in the application layer so the ORM state is
|
|
||||||
consistent inside the same transaction.
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
async def link_supplier_material(
|
async def link_supplier_material(
|
||||||
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
||||||
) -> SupplierMaterial:
|
) -> SupplierMaterial:
|
||||||
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
|
"""Upsert a supplier<->material link (idempotent).
|
||||||
|
|
||||||
Caller is responsible for the surrounding transaction.
|
Caller is responsible for the surrounding transaction.
|
||||||
"""
|
"""
|
||||||
@@ -42,15 +38,11 @@ async def link_supplier_material(
|
|||||||
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
||||||
db.add(link)
|
db.add(link)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# First-link-wins: only when the material has no category yet.
|
|
||||||
if material.category_id is None:
|
|
||||||
material.category_id = supplier.category_id
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
||||||
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
|
"""Remove the link."""
|
||||||
link = await db.scalar(
|
link = await db.scalar(
|
||||||
select(SupplierMaterial).where(
|
select(SupplierMaterial).where(
|
||||||
SupplierMaterial.supplier_id == supplier_id,
|
SupplierMaterial.supplier_id == supplier_id,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Audit helper — one row per sensitive mutation, written in the SAME
|
||||||
|
transaction as the change (rolled back together with it).
|
||||||
|
|
||||||
|
await record(db, actor=ctx.display_name, action="user.role_changed",
|
||||||
|
entity="user", entity_id=str(user.id),
|
||||||
|
details={"from": old_role, "to": user.role})
|
||||||
|
|
||||||
|
Never raises: an audit failure must not fail the business operation.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.audit import AuditLog
|
||||||
|
|
||||||
|
logger = logging.getLogger("procurement.audit")
|
||||||
|
|
||||||
|
|
||||||
|
async def record(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: str | None,
|
||||||
|
action: str,
|
||||||
|
entity: str,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
db.add(
|
||||||
|
AuditLog(
|
||||||
|
actor=actor,
|
||||||
|
action=action,
|
||||||
|
entity=entity,
|
||||||
|
entity_id=str(entity_id) if entity_id else None,
|
||||||
|
details=details or {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
except Exception: # noqa: BLE001 — audit must never break the caller
|
||||||
|
logger.exception("failed to write audit_log row (%s/%s)", action, entity)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""HMAC-SHA256 token verification for standalone draft review."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
def _b64url_encode(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url_decode(s: str) -> bytes:
|
||||||
|
padding = 4 - (len(s) % 4)
|
||||||
|
if padding != 4:
|
||||||
|
s += "=" * padding
|
||||||
|
return base64.urlsafe_b64decode(s.encode("ascii"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret(secret: str | None = None) -> str:
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
try:
|
||||||
|
from app.config import settings
|
||||||
|
return settings.DRAFT_TOKEN_SECRET
|
||||||
|
except Exception:
|
||||||
|
return "procurement-draft-secret-change-in-prod-v7"
|
||||||
|
|
||||||
|
|
||||||
|
def create_draft_token(
|
||||||
|
draft_id: str | UUID,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
secret: str | None = None,
|
||||||
|
expires_in: int = 7200,
|
||||||
|
) -> str:
|
||||||
|
sec = _get_secret(secret)
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"draft_id": str(draft_id),
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"user_id": str(user_id) if user_id else None,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + expires_in,
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
payload_b64 = _b64url_encode(payload_bytes)
|
||||||
|
|
||||||
|
sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
sig_b64 = _b64url_encode(sig)
|
||||||
|
|
||||||
|
return f"{payload_b64}.{sig_b64}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_draft_token(
|
||||||
|
token: str,
|
||||||
|
secret: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Verify signature and expiration of a draft token. Return payload or None."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sec = _get_secret(secret)
|
||||||
|
payload_b64, sig_b64 = token.split(".", 1)
|
||||||
|
expected_sig = hmac.new(sec.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
|
||||||
|
actual_sig = _b64url_decode(sig_b64)
|
||||||
|
|
||||||
|
if not hmac.compare_digest(expected_sig, actual_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload_bytes = _b64url_decode(payload_b64)
|
||||||
|
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||||
|
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None and int(exp) < int(time.time()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Ephemeral, URL-safe file-preview tokens.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
File downloads require an ``Authorization: Bearer`` header, but a browser
|
||||||
|
cannot attach headers to the requests made by ``<iframe src>`` / ``<img
|
||||||
|
src>`` — which is what the preview modal uses. The previous workaround
|
||||||
|
fetched the bytes with ``fetch`` and rendered a local ``blob:`` URL, but
|
||||||
|
download managers (IDM et al.) hook ``blob:`` URLs, fail to resolve them
|
||||||
|
outside the originating document, and hand the browser a 0-byte file under
|
||||||
|
a bare UUID name — so the preview broke and a junk download appeared.
|
||||||
|
|
||||||
|
Instead we mint a short-lived HMAC token scoped to a single attachment and
|
||||||
|
put it in the query string, giving the iframe a *real* URL that also
|
||||||
|
carries its own authorization. The token is not a session credential: it
|
||||||
|
authorises exactly one (quote_id, kind, attachment_id) triple and expires
|
||||||
|
in minutes.
|
||||||
|
|
||||||
|
Format: ``<expiry-unix>.<hex-hmac>`` over ``"{quote_id}:{kind}:{attachment_id}:{expiry}"``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# پیشنمایش کوتاهعمر است — فقط برای باز شدن مودال، نه اشتراکگذاری لینک
|
||||||
|
DEFAULT_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> bytes:
|
||||||
|
"""Key for the HMAC. Reuses existing secrets so no new config is needed."""
|
||||||
|
base = (
|
||||||
|
settings.CASDOOR_CLIENT_SECRET
|
||||||
|
or settings.MINIO_SECRET_KEY
|
||||||
|
or "procurement-preview-fallback"
|
||||||
|
)
|
||||||
|
return base.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, expiry: int) -> str:
|
||||||
|
msg = f"{quote_id}:{kind}:{attachment_id}:{expiry}".encode("utf-8")
|
||||||
|
return hmac.new(_secret(), msg, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def make_preview_token(
|
||||||
|
quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID, ttl: int = DEFAULT_TTL_SECONDS
|
||||||
|
) -> str:
|
||||||
|
expiry = int(time.time()) + ttl
|
||||||
|
return f"{expiry}.{_sign(quote_id, kind, attachment_id, expiry)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_preview_token(
|
||||||
|
token: str, quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID
|
||||||
|
) -> bool:
|
||||||
|
"""True when the token is well-formed, unexpired and matches this file."""
|
||||||
|
if not token or "." not in token:
|
||||||
|
return False
|
||||||
|
expiry_raw, signature = token.split(".", 1)
|
||||||
|
try:
|
||||||
|
expiry = int(expiry_raw)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if expiry < int(time.time()):
|
||||||
|
return False
|
||||||
|
expected = _sign(quote_id, kind, attachment_id, expiry)
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Shared rate limiter (slowapi) — in-memory, single-worker friendly.
|
||||||
|
|
||||||
|
Login brute-force brake: the edge (Traefik) has a coarse per-IP limit for
|
||||||
|
the whole API host; this adds a tight limit on the credential endpoint
|
||||||
|
itself. Kept in its own module so routers can import it without a circular
|
||||||
|
import on app.main.
|
||||||
|
"""
|
||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -1,14 +1,33 @@
|
|||||||
"""Placeholder auth: no middleware logic, only a header read for `actor` attribution."""
|
"""Attribution for quote_events.actor — the VERIFIED user, not the raw token.
|
||||||
|
|
||||||
|
Historically this stored the bearer token string verbatim (unverifiable and
|
||||||
|
a token leak into the database). All routes that use it now sit behind the
|
||||||
|
global JWT dependency, so the token is resolved to the authenticated user
|
||||||
|
and the display name is recorded instead.
|
||||||
|
"""
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.auth import get_optional_user
|
||||||
|
|
||||||
def get_actor(request: Request) -> str | None:
|
|
||||||
"""Extract the bearer token (if any) to attribute quote_events.actor.
|
|
||||||
|
|
||||||
This is the reserved placeholder from the contract — no authentication
|
async def get_actor(request: Request) -> str | None:
|
||||||
or authorization is enforced yet.
|
"""Resolve the caller's display name from the Casdoor Bearer token.
|
||||||
|
|
||||||
|
Uses its own session (get_optional_user default) — same reason as
|
||||||
|
get_current_user: quote routers open explicit db.begin() blocks.
|
||||||
"""
|
"""
|
||||||
auth = request.headers.get("Authorization")
|
ctx = await get_optional_user(request)
|
||||||
if auth and auth.lower().startswith("bearer "):
|
if ctx is not None:
|
||||||
return auth[7:].strip() or None
|
return ctx.display_name
|
||||||
return None
|
# Dev-mode header fallback (only active when DEV_LOGIN_ENABLED) carries
|
||||||
|
# the name in X-User-Name / X-User-Name-B64 — keep attribution useful.
|
||||||
|
name = request.headers.get("x-user-name")
|
||||||
|
import base64
|
||||||
|
|
||||||
|
b64 = request.headers.get("x-user-name-b64")
|
||||||
|
if b64:
|
||||||
|
try:
|
||||||
|
name = base64.b64decode(b64.encode()).decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return name
|
||||||
|
|||||||
@@ -10,3 +10,13 @@ minio>=7.2.0
|
|||||||
# v2.6 — Casdoor JWT auth
|
# v2.6 — Casdoor JWT auth
|
||||||
pyjwt[crypto]>=2.8.0
|
pyjwt[crypto]>=2.8.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
|
||||||
|
# Casdoor seeding — bcrypt password hashes (never plaintext)
|
||||||
|
bcrypt>=4.1.0
|
||||||
|
|
||||||
|
# Observability — metrics + Sentry/GlitchTip exception capture
|
||||||
|
prometheus-fastapi-instrumentator>=7.0.0
|
||||||
|
sentry-sdk[fastapi]>=2.0.0
|
||||||
|
|
||||||
|
# Rate limiting (login brute-force brake; edge limit lives in Traefik)
|
||||||
|
slowapi>=0.1.9
|
||||||
|
|||||||
+87
-3
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
|
|||||||
|
|
||||||
USER_PLAN = [
|
USER_PLAN = [
|
||||||
# (casdoor username, first, last, role)
|
# (casdoor username, first, last, role)
|
||||||
|
("ali.mohaghegh", "علی", "محقق", "admin"),
|
||||||
("razaei", "علی", "رضایی", "admin"),
|
("razaei", "علی", "رضایی", "admin"),
|
||||||
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
("mousavi", "حسین", "موسوی", "procurement_manager"),
|
||||||
("mohaghegh", "محمد", "محقق", "pm"),
|
("mohaghegh", "محمد", "محقق", "pm"),
|
||||||
@@ -209,7 +210,7 @@ async def run() -> None:
|
|||||||
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
|
"item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, "
|
||||||
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
|
"line_items, quotes, supplier_materials, suppliers, brand_materials, "
|
||||||
"material_attribute_values, materials, brands, warehouses, projects, users, "
|
"material_attribute_values, materials, brands, warehouses, projects, users, "
|
||||||
"category_reference_units, unit_conversions, standard_conversions "
|
"standard_conversions "
|
||||||
"RESTART IDENTITY CASCADE"
|
"RESTART IDENTITY CASCADE"
|
||||||
))
|
))
|
||||||
# Only the 8 canonical topic categories may exist — any leftover slug
|
# Only the 8 canonical topic categories may exist — any leftover slug
|
||||||
@@ -263,8 +264,9 @@ async def run() -> None:
|
|||||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||||
cat = await get_category(db, cat_slug)
|
cat = await get_category(db, cat_slug)
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=cat.id,
|
|
||||||
name=name,
|
name=name,
|
||||||
|
topic=cat.name,
|
||||||
|
category_id=cat.id, # v2.8: dashboard Category→Material cascade
|
||||||
canonical_unit_id=unit_ids[unit_code],
|
canonical_unit_id=unit_ids[unit_code],
|
||||||
status="active",
|
status="active",
|
||||||
)
|
)
|
||||||
@@ -289,8 +291,10 @@ async def run() -> None:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
suppliers.append(sup)
|
suppliers.append(sup)
|
||||||
# last-quoted prices: link each supplier to 2 materials of its category
|
# last-quoted prices: link each supplier to 2 materials of its category
|
||||||
|
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
|
||||||
for sup in suppliers:
|
for sup in suppliers:
|
||||||
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
|
target_slug = sup_cat_slug.get(sup.name)
|
||||||
|
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
|
||||||
for m in cat_materials[:2]:
|
for m in cat_materials[:2]:
|
||||||
db.add(SupplierMaterial(
|
db.add(SupplierMaterial(
|
||||||
supplier_id=sup.id, material_id=m.id, is_active=True,
|
supplier_id=sup.id, material_id=m.id, is_active=True,
|
||||||
@@ -440,6 +444,86 @@ async def run() -> None:
|
|||||||
))
|
))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
# ---- MVP dashboard demo ledger -----------------------------------
|
||||||
|
# Multi-day history so every widget has real data to show:
|
||||||
|
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
|
||||||
|
# - withdrawals/returns/waste over the last ~12 days → flow series
|
||||||
|
# - tickets dated TODAY (performed_by names) → daily control
|
||||||
|
# Quantities are hand-checked to keep every row >= 0.
|
||||||
|
|
||||||
|
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
|
||||||
|
mat = material_by_name[MATERIALS[mat_idx][0]]
|
||||||
|
wh = warehouse_rows[project_idx][wh_idx]
|
||||||
|
inv = await db.scalar(select(InventoryItem).where(
|
||||||
|
InventoryItem.warehouse_id == wh.id,
|
||||||
|
InventoryItem.material_id == mat.id,
|
||||||
|
InventoryItem.specs_hash == "default",
|
||||||
|
))
|
||||||
|
if inv is None:
|
||||||
|
inv = InventoryItem(
|
||||||
|
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
|
||||||
|
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
|
||||||
|
specs={}, specs_hash="default",
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
await db.flush()
|
||||||
|
return inv
|
||||||
|
|
||||||
|
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
|
||||||
|
days_ago: int, performed_by: str, note: str | None = None) -> None:
|
||||||
|
inv.quantity += Decimal(str(qty))
|
||||||
|
db.add(InventoryTransaction(
|
||||||
|
inventory_id=inv.id,
|
||||||
|
warehouse_id=wh.id,
|
||||||
|
transaction_type=tx_type,
|
||||||
|
quantity=Decimal(str(qty)),
|
||||||
|
performed_by=performed_by,
|
||||||
|
note=note or "تاریخچه دمو داشبورد",
|
||||||
|
transaction_date=d(days_ago),
|
||||||
|
))
|
||||||
|
|
||||||
|
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
|
||||||
|
|
||||||
|
# dead stock: cable + switches bought long ago, untouched since
|
||||||
|
cable = await ensure_inv(0, 0, 0)
|
||||||
|
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
|
||||||
|
keys = await ensure_inv(1, 0, 0)
|
||||||
|
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
|
||||||
|
|
||||||
|
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
|
||||||
|
cement = await ensure_inv(4, 2, 0)
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
|
||||||
|
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
|
||||||
|
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
|
||||||
|
|
||||||
|
blocks = await ensure_inv(5, 2, 1)
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
|
||||||
|
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
|
||||||
|
|
||||||
|
pe16_a = await ensure_inv(6, 1, 0)
|
||||||
|
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
|
||||||
|
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
|
||||||
|
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
|
||||||
|
|
||||||
|
pe16_b = await ensure_inv(6, 1, 1)
|
||||||
|
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
|
||||||
|
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
|
||||||
|
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
|
||||||
|
|
||||||
|
pe25 = await ensure_inv(7, 1, 0)
|
||||||
|
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
|
||||||
|
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
|
||||||
|
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# summary
|
# summary
|
||||||
|
|||||||
@@ -20,22 +20,34 @@ import json
|
|||||||
import os
|
import os
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
|
BASE = os.environ.get("CASDOOR_BASE", "http://localhost:8080")
|
||||||
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
|
ADMIN = os.environ.get("CASDOOR_ADMIN", "admin")
|
||||||
|
# Casdoor's own bootstrap default for the built-in console admin is "123" —
|
||||||
|
# the seeder LOGS IN with it (it cannot change the console password).
|
||||||
|
# Override with CASDOOR_ADMIN_PW after rotating (docs/SECRET-MANAGEMENT.md);
|
||||||
|
# production MUST set a real value here once the console password is rotated.
|
||||||
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123")
|
ADMIN_PW = os.environ.get("CASDOOR_ADMIN_PW", "123")
|
||||||
|
|
||||||
ORG = "inventory"
|
ORG = "inventory"
|
||||||
APP = "inventory-app"
|
APP = "inventory-app"
|
||||||
CLIENT_ID = "inventory-app-client"
|
CLIENT_ID = "inventory-app-client"
|
||||||
CLIENT_SECRET = "inventory-app-secret-2026"
|
# Must MATCH the API's PROCUREMENT_CASDOOR_CLIENT_SECRET (compose default
|
||||||
|
# below) — changing one without the other breaks POST /auth/login.
|
||||||
|
CLIENT_SECRET = os.environ.get("CASDOOR_CLIENT_SECRET", "inventory-app-secret-2026")
|
||||||
REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow
|
REDIRECT_URI = "http://localhost/login" # Traefik edge (v1.3); unused by the in-page flow
|
||||||
|
|
||||||
|
# One password for all seeded dev users — override with CASDOOR_SEED_PASSWORD;
|
||||||
|
# stored as a bcrypt hash (passwordType "bcrypt"), never plaintext.
|
||||||
|
SEED_PASSWORD = os.environ.get("CASDOOR_SEED_PASSWORD", "123456")
|
||||||
|
|
||||||
USERS = [
|
USERS = [
|
||||||
# (name, first_name, last_name, email, password)
|
# (name, first_name, last_name, email)
|
||||||
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"),
|
("razaei", "آقای", "رضایی", "razaei@sample.ir"),
|
||||||
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"),
|
("mousavi", "آقای", "موسوی", "mousavi@sample.ir"),
|
||||||
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"),
|
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"),
|
||||||
("tester", "کاربر", "تست", "tester@sample.ir", "123456"),
|
("tester", "کاربر", "تست", "tester@sample.ir"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -69,17 +81,21 @@ def _login():
|
|||||||
return jar
|
return jar
|
||||||
|
|
||||||
|
|
||||||
|
def _bcrypt(pw: str) -> str:
|
||||||
|
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
cookies = _login()
|
cookies = _login()
|
||||||
if cookies is None:
|
if cookies is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1) organization (passwordType "plain" = dev: passwords stored as-is)
|
# 1) organization (bcrypt — passwords are never stored plaintext)
|
||||||
status, body = _request("POST", "/api/add-organization", {
|
status, body = _request("POST", "/api/add-organization", {
|
||||||
"owner": "admin",
|
"owner": "admin",
|
||||||
"name": ORG,
|
"name": ORG,
|
||||||
"displayName": "سازمان تدارکات",
|
"displayName": "سازمان تدارکات",
|
||||||
"passwordType": "plain",
|
"passwordType": "bcrypt",
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print("add-organization:", status, body.get("msg", body.get("error", "")))
|
print("add-organization:", status, body.get("msg", body.get("error", "")))
|
||||||
|
|
||||||
@@ -102,7 +118,8 @@ def main():
|
|||||||
print("add-application:", status, body.get("msg", body.get("error", "")))
|
print("add-application:", status, body.get("msg", body.get("error", "")))
|
||||||
|
|
||||||
# 3) users (roles are NOT stored here — they live in the app's users table)
|
# 3) users (roles are NOT stored here — they live in the app's users table)
|
||||||
for name, first, last, email, pw in USERS:
|
pw_hash = _bcrypt(SEED_PASSWORD)
|
||||||
|
for name, first, last, email in USERS:
|
||||||
status, body = _request("POST", "/api/add-user", {
|
status, body = _request("POST", "/api/add-user", {
|
||||||
"owner": ORG,
|
"owner": ORG,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -110,21 +127,23 @@ def main():
|
|||||||
"firstName": first,
|
"firstName": first,
|
||||||
"lastName": last,
|
"lastName": last,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": pw,
|
"password": pw_hash,
|
||||||
"passwordType": "plain",
|
"passwordType": "bcrypt",
|
||||||
"phone": "",
|
"phone": "",
|
||||||
"type": "normal-user",
|
"type": "normal-user",
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
|
print(f"add-user {name}:", status, body.get("msg", body.get("error", "")))
|
||||||
# Ensure passwordType even for already-existing users (this Casdoor
|
# Ensure passwordType even for already-existing users (this Casdoor
|
||||||
# version refuses login when both user and org passwordType are empty).
|
# version refuses login when both user and org passwordType are empty).
|
||||||
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", {
|
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type,password", {
|
||||||
"passwordType": "plain",
|
"passwordType": "bcrypt",
|
||||||
|
"password": pw_hash,
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
|
print(f"set-password-type {name}:", status, body.get("msg", body.get("error", "")))
|
||||||
|
|
||||||
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
|
print("\nDone. Casdoor console: http://localhost:8080")
|
||||||
print("Login in the app: http://localhost:3000/login")
|
print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)")
|
||||||
|
print("Login in the app: http://localhost/login")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+145
-61
@@ -1,9 +1,14 @@
|
|||||||
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
|
"""End-to-end smoke tests against a running server (http://127.0.0.1:8000).
|
||||||
|
|
||||||
Run: .venv/Scripts/python.exe tools/smoke_test.py
|
Dataset-independent: assertions verify BEHAVIOR (status codes, envelopes,
|
||||||
|
shapes, filtering) rather than exact seed counts, so the suite works with
|
||||||
|
any seed version. Run from the api container or a host with access:
|
||||||
|
|
||||||
|
Run: python tools/smoke_test.py
|
||||||
|
Env: SMOKE_USER / SMOKE_PASSWORD (default tester / 123456 — seeded users)
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -23,84 +28,150 @@ def check(name, ok, extra=""):
|
|||||||
|
|
||||||
c = httpx.Client(base_url=BASE, timeout=30)
|
c = httpx.Client(base_url=BASE, timeout=30)
|
||||||
|
|
||||||
|
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
|
||||||
|
r = c.post("/auth/login", json={
|
||||||
|
"username": os.environ.get("SMOKE_USER", "tester"),
|
||||||
|
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
|
||||||
|
})
|
||||||
|
check("POST /auth/login", r.status_code == 200, r.text[:200])
|
||||||
|
if r.status_code == 200:
|
||||||
|
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
|
||||||
|
|
||||||
# ---------- catalog ----------
|
# ---------- catalog ----------
|
||||||
r = c.get("/units", params={"page_size": 5})
|
r = c.get("/units", params={"page_size": 5})
|
||||||
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
|
units = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
check("GET /units", r.status_code == 200 and len(units) == 5 and "si_factor" in units[0])
|
||||||
|
|
||||||
r = c.get("/units", params={"dimension": "mass"})
|
if units:
|
||||||
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
dim = units[0]["dimension"]
|
||||||
|
r = c.get("/units", params={"dimension": dim, "page_size": 100})
|
||||||
|
body = r.json()
|
||||||
|
check(
|
||||||
|
"GET /units?dimension filter",
|
||||||
|
r.status_code == 200 and body["meta"]["total"] >= 1
|
||||||
|
and all(u["dimension"] == dim for u in body["data"]),
|
||||||
|
r.text[:200],
|
||||||
|
)
|
||||||
|
|
||||||
r = c.get("/brands")
|
r = c.get("/brands")
|
||||||
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
|
brands = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
check("GET /brands", r.status_code == 200 and brands and r.json()["meta"]["total"] >= 1)
|
||||||
|
|
||||||
r = c.get("/material-categories", params={"q": "سیمان"})
|
# category q-search (only when categories exist)
|
||||||
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
r = c.get("/material-categories", params={"page_size": 100})
|
||||||
|
cats = r.json()["data"] if r.status_code == 200 else []
|
||||||
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
|
check("GET /material-categories", r.status_code == 200 and isinstance(cats, list))
|
||||||
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
if cats:
|
||||||
|
needle = cats[0]["name"][:3]
|
||||||
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
|
r = c.get("/material-categories", params={"q": needle})
|
||||||
if r.status_code == 200 and r.json()["data"]:
|
|
||||||
m = r.json()["data"][0]
|
|
||||||
check(
|
check(
|
||||||
"GET /materials expand",
|
"GET /material-categories?q (unicode search)",
|
||||||
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
|
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
|
||||||
)
|
)
|
||||||
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
|
|
||||||
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
|
# materials: q-search against a REAL seeded name (fetched, not hard-coded)
|
||||||
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
|
r = c.get("/materials", params={"page_size": 100})
|
||||||
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
|
materials = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
check("GET /materials", r.status_code == 200 and materials and r.json()["meta"]["total"] >= 1)
|
||||||
|
|
||||||
|
if materials:
|
||||||
|
target = materials[0]
|
||||||
|
needle = target["name"].split()[0]
|
||||||
|
r = c.get("/materials", params={"q": needle})
|
||||||
|
check(
|
||||||
|
"GET /materials?q (unicode search)",
|
||||||
|
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": needle})
|
||||||
|
expanded = [m for m in r.json().get("data", []) if m["id"] == target["id"]]
|
||||||
|
if r.status_code == 200 and expanded:
|
||||||
|
m = expanded[0]
|
||||||
|
check(
|
||||||
|
"GET /materials expand",
|
||||||
|
"category" in m and "brands" in m and "canonical_unit" in m and "attributes" in m,
|
||||||
|
)
|
||||||
|
check("expand category shape", m["category"].keys() == {"id", "slug", "name"})
|
||||||
|
check("expand brands shape", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
|
||||||
|
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
|
||||||
|
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
|
||||||
|
else:
|
||||||
|
check("GET /materials expand", False, r.text[:300])
|
||||||
|
|
||||||
|
# ---------- EAV filters (positive filter only when the seed has values) ----------
|
||||||
|
r = c.get("/attribute-definitions", params={"page_size": 100})
|
||||||
|
attr_defs = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
|
||||||
|
if attr_defs and materials:
|
||||||
|
r = c.get("/materials", params={"expand": "attributes", "page_size": 100})
|
||||||
|
with_attrs = [m for m in r.json().get("data", []) if m.get("attributes")]
|
||||||
|
if with_attrs:
|
||||||
|
m0 = with_attrs[0]
|
||||||
|
key = m0["attributes"][0]["attribute_key"]
|
||||||
|
value = str(m0["attributes"][0]["value"])
|
||||||
|
r = c.get("/materials", params={f"attr_{key}": value, "expand": "attributes"})
|
||||||
|
check(
|
||||||
|
f"GET /materials?attr_{key} filter",
|
||||||
|
r.status_code == 200 and any(x["id"] == m0["id"] for x in r.json().get("data", [])),
|
||||||
|
r.text[:200],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" SKIP EAV positive filter — seed defines no material attribute values")
|
||||||
else:
|
else:
|
||||||
check("GET /materials expand", False, r.text[:300])
|
print(" SKIP EAV positive filter — seed defines no attribute definitions")
|
||||||
|
|
||||||
# dynamic EAV filter: attr_rebar_diameter=14
|
# unknown attr filter -> 400 (dataset-independent)
|
||||||
r = c.get("/materials", params={"attr_rebar_diameter": "14", "expand": "attributes"})
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()["data"]
|
|
||||||
ok = len(data) == 1 and data[0]["name"] == "میلگرد A3 سایز ۱۴"
|
|
||||||
check("GET /materials?attr_rebar_diameter=14", ok, r.text[:200])
|
|
||||||
else:
|
|
||||||
check("GET /materials?attr_rebar_diameter=14", False, r.text[:200])
|
|
||||||
|
|
||||||
# EAV boolean filter: attr_rebar_ribbed=true
|
|
||||||
r = c.get("/materials", params={"attr_rebar_ribbed": "true"})
|
|
||||||
check("GET /materials?attr_rebar_ribbed=true", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
|
||||||
|
|
||||||
# brand filter
|
|
||||||
r = c.get("/brands", params={"q": "فولاد مبارکه"})
|
|
||||||
brand_id = r.json()["data"][0]["id"]
|
|
||||||
r = c.get("/materials", params={"brand_id": brand_id})
|
|
||||||
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] == 3, r.text[:200])
|
|
||||||
|
|
||||||
# unknown attr filter -> 400
|
|
||||||
r = c.get("/materials", params={"attr_nonexistent": "1"})
|
r = c.get("/materials", params={"attr_nonexistent": "1"})
|
||||||
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
|
check("unknown attr filter -> 400", r.status_code == 400 and r.json()["error"]["code"] == "VALIDATION_ERROR")
|
||||||
|
|
||||||
r = c.get("/materials?page_size=999")
|
r = c.get("/materials?page_size=999")
|
||||||
check("page_size>100 -> 400", r.status_code == 400)
|
check("page_size>100 -> 400", r.status_code == 400)
|
||||||
|
|
||||||
|
# brand filter via a brand that actually has material links
|
||||||
|
r = c.get("/brand-materials", params={"page_size": 100})
|
||||||
|
brand_links = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
if brand_links:
|
||||||
|
brand_id = brand_links[0]["brand_id"]
|
||||||
|
r = c.get("/materials", params={"brand_id": brand_id})
|
||||||
|
check("GET /materials?brand_id=", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
|
||||||
|
r = c.get("/brand-materials", params={"brand_id": brand_id})
|
||||||
|
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200])
|
||||||
|
else:
|
||||||
|
check("GET /materials?brand_id=", False, "seed has no brand-material links")
|
||||||
|
|
||||||
|
# ---------- suppliers / projects ----------
|
||||||
r = c.get("/suppliers")
|
r = c.get("/suppliers")
|
||||||
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
|
suppliers = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
check("GET /suppliers", r.status_code == 200 and suppliers)
|
||||||
|
|
||||||
r = c.get("/projects")
|
r = c.get("/projects")
|
||||||
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
|
projects = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
check("GET /projects", r.status_code == 200 and projects)
|
||||||
|
|
||||||
r = c.get("/attribute-definitions", params={"expand": "unit"})
|
r = c.get("/attribute-definitions")
|
||||||
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
|
attr_shape_ok = r.status_code == 200 and all(
|
||||||
|
set(a) >= {"attribute_key", "label", "data_type"} for a in r.json().get("data", [])
|
||||||
|
)
|
||||||
|
check("GET /attribute-definitions", attr_shape_ok, r.text[:200])
|
||||||
|
|
||||||
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
|
if cats:
|
||||||
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
|
r = c.get(f"/material-categories/{cats[0]['id']}/attributes")
|
||||||
|
check(
|
||||||
r = c.get("/brand-materials", params={"brand_id": brand_id})
|
"GET category attributes (nested)",
|
||||||
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
r.status_code == 200 and isinstance(r.json()["data"], list)
|
||||||
|
and all("attribute" in a for a in r.json()["data"]),
|
||||||
|
r.text[:200],
|
||||||
|
)
|
||||||
|
|
||||||
# ---------- quotes ----------
|
# ---------- quotes ----------
|
||||||
r = c.get("/quotes", params={"page_size": 5})
|
r = c.get("/quotes", params={"page_size": 5})
|
||||||
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
|
quotes = r.json()["data"] if r.status_code == 200 else []
|
||||||
|
check("GET /quotes", r.status_code == 200 and quotes and r.json()["meta"]["total"] >= 1)
|
||||||
|
|
||||||
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
|
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project"})
|
||||||
if r.status_code == 200 and r.json()["data"]:
|
accepted = [q for q in r.json().get("data", []) if q.get("line_items")] if r.status_code == 200 else []
|
||||||
q = r.json()["data"][0]
|
if accepted:
|
||||||
|
q = accepted[0]
|
||||||
li = q["line_items"][0]
|
li = q["line_items"][0]
|
||||||
check(
|
check(
|
||||||
"GET /quotes expand full shape",
|
"GET /quotes expand full shape",
|
||||||
@@ -111,21 +182,34 @@ if r.status_code == 200 and r.json()["data"]:
|
|||||||
r.text[:400],
|
r.text[:400],
|
||||||
)
|
)
|
||||||
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
|
check("events sorted ASC", [e["event_type"] for e in q["events"]] == sorted([e["event_type"] for e in q["events"]]))
|
||||||
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
|
if q["termsheets"]:
|
||||||
|
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
|
||||||
else:
|
else:
|
||||||
check("GET /quotes expand full shape", False, r.text[:400])
|
check("GET /quotes expand full shape", False, r.text[:400])
|
||||||
|
|
||||||
r = c.get("/quotes/stats")
|
r = c.get("/quotes/stats")
|
||||||
check("GET /quotes/stats", r.status_code == 200 and r.json()["data"]["total"] == 7 and "average_lead_time_days" in r.json()["data"], r.text[:300])
|
stats_ok = r.status_code == 200 and "average_lead_time_days" in r.json()["data"]
|
||||||
|
if quotes:
|
||||||
|
stats_ok = stats_ok and r.json()["data"]["total"] == r.json()["data"]["offered"] + \
|
||||||
|
r.json()["data"]["accepted"] + r.json()["data"]["arrived"] + \
|
||||||
|
r.json()["data"]["rejected"] + r.json()["data"]["expired"]
|
||||||
|
check("GET /quotes/stats", stats_ok, r.text[:300])
|
||||||
|
|
||||||
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
|
r = c.get("/quotes/stats", params={"status": "offered"}) # status filter not part of stats contract; ensure no crash
|
||||||
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
|
check("GET /quotes/stats (extra param ignored)", r.status_code == 200)
|
||||||
|
|
||||||
r = c.get("/quotes", params={"q": "میلگرد"})
|
if materials:
|
||||||
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
|
needle = materials[0]["name"].split()[0]
|
||||||
|
r = c.get("/quotes", params={"q": needle})
|
||||||
|
check("GET /quotes?q (searches material names)", r.status_code == 200, r.text[:300])
|
||||||
|
|
||||||
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
|
if projects:
|
||||||
check("GET /projects/{id}/stats", r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"}, r.text[:300])
|
r = c.get(f"/projects/{projects[0]['id']}/stats")
|
||||||
|
check(
|
||||||
|
"GET /projects/{id}/stats",
|
||||||
|
r.status_code == 200 and set(r.json()["data"]) >= {"total_quotes", "offered", "accepted", "arrived", "rejected", "expired"},
|
||||||
|
r.text[:300],
|
||||||
|
)
|
||||||
|
|
||||||
print(f"\n===== {PASS} passed, {FAIL} failed =====")
|
print(f"\n===== {PASS} passed, {FAIL} failed =====")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
|
|||||||
@@ -19,9 +19,20 @@ def check(name, ok, extra=""):
|
|||||||
|
|
||||||
c = httpx.Client(base_url=BASE, timeout=30)
|
c = httpx.Client(base_url=BASE, timeout=30)
|
||||||
|
|
||||||
|
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
|
||||||
|
import os
|
||||||
|
|
||||||
|
r = c.post("/auth/login", json={
|
||||||
|
"username": os.environ.get("SMOKE_USER", "tester"),
|
||||||
|
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
|
||||||
|
})
|
||||||
|
check("POST /auth/login", r.status_code == 200, r.text[:200])
|
||||||
|
if r.status_code == 200:
|
||||||
|
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
|
||||||
|
|
||||||
# ---------- units ----------
|
# ---------- units ----------
|
||||||
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
|
r = c.post("/units", json={"code": "test-unit", "name_fa": "واحد تست", "dimension": "count", "si_factor": 1.0})
|
||||||
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, r.text[:200])
|
check("POST /units", r.status_code == 201 and r.json()["data"]["si_factor"] == 1.0, r.text[:200])
|
||||||
uid = r.json()["data"]["id"]
|
uid = r.json()["data"]["id"]
|
||||||
r = c.post("/units", json={"code": "test-unit", "si_factor": 1.0})
|
r = c.post("/units", json={"code": "test-unit", "si_factor": 1.0})
|
||||||
check("POST /units duplicate code -> 409", r.status_code == 409, r.text[:200])
|
check("POST /units duplicate code -> 409", r.status_code == 409, r.text[:200])
|
||||||
@@ -42,21 +53,28 @@ r = c.patch(f"/brands/{bid}", json={"name": "برند تست ۲"})
|
|||||||
check("PATCH /brands", r.status_code == 200 and r.json()["data"]["name"] == "برند تست ۲")
|
check("PATCH /brands", r.status_code == 200 and r.json()["data"]["name"] == "برند تست ۲")
|
||||||
r = c.delete(f"/brands/{bid}")
|
r = c.delete(f"/brands/{bid}")
|
||||||
check("DELETE /brands (unused)", r.status_code == 200)
|
check("DELETE /brands (unused)", r.status_code == 200)
|
||||||
r = c.get("/brands", params={"q": "فولاد مبارکه"})
|
_bml = c.get("/brand-materials", params={"page_size": 1}).json()["data"]
|
||||||
brand = r.json()["data"][0]
|
if _bml:
|
||||||
|
brand = {"id": _bml[0]["brand_id"]}
|
||||||
|
else:
|
||||||
|
brand = c.get("/brands", params={"page_size": 1}).json()["data"][0]
|
||||||
r = c.delete(f"/brands/{brand['id']}")
|
r = c.delete(f"/brands/{brand['id']}")
|
||||||
check("DELETE /brands with associations -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE /brands with associations -> 409", r.status_code == 409, r.text[:200])
|
||||||
|
|
||||||
# ---------- categories ----------
|
# ---------- categories ----------
|
||||||
r = c.post("/material-categories", json={"slug": "test-cat", "name": "دسته تست"})
|
r = c.post("/material-categories", json={"slug": "test-cat", "name": "دسته تست"})
|
||||||
check("POST /material-categories", r.status_code == 201, r.text[:200])
|
if r.status_code == 409: # rerun safety — fetch the existing test category
|
||||||
cid = r.json()["data"]["id"]
|
cid = c.get("/material-categories", params={"q": "test-cat"}).json()["data"][0]["id"]
|
||||||
|
check("POST /material-categories (exists from previous run)", True)
|
||||||
|
else:
|
||||||
|
check("POST /material-categories", r.status_code == 201, r.text[:200])
|
||||||
|
cid = r.json()["data"]["id"]
|
||||||
r = c.patch(f"/material-categories/{cid}", json={"name": "دسته تست ۲", "is_active": False})
|
r = c.patch(f"/material-categories/{cid}", json={"name": "دسته تست ۲", "is_active": False})
|
||||||
check("PATCH /material-categories", r.status_code == 200 and r.json()["data"]["is_active"] is False)
|
check("PATCH /material-categories", r.status_code == 200 and r.json()["data"]["is_active"] is False)
|
||||||
r = c.delete(f"/material-categories/{cid}")
|
r = c.delete(f"/material-categories/{cid}")
|
||||||
check("DELETE /material-categories (empty)", r.status_code == 200)
|
check("DELETE /material-categories (empty)", r.status_code == 200)
|
||||||
r = c.get("/material-categories", params={"q": "میلگرد"})
|
_mat0 = c.get("/materials", params={"page_size": 1, "expand": "category"}).json()["data"][0]
|
||||||
cat = r.json()["data"][0]
|
cat = _mat0["category"]
|
||||||
r = c.delete(f"/material-categories/{cat['id']}")
|
r = c.delete(f"/material-categories/{cat['id']}")
|
||||||
check("DELETE /material-categories with materials -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE /material-categories with materials -> 409", r.status_code == 409, r.text[:200])
|
||||||
|
|
||||||
@@ -75,11 +93,16 @@ r = c.patch(f"/attribute-definitions/{adid}", json={"data_type": "number"})
|
|||||||
check("PATCH data_type with no values -> OK", r.status_code == 200 and r.json()["data"]["data_type"] == "number", r.text[:200])
|
check("PATCH data_type with no values -> OK", r.status_code == 200 and r.json()["data"]["data_type"] == "number", r.text[:200])
|
||||||
|
|
||||||
# use a DEDICATED test material so the seed material's EAV values stay intact
|
# use a DEDICATED test material so the seed material's EAV values stay intact
|
||||||
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
|
# (dataset-independent: any category; rerun reuses the existing test material)
|
||||||
|
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
|
||||||
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
|
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
|
||||||
r = c.post("/materials", json={"category_id": cat["id"], "name": "متریال تست EAV", "canonical_unit_id": unit_kg})
|
r = c.post("/materials", json={"category_id": cat["id"], "name": "متریال تست EAV", "canonical_unit_id": unit_kg})
|
||||||
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
|
if r.status_code == 409: # rerun
|
||||||
tmat = r.json()["data"]
|
tmat = c.get("/materials", params={"q": "متریال تست EAV"}).json()["data"][0]
|
||||||
|
check("create test material for EAV tests (exists from previous run)", True)
|
||||||
|
else:
|
||||||
|
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
|
||||||
|
tmat = r.json()["data"]
|
||||||
mat = tmat
|
mat = tmat
|
||||||
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": [
|
r = c.put(f"/materials/{mat['id']}/attribute-values", json={"attributes": [
|
||||||
{"attribute_definition_id": adid, "value": 5},
|
{"attribute_definition_id": adid, "value": 5},
|
||||||
@@ -99,7 +122,11 @@ check("DELETE attribute-definition (no values) -> 200", r.status_code == 200, r.
|
|||||||
# ---------- category attributes ----------
|
# ---------- category attributes ----------
|
||||||
# use a FRESH definition so the pair is not already in the seed junction
|
# use a FRESH definition so the pair is not already in the seed junction
|
||||||
r = c.post("/attribute-definitions", json={"attribute_key": "test_ca_attr", "label": "ویژگی دسته", "data_type": "string"})
|
r = c.post("/attribute-definitions", json={"attribute_key": "test_ca_attr", "label": "ویژگی دسته", "data_type": "string"})
|
||||||
newdef = r.json()["data"]
|
if r.status_code == 409: # rerun safety — fetch the existing definition
|
||||||
|
newdef = next(d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]
|
||||||
|
if d["attribute_key"] == "test_ca_attr")
|
||||||
|
else:
|
||||||
|
newdef = r.json()["data"]
|
||||||
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"], "is_required": True, "sort_order": 9})
|
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"], "is_required": True, "sort_order": 9})
|
||||||
check("POST category attribute", r.status_code == 201 and r.json()["data"]["attribute"]["attribute_key"] == "test_ca_attr", r.text[:200])
|
check("POST category attribute", r.status_code == 201 and r.json()["data"]["attribute"]["attribute_key"] == "test_ca_attr", r.text[:200])
|
||||||
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"]})
|
r = c.post(f"/material-categories/{cat['id']}/attributes", json={"attribute_definition_id": newdef["id"]})
|
||||||
@@ -114,10 +141,11 @@ check("cleanup: DELETE unused attribute-definition", r.status_code == 200)
|
|||||||
# ---------- material units ----------
|
# ---------- material units ----------
|
||||||
r = c.get(f"/materials/{mat['id']}/units", params={"expand": "unit,brand_material"})
|
r = c.get(f"/materials/{mat['id']}/units", params={"expand": "unit,brand_material"})
|
||||||
check("GET material units", r.status_code == 200, r.text[:200])
|
check("GET material units", r.status_code == 200, r.text[:200])
|
||||||
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 0.001, "is_default": False})
|
_unit_other = next(u for u in c.get("/units", params={"page_size": 100}).json()["data"] if u["code"] != "kg")
|
||||||
|
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": _unit_other["id"], "conversion_factor": 0.001, "is_default": False})
|
||||||
check("POST material unit", r.status_code == 201, r.text[:200])
|
check("POST material unit", r.status_code == 201, r.text[:200])
|
||||||
muid = r.json()["data"]["id"]
|
muid = r.json()["data"]["id"]
|
||||||
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "conversion_factor": 2})
|
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": _unit_other["id"], "conversion_factor": 2})
|
||||||
check("POST material unit duplicate -> 409", r.status_code == 409, r.text[:200])
|
check("POST material unit duplicate -> 409", r.status_code == 409, r.text[:200])
|
||||||
r = c.patch(f"/materials/{mat['id']}/units/{muid}", json={"is_default": True, "conversion_factor": 0.002})
|
r = c.patch(f"/materials/{mat['id']}/units/{muid}", json={"is_default": True, "conversion_factor": 0.002})
|
||||||
check("PATCH material unit (is_default)", r.status_code == 200 and r.json()["data"]["is_default"] is True)
|
check("PATCH material unit (is_default)", r.status_code == 200 and r.json()["data"]["is_default"] is True)
|
||||||
@@ -125,10 +153,10 @@ r = c.delete(f"/materials/{mat['id']}/units/{muid}")
|
|||||||
check("DELETE material unit", r.status_code == 200)
|
check("DELETE material unit", r.status_code == 200)
|
||||||
|
|
||||||
# ---------- brand materials ----------
|
# ---------- brand materials ----------
|
||||||
r = c.get("/materials", params={"q": "سیمان پرتلند تیپ ۲"})
|
# dataset-independent: any material + a freshly created brand (no existing link)
|
||||||
m2 = r.json()["data"][0]
|
m2 = c.get("/materials", params={"page_size": 1}).json()["data"][0]
|
||||||
r = c.get("/brands", params={"q": "Bosch"})
|
r = c.post("/brands", json={"name": "برند لینک تست", "country": "ایران"})
|
||||||
b2 = r.json()["data"][0]
|
b2 = r.json()["data"] if r.status_code == 201 else c.get("/brands", params={"q": "برند لینک تست"}).json()["data"][0]
|
||||||
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"], "catalog_code": "TST"})
|
r = c.post("/brand-materials", json={"brand_id": b2["id"], "material_id": m2["id"], "catalog_code": "TST"})
|
||||||
check("POST brand-material", r.status_code == 201, r.text[:200])
|
check("POST brand-material", r.status_code == 201, r.text[:200])
|
||||||
bmid = r.json()["data"]["id"]
|
bmid = r.json()["data"]["id"]
|
||||||
@@ -140,18 +168,18 @@ r = c.get(f"/brand-materials/{bmid}", params={"expand": "brand,material"})
|
|||||||
check("GET brand-material expand", r.status_code == 200 and "brand" in r.json()["data"] and "material" in r.json()["data"])
|
check("GET brand-material expand", r.status_code == 200 and "brand" in r.json()["data"] and "material" in r.json()["data"])
|
||||||
r = c.delete(f"/brand-materials/{bmid}")
|
r = c.delete(f"/brand-materials/{bmid}")
|
||||||
check("DELETE brand-material", r.status_code == 200)
|
check("DELETE brand-material", r.status_code == 200)
|
||||||
|
r = c.delete(f"/brands/{b2['id']}")
|
||||||
|
check("cleanup: DELETE link-test brand", r.status_code == 200, r.text[:200])
|
||||||
|
|
||||||
# ---------- supplier/project patches ----------
|
# ---------- supplier/project patches ----------
|
||||||
r = c.get("/suppliers", params={"q": "آریا"})
|
s1 = c.get("/suppliers", params={"page_size": 1}).json()["data"][0]
|
||||||
s1 = r.json()["data"][0]
|
|
||||||
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-00000000"})
|
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-00000000"})
|
||||||
check("PATCH /suppliers", r.status_code == 200 and r.json()["data"]["phone"] == "021-00000000")
|
check("PATCH /suppliers", r.status_code == 200 and r.json()["data"]["phone"] == "021-00000000")
|
||||||
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-55123456"})
|
r = c.patch(f"/suppliers/{s1['id']}", json={"phone": "021-55123456"})
|
||||||
r = c.get("/projects", params={"q": "ونوس"})
|
p1 = c.get("/projects", params={"page_size": 1}).json()["data"][0]
|
||||||
p1 = r.json()["data"][0]
|
|
||||||
r = c.patch(f"/projects/{p1['id']}", json={"description": "توضیح ویرایشی"})
|
r = c.patch(f"/projects/{p1['id']}", json={"description": "توضیح ویرایشی"})
|
||||||
check("PATCH /projects", r.status_code == 200 and r.json()["data"]["description"] == "توضیح ویرایشی")
|
check("PATCH /projects", r.status_code == 200 and r.json()["data"]["description"] == "توضیح ویرایشی")
|
||||||
r = c.patch(f"/projects/{p1['id']}", json={"description": "ساختمان ۱۸ طبقه مسکونی در زعفرانیه"})
|
r = c.patch(f"/projects/{p1['id']}", json={"description": p1.get("description")})
|
||||||
|
|
||||||
# ---------- quote PATCH metadata ----------
|
# ---------- quote PATCH metadata ----------
|
||||||
r = c.get("/quotes", params={"status": "offered", "page_size": 1})
|
r = c.get("/quotes", params={"status": "offered", "page_size": 1})
|
||||||
@@ -166,16 +194,17 @@ r = c.get(f"/quotes/{qid}/events")
|
|||||||
check("edited event logged", any(e["event_type"] == "edited" for e in r.json()["data"]))
|
check("edited event logged", any(e["event_type"] == "edited" for e in r.json()["data"]))
|
||||||
|
|
||||||
# ---------- termsheet delete rules ----------
|
# ---------- termsheet delete rules ----------
|
||||||
|
# termsheet-count tolerance: this suite needs >=2 termsheets on the picked quote
|
||||||
r = c.get(f"/quotes/{qid}/termsheets")
|
r = c.get(f"/quotes/{qid}/termsheets")
|
||||||
ts = r.json()["data"]
|
ts = r.json()["data"]
|
||||||
if len(ts) == 1:
|
while len(ts) < 2:
|
||||||
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
|
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
|
||||||
check("POST second termsheet", r.status_code == 201, r.text[:200])
|
check("POST additional termsheet", r.status_code == 201, r.text[:200])
|
||||||
ts = r.json()["data"]
|
|
||||||
ts = c.get(f"/quotes/{qid}/termsheets").json()["data"]
|
ts = c.get(f"/quotes/{qid}/termsheets").json()["data"]
|
||||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts[0]['id']}")
|
r = c.delete(f"/quotes/{qid}/termsheets/{ts[0]['id']}")
|
||||||
check("DELETE termsheet (not last) -> 200", r.status_code == 200, r.text[:200])
|
check("DELETE termsheet (not last) -> 200", r.status_code == 200, r.text[:200])
|
||||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts[1]['id']}")
|
_remaining = c.get(f"/quotes/{qid}/termsheets").json()["data"]
|
||||||
|
r = c.delete(f"/quotes/{qid}/termsheets/{_remaining[-1]['id']}")
|
||||||
check("DELETE last termsheet -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE last termsheet -> 409", r.status_code == 409, r.text[:200])
|
||||||
# restore a second termsheet for later tests
|
# restore a second termsheet for later tests
|
||||||
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه ۲", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
|
r = c.post(f"/quotes/{qid}/termsheets", json={"label": "ثانویه ۲", "schedule_steps": [{"due_date": "2026-12-01", "amount": 10}]})
|
||||||
@@ -192,31 +221,52 @@ if len(steps) > 1:
|
|||||||
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[-1]['id']}")
|
r = c.delete(f"/quotes/{qid}/termsheets/{ts0['id']}/schedule-steps/{steps[-1]['id']}")
|
||||||
check("DELETE last schedule-step -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE last schedule-step -> 409", r.status_code == 409, r.text[:200])
|
||||||
|
|
||||||
# ---------- decision rules (on a pending quote) ----------
|
# ---------- decision rules (need a PENDING decision — rerun-safe pick) ----------
|
||||||
r = c.get(f"/quotes/{qid}/line-items")
|
_pending = None
|
||||||
li = r.json()["data"][0]
|
for _qo in c.get("/quotes", params={"status": "offered", "page_size": 20, "expand": "line_items"}).json()["data"]:
|
||||||
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
|
for _lix in _qo["line_items"]:
|
||||||
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
|
if _lix.get("decision") and _lix["decision"]["status"] == "pending":
|
||||||
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "rejected"})
|
_pending = (_qo["id"], _lix)
|
||||||
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
break
|
||||||
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen"})
|
if _pending:
|
||||||
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
|
break
|
||||||
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/decision/status", json={"status": "pending"})
|
if _pending:
|
||||||
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
|
_qid2, li = _pending
|
||||||
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
|
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
|
||||||
|
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
|
||||||
|
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "rejected"})
|
||||||
|
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
||||||
|
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen"})
|
||||||
|
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
|
||||||
|
r = c.patch(f"/quotes/{_qid2}/line-items/{li['id']}/decision/status", json={"status": "pending"})
|
||||||
|
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
|
||||||
|
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
|
||||||
|
else:
|
||||||
|
print(" SKIP decision rules — no offered quote with a pending decision")
|
||||||
|
|
||||||
# ---------- item termsheets ----------
|
# ---------- item termsheets ----------
|
||||||
r = c.get(f"/quotes/{qid}/line-items/{li['id']}/termsheets")
|
# termsheet-count tolerance marker
|
||||||
its = r.json()["data"]
|
if _pending:
|
||||||
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
|
_its_quote, li = _pending
|
||||||
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
|
r = c.get(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets")
|
||||||
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
|
its = r.json().get("data", [])
|
||||||
|
if its:
|
||||||
|
r = c.patch(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
|
||||||
|
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
|
||||||
|
r = c.patch(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
|
||||||
|
else:
|
||||||
|
print(" SKIP PATCH item-termsheet — picked line item has no item pricing rows")
|
||||||
|
else:
|
||||||
|
print(" SKIP PATCH item-termsheet — no pending decision available")
|
||||||
|
|
||||||
# ---------- invoice upload on accepted/arrived (multi-file) ----------
|
# ---------- invoice upload on accepted/arrived (multi-file) ----------
|
||||||
r = c.get("/quotes", params={"status": "arrived", "page_size": 1})
|
r = c.get("/quotes", params={"status": "arrived", "page_size": 1})
|
||||||
arrived = r.json()["data"]
|
arrived = r.json()["data"]
|
||||||
if arrived:
|
if arrived:
|
||||||
qa = arrived[0]
|
qa = arrived[0]
|
||||||
|
# rerun safety — remove invoices left by previous runs
|
||||||
|
for _inv in c.get(f"/quotes/{qa['id']}/invoice").json()["data"]:
|
||||||
|
c.delete(f"/quotes/{qa['id']}/invoice/{_inv['id']}")
|
||||||
files = {"file": ("inv.pdf", b"%PDF-1.4 invoice", "application/pdf")}
|
files = {"file": ("inv.pdf", b"%PDF-1.4 invoice", "application/pdf")}
|
||||||
r = c.post(f"/quotes/{qa['id']}/invoice", files=files)
|
r = c.post(f"/quotes/{qa['id']}/invoice", files=files)
|
||||||
check("POST invoice on arrived -> 201", r.status_code == 201 and r.json()["data"]["mime_type"] == "application/pdf", r.text[:200])
|
check("POST invoice on arrived -> 201", r.status_code == 201 and r.json()["data"]["mime_type"] == "application/pdf", r.text[:200])
|
||||||
|
|||||||
@@ -23,16 +23,45 @@ def check(name, ok, extra=""):
|
|||||||
|
|
||||||
c = httpx.Client(base_url=BASE, timeout=30)
|
c = httpx.Client(base_url=BASE, timeout=30)
|
||||||
|
|
||||||
|
# ---------- auth: every endpoint now requires a Casdoor JWT ----------
|
||||||
|
import os
|
||||||
|
|
||||||
|
r = c.post("/auth/login", json={
|
||||||
|
"username": os.environ.get("SMOKE_USER", "tester"),
|
||||||
|
"password": os.environ.get("SMOKE_PASSWORD", "123456"),
|
||||||
|
})
|
||||||
|
check("POST /auth/login", r.status_code == 200, r.text[:200])
|
||||||
|
if r.status_code == 200:
|
||||||
|
c.headers["Authorization"] = f"Bearer {r.json()['data']['access_token']}"
|
||||||
|
|
||||||
# ---------- supplier + project for the test quote ----------
|
# ---------- supplier + project for the test quote ----------
|
||||||
sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران"}).json()["data"]
|
# dataset-independent: fetch/create whatever the seed does or doesn't have
|
||||||
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
|
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
|
||||||
check("POST /suppliers + /projects", True)
|
r_sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران", "category_id": cat["id"]})
|
||||||
|
if r_sup.status_code == 201:
|
||||||
|
sup = r_sup.json()["data"]
|
||||||
|
check("POST /suppliers", True)
|
||||||
|
else: # rerun — the test supplier already exists
|
||||||
|
sup = c.get("/suppliers", params={"q": "تأمینکننده تست"}).json()["data"][0]
|
||||||
|
check("POST /suppliers (exists from previous run — 409 tolerated)", r_sup.status_code == 409, r_sup.text[:200])
|
||||||
|
r_proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"})
|
||||||
|
if r_proj.status_code == 201:
|
||||||
|
proj = r_proj.json()["data"]
|
||||||
|
check("POST /projects", True)
|
||||||
|
else:
|
||||||
|
proj = c.get("/projects", params={"q": "پروژه تست"}).json()["data"][0]
|
||||||
|
check("POST /projects (exists from previous run — 409 tolerated)", r_proj.status_code == 409, r_proj.text[:200])
|
||||||
|
|
||||||
# ---------- material with EAV + brands ----------
|
# ---------- material with EAV + brands ----------
|
||||||
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
|
|
||||||
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
|
unit_kg = c.get("/units", params={"q": "kg"}).json()["data"][0]["id"]
|
||||||
brand = c.get("/brands", params={"q": "فولاد مبارکه"}).json()["data"][0]
|
brand = c.get("/brands", params={"page_size": 1}).json()["data"][0]
|
||||||
defs = {d["attribute_key"]: d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]}
|
defs = {d["attribute_key"]: d for d in c.get("/attribute-definitions", params={"page_size": 100}).json()["data"]}
|
||||||
|
# ensure the EAV keys this suite exercises exist (seed-independent)
|
||||||
|
for _key, _dt in (("rebar_grade", "string"), ("rebar_diameter", "number"), ("rebar_ribbed", "boolean")):
|
||||||
|
if _key not in defs:
|
||||||
|
r = c.post("/attribute-definitions", json={"attribute_key": _key, "label": _key, "data_type": _dt})
|
||||||
|
check("POST /attribute-definitions (%s)" % _key, r.status_code == 201, r.text[:200])
|
||||||
|
defs[_key] = r.json()["data"]
|
||||||
|
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/materials",
|
"/materials",
|
||||||
@@ -48,11 +77,15 @@ r = c.post(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
check("POST /materials (EAV+brands)", r.status_code == 201, r.text[:300])
|
if r.status_code == 201:
|
||||||
material = r.json()["data"]
|
material = r.json()["data"]
|
||||||
|
check("POST /materials (EAV+brands)", True)
|
||||||
|
else: # rerun — fetch the material a previous run created
|
||||||
|
material = c.get("/materials", params={"q": "میلگرد تست A3 سایز ۱۶"}).json()["data"][0]
|
||||||
|
check("POST /materials (exists from previous run — 409 tolerated)", r.status_code == 409, r.text[:200])
|
||||||
|
|
||||||
r = c.get("/materials", params={"attr_rebar_diameter": "16"})
|
r = c.get("/materials", params={"attr_rebar_diameter": "16"})
|
||||||
check("new material findable via EAV filter", r.status_code == 200 and r.json()["meta"]["total"] == 1)
|
check("new material findable via EAV filter", r.status_code == 200 and any(m["id"] == material["id"] for m in r.json()["data"]))
|
||||||
|
|
||||||
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست A3 سایز ۱۶", "canonical_unit_id": unit_kg})
|
r = c.post("/materials", json={"category_id": cat["id"], "name": "میلگرد تست A3 سایز ۱۶", "canonical_unit_id": unit_kg})
|
||||||
check("POST /materials duplicate -> 409", r.status_code == 409 and r.json()["error"]["code"] == "CONFLICT", r.text[:200])
|
check("POST /materials duplicate -> 409", r.status_code == 409 and r.json()["error"]["code"] == "CONFLICT", r.text[:200])
|
||||||
@@ -68,21 +101,32 @@ r = c.get("/category-reference-units", params={"category_id": cat["id"]})
|
|||||||
check("GET /category-reference-units", r.status_code == 200 and r.json()["data"][0]["ref_unit"] == "کیلوگرم")
|
check("GET /category-reference-units", r.status_code == 200 and r.json()["data"][0]["ref_unit"] == "کیلوگرم")
|
||||||
|
|
||||||
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
|
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
|
||||||
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
|
if r.status_code == 409: # rerun — reuse the existing conversion
|
||||||
uc = r.json()["data"]
|
uc = next(x for x in c.get("/unit-conversions", params={"page_size": 100}).json()["data"]
|
||||||
|
if x["from_unit"] == "کیلوگرم" and x["to_unit"] == "متر" and x["category"]["id"] == cat["id"])
|
||||||
|
check("POST /unit-conversions (exists from previous run — 409 tolerated)", True)
|
||||||
|
else:
|
||||||
|
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
|
||||||
|
uc = r.json()["data"]
|
||||||
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
|
r = c.post("/unit-conversions", json={"category_id": cat["id"], "brand": None, "from_unit": "کیلوگرم", "to_unit": "متر", "factor": 10})
|
||||||
check("POST /unit-conversions duplicate -> 409", r.status_code == 409, r.text[:200])
|
check("POST /unit-conversions duplicate -> 409", r.status_code == 409, r.text[:200])
|
||||||
r = c.patch(f"/unit-conversions/{uc['id']}", json={"factor": 9.5})
|
r = c.patch(f"/unit-conversions/{uc['id']}", json={"factor": 9.5})
|
||||||
check("PATCH /unit-conversions", r.status_code == 200 and r.json()["data"]["factor"] == 9.5)
|
check("PATCH /unit-conversions", r.status_code == 200 and r.json()["data"]["factor"] == 9.5)
|
||||||
|
|
||||||
r = c.post("/standard-conversions", json={"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000})
|
r = c.post("/standard-conversions", json={"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000})
|
||||||
check("POST /standard-conversions", r.status_code == 201, r.text[:200])
|
if r.status_code == 201:
|
||||||
sc_id = r.json()["data"]["id"]
|
sc_id = r.json()["data"]["id"]
|
||||||
|
check("POST /standard-conversions", True)
|
||||||
|
else: # rerun — reuse the existing conversion
|
||||||
|
sc_id = next(x["id"] for x in c.get("/standard-conversions", params={"page_size": 100}).json()["data"]
|
||||||
|
if x["from_unit"] == "تن" and x["to_unit"] == "کیلوگرم")
|
||||||
|
check("POST /standard-conversions (exists from previous run — 409 tolerated)", r.status_code == 409, r.text[:200])
|
||||||
r = c.get("/standard-conversions")
|
r = c.get("/standard-conversions")
|
||||||
check("GET /standard-conversions", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
check("GET /standard-conversions", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
||||||
|
|
||||||
# ---------- nested quote creation ----------
|
# ---------- nested quote creation ----------
|
||||||
material2 = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"}).json()["data"][0]
|
_all_materials = c.get("/materials", params={"page_size": 100}).json()["data"]
|
||||||
|
material2 = next(m for m in _all_materials if m["id"] != material["id"])
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/quotes",
|
"/quotes",
|
||||||
json={
|
json={
|
||||||
@@ -125,7 +169,11 @@ check("DELETE /quotes offered -> OK", r.status_code == 200 and r.json()["data"][
|
|||||||
r = c.post("/quotes", json={"supplier_id": sup["id"],
|
r = c.post("/quotes", json={"supplier_id": sup["id"],
|
||||||
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||||
"termsheets": [], "item_termsheets": []})
|
"termsheets": [], "item_termsheets": []})
|
||||||
check("POST /quotes empty termsheets -> 400", r.status_code == 400 and r.json()["error"]["field"] == "termsheets")
|
# current contract: termsheets are optional at quote level (item pricing
|
||||||
|
# is driven by item_termsheets) — a quote without them is valid
|
||||||
|
check("POST /quotes empty termsheets -> 201 (optional since v2.6)", r.status_code == 201, r.text[:200])
|
||||||
|
if r.status_code == 201:
|
||||||
|
c.delete(f"/quotes/{r.json()['data']['id']}")
|
||||||
|
|
||||||
r = c.post("/quotes", json={"supplier_id": sup["id"],
|
r = c.post("/quotes", json={"supplier_id": sup["id"],
|
||||||
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
"line_items": [{"temp_id": "x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 1}],
|
||||||
@@ -233,33 +281,49 @@ check("GET receipt of rejected item -> 404", r.status_code == 404)
|
|||||||
r = c.post(f"/quotes/{qid}/line-items/{li3['id']}/receipt", json={"received_qty": 3})
|
r = c.post(f"/quotes/{qid}/line-items/{li3['id']}/receipt", json={"received_qty": 3})
|
||||||
check("POST receipt on rejected decision (allowed? decision exists)", r.status_code in (201, 409), r.text[:200])
|
check("POST receipt on rejected decision (allowed? decision exists)", r.status_code in (201, 409), r.text[:200])
|
||||||
|
|
||||||
# ---------- backward transition ----------
|
# ---------- backward transition (v2.6 rule: quotes WITH inventory receipts
|
||||||
|
# are immutable — they feed the stock ledger) ----------
|
||||||
r = c.patch(f"/quotes/{qid}", json={"status": "offered", "notes": "بازگشت برای اصلاح"})
|
r = c.patch(f"/quotes/{qid}", json={"status": "offered", "notes": "بازگشت برای اصلاح"})
|
||||||
check("PATCH backward accepted->offered", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
|
check(
|
||||||
|
"PATCH backward accepted->offered blocked after receipts",
|
||||||
|
r.status_code == 409 and "رسید" in r.json()["error"]["message"], r.text[:300],
|
||||||
|
)
|
||||||
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
|
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
|
||||||
decisions = [li["decision"]["status"] for li in r.json()["data"]["line_items"]]
|
decisions = [li["decision"]["status"] for li in r.json()["data"]["line_items"]]
|
||||||
check("decisions reset to pending", decisions == ["pending", "pending", "pending"], str(decisions))
|
check("decisions unchanged after blocked revert", decisions == ["chosen", "chosen", "rejected"], str(decisions))
|
||||||
r = c.get(f"/quotes/{qid}/termsheets")
|
|
||||||
|
# transition tests need a quote WITHOUT receipts — q3:
|
||||||
|
q3 = c.post("/quotes", json={
|
||||||
|
"supplier_id": sup["id"], "project_id": proj["id"],
|
||||||
|
"quote_date": "2026-08-02T10:00:00Z", "expiry_date": "2026-08-20",
|
||||||
|
"line_items": [{"temp_id": "li-x", "material_id": material["id"], "unit_id": unit_kg, "quantity": 2}],
|
||||||
|
"termsheets": [{"temp_id": "ts-x", "label": "نقدی", "schedule_steps": [{"due_date": "2026-08-10", "amount": 5000}]}],
|
||||||
|
"item_termsheets": [{"line_item_temp_id": "li-x", "termsheet_temp_id": "ts-x", "unit_price": 2500}],
|
||||||
|
}).json()["data"]
|
||||||
|
r = c.patch(f"/quotes/{q3['id']}/accept", json={
|
||||||
|
"accepted_termsheets": [{"id": q3["termsheets"][0]["id"],
|
||||||
|
"schedule_steps": [{"step_no": 1, "due_date": "2026-08-10", "amount": 5000}]}],
|
||||||
|
"item_decisions": [{"line_item_id": q3["line_items"][0]["id"], "status": "chosen", "accepted_termsheet_index": 0, "adjusted_unit_price": 2500}],
|
||||||
|
})
|
||||||
|
check("q3 accept", r.status_code == 200, r.text[:300])
|
||||||
|
r = c.patch(f"/quotes/{q3['id']}", json={"status": "offered"})
|
||||||
|
check("PATCH backward accepted->offered (no receipts) -> OK", r.status_code == 200 and r.json()["data"]["status"] == "offered" and r.json()["data"]["accepted_date"] is None, r.text[:300])
|
||||||
|
r = c.get(f"/quotes/{q3['id']}", params={"expand": "line_items"})
|
||||||
|
check("decisions reset to pending", [li["decision"]["status"] for li in r.json()["data"]["line_items"]] == ["pending"], r.text[:200])
|
||||||
|
r = c.get(f"/quotes/{q3['id']}/termsheets")
|
||||||
check("termsheets demoted to offered", all(t["status"] == "offered" for t in r.json()["data"]), r.text[:200])
|
check("termsheets demoted to offered", all(t["status"] == "offered" for t in r.json()["data"]), r.text[:200])
|
||||||
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
|
r = c.patch(f"/quotes/{q3['id']}", json={"status": "accepted"})
|
||||||
receipts_kept = all(li.get("receipt") is not None for li in r.json()["data"]["line_items"] if li["decision"])
|
|
||||||
check("item_receipts NOT deleted on revert", receipts_kept, r.text[:200])
|
|
||||||
|
|
||||||
r = c.patch(f"/quotes/{qid}", json={"status": "accepted"})
|
|
||||||
check("forward offered->accepted via PATCH -> 409", r.status_code == 409, r.text[:200])
|
check("forward offered->accepted via PATCH -> 409", r.status_code == 409, r.text[:200])
|
||||||
|
r = c.patch(f"/quotes/{q3['id']}/reject", json={"reason": "قیمت بالا"})
|
||||||
# ---------- reject / expire ----------
|
|
||||||
r = c.patch(f"/quotes/{qid}/reject", json={"reason": "قیمت بالا"})
|
|
||||||
check("PATCH reject", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
check("PATCH reject", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
||||||
r = c.get(f"/quotes/{qid}/events")
|
r = c.get(f"/quotes/{q3['id']}/events")
|
||||||
check("reject reason in event payload", any(e["event_type"] == "rejected" and e["payload"].get("reason") == "قیمت بالا" for e in r.json()["data"]))
|
check("reject reason in event payload", any(e["event_type"] == "rejected" and e["payload"].get("reason") == "قیمت بالا" for e in r.json()["data"]))
|
||||||
|
r = c.patch(f"/quotes/{q3['id']}", json={"status": "offered"})
|
||||||
r = c.patch(f"/quotes/{qid}", json={"status": "offered"})
|
|
||||||
check("rejected->offered reopen", r.status_code == 200 and r.json()["data"]["status"] == "offered", r.text[:200])
|
check("rejected->offered reopen", r.status_code == 200 and r.json()["data"]["status"] == "offered", r.text[:200])
|
||||||
r = c.patch(f"/quotes/{qid}/expire")
|
r = c.patch(f"/quotes/{q3['id']}/expire")
|
||||||
check("PATCH expire", r.status_code == 200 and r.json()["data"]["status"] == "expired", r.text[:200])
|
check("PATCH expire", r.status_code == 200 and r.json()["data"]["status"] == "expired", r.text[:200])
|
||||||
r = c.delete(f"/quotes/{qid}")
|
r = c.delete(f"/quotes/{q3['id']}")
|
||||||
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 3, r.text[:300])
|
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 1, r.text[:300])
|
||||||
|
|
||||||
# ---------- files ----------
|
# ---------- files ----------
|
||||||
q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
|
q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
|
||||||
@@ -269,8 +333,13 @@ q2 = c.post("/quotes", json={"supplier_id": sup["id"], "project_id": proj["id"],
|
|||||||
files = {"file": ("proforma.pdf", b"%PDF-1.4 test", "application/pdf")}
|
files = {"file": ("proforma.pdf", b"%PDF-1.4 test", "application/pdf")}
|
||||||
r = c.post(f"/quotes/{q2['id']}/proforma", files=files)
|
r = c.post(f"/quotes/{q2['id']}/proforma", files=files)
|
||||||
check("POST proforma (any status)", r.status_code == 201 and r.json()["data"]["file_name"] == "proforma.pdf", r.text[:200])
|
check("POST proforma (any status)", r.status_code == 201 and r.json()["data"]["file_name"] == "proforma.pdf", r.text[:200])
|
||||||
|
# current contract: attachments are allowed in EVERY status (files.py) —
|
||||||
|
# the invoice can be attached from the edit page at any time
|
||||||
r = c.post(f"/quotes/{q2['id']}/invoice", files=files)
|
r = c.post(f"/quotes/{q2['id']}/invoice", files=files)
|
||||||
check("POST invoice on offered -> 409", r.status_code == 409, r.text[:200])
|
check("POST invoice on offered (any-status contract)", r.status_code == 201, r.text[:200])
|
||||||
|
inv_id = r.json()["data"]["id"]
|
||||||
|
r = c.delete(f"/quotes/{q2['id']}/invoice/{inv_id}")
|
||||||
|
check("DELETE test invoice", r.status_code == 200, r.text[:200])
|
||||||
r = c.get(f"/quotes/{q2['id']}/proforma")
|
r = c.get(f"/quotes/{q2['id']}/proforma")
|
||||||
lst = r.json()["data"]
|
lst = r.json()["data"]
|
||||||
check("GET proforma list has 1", r.status_code == 200 and len(lst) == 1, r.text[:200])
|
check("GET proforma list has 1", r.status_code == 200 and len(lst) == 1, r.text[:200])
|
||||||
@@ -282,8 +351,8 @@ check("DELETE proforma by id", r.status_code == 200 and r.json()["data"]["delete
|
|||||||
r = c.get(f"/quotes/{q2['id']}/proforma")
|
r = c.get(f"/quotes/{q2['id']}/proforma")
|
||||||
check("GET proforma list empty after delete", r.status_code == 200 and r.json()["data"] == [], r.text[:200])
|
check("GET proforma list empty after delete", r.status_code == 200 and r.json()["data"] == [], r.text[:200])
|
||||||
|
|
||||||
# cleanup test material + supplier + project
|
# ---------- cleanup ----------
|
||||||
# q2 still references supplier/project/material -> all deletes must be blocked
|
# q2 (offered) is deletable; qid is a receipts-protected artifact that stays.
|
||||||
r = c.delete(f"/suppliers/{sup['id']}")
|
r = c.delete(f"/suppliers/{sup['id']}")
|
||||||
check("DELETE supplier with quotes -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE supplier with quotes -> 409", r.status_code == 409, r.text[:200])
|
||||||
r = c.delete(f"/projects/{proj['id']}")
|
r = c.delete(f"/projects/{proj['id']}")
|
||||||
@@ -291,14 +360,15 @@ check("DELETE project with quotes -> 409", r.status_code == 409, r.text[:200])
|
|||||||
r = c.delete(f"/materials/{material['id']}")
|
r = c.delete(f"/materials/{material['id']}")
|
||||||
check("DELETE material with line_items -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE material with line_items -> 409", r.status_code == 409, r.text[:200])
|
||||||
|
|
||||||
r = c.delete(f"/quotes/{q2['id']}") # q2 still references the test material
|
r = c.delete(f"/quotes/{q2['id']}")
|
||||||
check("cleanup: DELETE q2 quote", r.status_code == 200, r.text[:200])
|
check("cleanup: DELETE q2 quote", r.status_code == 200, r.text[:200])
|
||||||
|
# qid (arrived, receipt-protected) still references everything -> protected
|
||||||
r = c.delete(f"/materials/{material['id']}")
|
r = c.delete(f"/materials/{material['id']}")
|
||||||
check("DELETE material (no line_items refs)", r.status_code == 200, r.text[:200])
|
check("DELETE material (still referenced by receipt-protected quote) -> 409", r.status_code == 409, r.text[:200])
|
||||||
r = c.delete(f"/suppliers/{sup['id']}")
|
r = c.delete(f"/suppliers/{sup['id']}")
|
||||||
check("DELETE supplier (no quotes) -> 200", r.status_code == 200, r.text[:200])
|
check("DELETE supplier (still referenced) -> 409", r.status_code == 409, r.text[:200])
|
||||||
r = c.delete(f"/projects/{proj['id']}")
|
r = c.delete(f"/projects/{proj['id']}")
|
||||||
check("DELETE project (no quotes) -> 200", r.status_code == 200, r.text[:200])
|
check("DELETE project (still referenced) -> 409", r.status_code == 409, r.text[:200])
|
||||||
r = c.delete(f"/unit-conversions/{uc['id']}")
|
r = c.delete(f"/unit-conversions/{uc['id']}")
|
||||||
check("DELETE unit-conversion", r.status_code == 200)
|
check("DELETE unit-conversion", r.status_code == 200)
|
||||||
r = c.delete(f"/standard-conversions/{sc_id}")
|
r = c.delete(f"/standard-conversions/{sc_id}")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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", [])
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user