Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65758b5e78 | ||
|
|
b1edf6eed3 | ||
|
|
a181a8753d | ||
|
|
8adfd2267f |
@@ -1,71 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# 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=
|
|
||||||
-10
@@ -11,19 +11,9 @@ __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/
|
||||||
v1.2 - Copy/
|
v1.2 - Copy/
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ 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 credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
|
| Casdoor console | http://localhost:8080 | `admin` / `123` |
|
||||||
| 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`) |
|
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password `123456` |
|
||||||
|
|
||||||
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 —
|
||||||
@@ -143,7 +143,7 @@ is an SSH tunnel from your machine:
|
|||||||
use the panel):
|
use the panel):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
|
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
|
||||||
```
|
```
|
||||||
|
|
||||||
You will also land in a normal SSH session — that is fine; the tunnel
|
You will also land in a normal SSH session — that is fine; the tunnel
|
||||||
@@ -151,17 +151,18 @@ is an SSH tunnel from your machine:
|
|||||||
|
|
||||||
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`](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.
|
||||||
|
|
||||||
⚠️ Those credentials are **full admin access to the file storage**. They live
|
⚠️ These credentials are **full admin access to the file storage**. Treat
|
||||||
only in the server-side `.env`; if one ever leaks, rotate it immediately
|
them as a production secret (this README is inside the repo, so anyone with
|
||||||
(checklist in `docs/SECRET-MANAGEMENT.md`).
|
repo access can read them — rotate them on the server if they ever leak).
|
||||||
|
|
||||||
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.
|
||||||
@@ -214,23 +215,15 @@ All should show `Up` (postgres also `(healthy)`).
|
|||||||
| 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`) |
|
||||||
|
|
||||||
Automated end-to-end checks (authenticates via `SMOKE_USER` /
|
Quick end-to-end check (Traefik → frontend proxy → API → DB):
|
||||||
`SMOKE_PASSWORD` — the seeder users):
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
backend\tools\smoke_test.py # read paths
|
curl http://localhost/api/v1/quotes?expand=supplier,project,line_items,termsheets,events,decision,receipt
|
||||||
backend\tools\smoke_test_write.py / smoke_test_misc.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Every API route now requires a Casdoor JWT — a bare
|
|
||||||
> `curl http://localhost/api/v1/quotes` returns **401** by design.
|
|
||||||
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MinIO (object storage for invoice / proforma files)
|
## MinIO (object storage for invoice / proforma files)
|
||||||
@@ -269,33 +262,47 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recovery / seeding
|
## Restoring the committed seed data
|
||||||
|
|
||||||
The databases are **not** in git — Docker volumes hold the data, and
|
The databases are **not** in git — Docker volumes hold the data. The repo
|
||||||
database dumps must never be committed (they contain user rows and signing
|
ships recovery dumps in `backups/علی محقق/`:
|
||||||
material). Two supported ways to bring a fresh stack up:
|
|
||||||
|
|
||||||
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
|
| File | Contents |
|
||||||
`postgres_data` volume; then run the seeders:
|
|---|---|
|
||||||
|
| `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:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose stop api casdoor
|
||||||
|
docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
|
||||||
|
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
|
||||||
|
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
|
||||||
|
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
|
||||||
|
different users — see the Login table above):
|
||||||
|
|
||||||
|
1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
|
||||||
```powershell
|
```powershell
|
||||||
# Casdoor org + app + users (prints/generates credentials — do not commit them)
|
|
||||||
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
|
||||||
# Demo dataset (optional):
|
|
||||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||||
```
|
```
|
||||||
|
2. **Casdoor seeder** (creates org `inventory` + app + users razaei /
|
||||||
2. **Restore your own dump** — create dumps on the server (kept server-side,
|
mousavi / mohaghegh / tester — run once after a fresh volume):
|
||||||
e.g. `/srv/erp/backups/`), never in git:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
|
python backend/tools/seed_casdoor.py # from the host, or from the api container
|
||||||
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
|
To create a fresh dump of your own current state: stop `api` + `casdoor`,
|
||||||
first — this **replaces all data**). The scheduled backup sidecar
|
`docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc`
|
||||||
(see `docs/OPERATIONS.md`) automates this.
|
(+ the `casdoor` DB) into `backups/`, then start them again.
|
||||||
|
|
||||||
## Common operations
|
## Common operations
|
||||||
|
|
||||||
@@ -310,11 +317,10 @@ docker compose down -v # ⚠️ full reset — wipes DB + MinIO vol
|
|||||||
|
|
||||||
## Known behavior
|
## Known behavior
|
||||||
|
|
||||||
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
|
- Authentication is Casdoor-based (JWT verified via JWKS): `/auth`, `/users`,
|
||||||
every API route** except `/health`, `/health/ready` and `/auth/login`
|
`/inventory` and `/pm` are protected (401 without a token, 403 without a role).
|
||||||
(401 without a token, 403 without a role). The former dev-header fallback
|
The procurement CRUD endpoints (quotes, suppliers, materials, …) are still
|
||||||
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
|
open — dev placeholder, do not expose to the public internet.
|
||||||
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
|
|
||||||
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
|
||||||
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
|
||||||
the procurement manager can only assign projects to project managers.
|
the procurement manager can only assign projects to project managers.
|
||||||
|
|||||||
+5
-19
@@ -26,7 +26,7 @@ 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 AsyncSessionLocal
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User
|
||||||
from app.utils.errors import (
|
from app.utils.errors import (
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -170,16 +170,11 @@ async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
|||||||
return list(rows)
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
async def get_optional_user(request: Request, db: AsyncSession) -> 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 "):
|
||||||
@@ -187,22 +182,13 @@ async def get_optional_user(request: Request, db: AsyncSession | None = None) ->
|
|||||||
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) -> UserContext:
|
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> 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:
|
||||||
|
|||||||
+8
-47
@@ -1,14 +1,7 @@
|
|||||||
"""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``
|
``PROCUREMENT_`` (e.g. ``PROCUREMENT_DATABASE_URL=...``) or a local ``.env`` file.
|
||||||
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
|
||||||
|
|
||||||
@@ -21,10 +14,7 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
# "dev" (local defaults allowed) | "prod" (fail-fast checks on startup).
|
# PostgreSQL DSN — the schema (migration.sql + seed.sql) must already exist.
|
||||||
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).
|
||||||
@@ -32,8 +22,6 @@ 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"
|
||||||
@@ -48,54 +36,27 @@ class Settings(BaseSettings):
|
|||||||
DEFAULT_PAGE_SIZE: int = 20
|
DEFAULT_PAGE_SIZE: int = 20
|
||||||
MAX_PAGE_SIZE: int = 100
|
MAX_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
# CORS — comma-separated exact browser origins (NOT "*" with
|
# CORS (dev-friendly default; the frontend is a static site).
|
||||||
# credentials; the prod checks reject wildcard). Example:
|
CORS_ORIGINS: list[str] = ["*"]
|
||||||
# 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 = "" # no default secret — set via env/.env
|
CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
|
||||||
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. No default password.
|
# identities into the app's users table (dev defaults: admin / 123).
|
||||||
CASDOOR_ADMIN_USER: str = "admin"
|
CASDOOR_ADMIN_USER: str = "admin"
|
||||||
CASDOOR_ADMIN_PASSWORD: str = ""
|
CASDOOR_ADMIN_PASSWORD: str = "123"
|
||||||
|
|
||||||
# Inventory dev fallback defaults (only used when DEV_LOGIN_ENABLED).
|
# Inventory dev fallback (only when no Authorization header is sent).
|
||||||
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()
|
||||||
|
|||||||
+25
-29
@@ -345,6 +345,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,13 +391,20 @@ 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 trigger, function, and column.
|
||||||
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
|
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||||
AFTER INSERT ON supplier_materials
|
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||||
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
|
||||||
@@ -393,26 +412,3 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
|||||||
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|||||||
+4
-128
@@ -7,55 +7,30 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import httpx
|
from fastapi import FastAPI, Request
|
||||||
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_dashboard_tables,
|
ensure_dashboard_tables,
|
||||||
ensure_material_topic_column,
|
ensure_material_topic_column,
|
||||||
ensure_quote_projects,
|
ensure_quote_projects,
|
||||||
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
|
|
||||||
|
|
||||||
setup_logging(settings.LOG_FORMAT)
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
@@ -70,41 +45,13 @@ 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_audit_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
|
||||||
@@ -127,21 +74,11 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origin_list,
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -221,72 +158,11 @@ 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
|
|
||||||
|
|
||||||
for router in ROUTERS:
|
for router in ROUTERS:
|
||||||
if router is _auth_router:
|
app.include_router(router, prefix=API_V1_PREFIX)
|
||||||
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,6 +1,5 @@
|
|||||||
"""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,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"""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,14 +130,11 @@ 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; decoupled from categories.
|
||||||
__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
|
||||||
canonical_unit_id: Mapped[int] = mapped_column(
|
canonical_unit_id: Mapped[int] = mapped_column(
|
||||||
@@ -149,7 +146,6 @@ 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")
|
||||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||||
back_populates="material", lazy="raise", passive_deletes=True
|
back_populates="material", lazy="raise", passive_deletes=True
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -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, Request
|
from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -21,9 +21,7 @@ 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.services import analytics
|
|
||||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
||||||
from app.utils.ratelimit import limiter
|
|
||||||
|
|
||||||
logger = logging.getLogger("procurement.auth")
|
logger = logging.getLogger("procurement.auth")
|
||||||
|
|
||||||
@@ -36,8 +34,7 @@ class LoginRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
@limiter.limit("10/minute", error_message="تلاشهای ورود بیش از حد مجاز — کمی بعد دوباره امتحان کنید")
|
async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
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 = {
|
||||||
@@ -78,15 +75,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
|||||||
claims = await _verify_token(access_token)
|
claims = await _verify_token(access_token)
|
||||||
ctx = await context_from_claims(claims, db)
|
ctx = await context_from_claims(claims, db)
|
||||||
projects = await _user_projects(db, ctx.user)
|
projects = await _user_projects(db, ctx.user)
|
||||||
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,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from urllib.parse import quote
|
|||||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||||
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 Quote
|
from app.models.procurement import Quote
|
||||||
from app.services import file_service
|
from app.services import file_service
|
||||||
@@ -28,10 +27,6 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
|
|
||||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||||
|
|
||||||
# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
|
|
||||||
# برای هر کاربر واردشده آزاد است.
|
|
||||||
_attachment_deleter = require_role("procurement_manager", "admin")
|
|
||||||
|
|
||||||
ALLOWED_MIME_TYPES = {
|
ALLOWED_MIME_TYPES = {
|
||||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||||
}
|
}
|
||||||
@@ -125,7 +120,7 @@ async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: As
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{quote_id}/invoice/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
@router.delete("/{quote_id}/invoice/{attachment_id}")
|
||||||
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")
|
||||||
@@ -170,7 +165,7 @@ async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: A
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{quote_id}/proforma/{attachment_id}", dependencies=[Depends(_attachment_deleter)])
|
@router.delete("/{quote_id}/proforma/{attachment_id}")
|
||||||
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")
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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
|
||||||
|
|
||||||
@@ -96,11 +95,9 @@ async def inventory_scope(
|
|||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
|
|
||||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||||
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
|
# when an explicit X-User-Role header is sent. No auth info at all → 401.
|
||||||
# Casdoor). Disabled by default: unauthenticated header identity in
|
if not request.headers.get("x-user-role"):
|
||||||
# production would hand admin access to anyone who can reach the API.
|
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
|
||||||
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 # مدیر تدارکات = ادمین (فقطخواندنی)
|
||||||
@@ -274,12 +271,13 @@ async def update_warehouse(
|
|||||||
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))}
|
||||||
|
|
||||||
@@ -495,16 +493,6 @@ async def create_transaction(
|
|||||||
if dest.project_id != scope.project_id:
|
if dest.project_id != scope.project_id:
|
||||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||||
await audit_record(
|
|
||||||
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]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ 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
|
||||||
@@ -26,9 +21,6 @@ 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(
|
||||||
@@ -67,7 +59,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("", dependencies=[Depends(_decision_writer)])
|
@router.put("")
|
||||||
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)
|
||||||
):
|
):
|
||||||
@@ -101,7 +93,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", dependencies=[Depends(_decision_writer)])
|
@router.patch("/status")
|
||||||
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)
|
||||||
):
|
):
|
||||||
@@ -126,7 +118,7 @@ async def patch_decision_status(
|
|||||||
return {"data": decision_full(decision)}
|
return {"data": decision_full(decision)}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("", dependencies=[Depends(_decision_writer)])
|
@router.patch("")
|
||||||
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)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.models.catalog import (
|
|||||||
MaterialUnit,
|
MaterialUnit,
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
||||||
from app.schemas.catalog import (
|
from app.schemas.catalog import (
|
||||||
MaterialAttributeValuesPut,
|
MaterialAttributeValuesPut,
|
||||||
MaterialCreate,
|
MaterialCreate,
|
||||||
@@ -108,7 +108,16 @@ async def list_materials(
|
|||||||
):
|
):
|
||||||
stmt = select(Material)
|
stmt = select(Material)
|
||||||
if category_id:
|
if category_id:
|
||||||
stmt = stmt.where(Material.category_id == category_id)
|
sub = (
|
||||||
|
select(1)
|
||||||
|
.select_from(SupplierMaterial)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(
|
||||||
|
SupplierMaterial.material_id == Material.id,
|
||||||
|
Supplier.category_id == category_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = stmt.where(exists(sub))
|
||||||
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(
|
||||||
|
|||||||
+10
-32
@@ -19,9 +19,8 @@ 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 (a global dependency in main.py) resolves the caller;
|
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
|
||||||
optional dev-header fallback (X-User-Role: pm|admin + X-Project-Id for pm)
|
X-Project-Id for pm) when no Authorization header — same pattern as the
|
||||||
exists only when PROCUREMENT_DEV_LOGIN_ENABLED=true — same pattern as the
|
|
||||||
inventory router.
|
inventory router.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
@@ -36,7 +35,6 @@ 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 BrandMaterial, Material, MaterialCategory, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
@@ -50,7 +48,6 @@ from app.models.procurement import (
|
|||||||
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,
|
||||||
@@ -101,12 +98,9 @@ async def arrivals_scope(
|
|||||||
if ctx.role in ("admin", "procurement_manager"):
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
|
||||||
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
if not request.headers.get("x-user-role"):
|
||||||
# Disabled by default: an ungated header identity in production would
|
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor یا حالت توسعه را ارسال کنید")
|
||||||
# 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"
|
||||||
@@ -201,17 +195,11 @@ 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}
|
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
||||||
cats = {}
|
cats = {}
|
||||||
if mat_ids:
|
if sup_cat_ids:
|
||||||
rows = (
|
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
||||||
await db.execute(
|
cats = {c.id: c for c in rows}
|
||||||
select(Material.id, MaterialCategory)
|
|
||||||
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
|
||||||
.where(Material.id.in_(mat_ids))
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
cats = {m_id: c for m_id, c in rows}
|
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m, sup, proj in decisions:
|
for d, li, q, m, sup, proj in decisions:
|
||||||
@@ -226,7 +214,7 @@ async def pm_on_the_way(
|
|||||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name,
|
"material": {"id": str(m.id), "name": m.name,
|
||||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
|
||||||
"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),
|
||||||
@@ -430,16 +418,6 @@ 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,
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
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,
|
||||||
@@ -39,9 +48,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
|
|||||||
def _quote_category_exists(category_id) -> exists:
|
def _quote_category_exists(category_id) -> exists:
|
||||||
return exists(
|
return exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
LineItem.quote_id == Quote.id,
|
Supplier.id == Quote.supplier_id,
|
||||||
Material.id == LineItem.material_id,
|
Supplier.category_id == category_id,
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,7 +120,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
|
||||||
|
# candidates, or an accepted (decision) target — NOT just the mirror
|
||||||
|
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 category_id:
|
if category_id:
|
||||||
stmt = stmt.where(_quote_category_exists(category_id))
|
stmt = stmt.where(_quote_category_exists(category_id))
|
||||||
if quote_date_from is not None:
|
if quote_date_from is not None:
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ async def list_supplier_materials(
|
|||||||
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 = {l.material_id for l in items}
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
cat_ids = {m.category_id for m in materials.values() if m.category_id}
|
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
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}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
@@ -72,8 +68,7 @@ async def create_supplier_material(
|
|||||||
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, None)}
|
||||||
return {"data": link_to_dict(link, material, category)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
from app.auth import UserContext, get_current_user, require_role
|
||||||
from app.utils.audit import record as audit_record
|
|
||||||
from app.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
|
||||||
@@ -169,7 +168,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,
|
||||||
ctx: UserContext = Depends(_org_manager),
|
_: 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)."""
|
||||||
@@ -186,7 +185,6 @@ async def create_user(
|
|||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
|
|
||||||
return {"data": user_to_dict(user)}
|
return {"data": user_to_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
@@ -194,7 +192,7 @@ async def create_user(
|
|||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
ctx: UserContext = Depends(require_role("admin")),
|
_: UserContext = Depends(require_role("admin")),
|
||||||
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 — ADMIN ONLY (RBAC): procurement managers must not
|
||||||
@@ -203,12 +201,10 @@ async def update_user_role(
|
|||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
previous_role = user.role
|
|
||||||
if user.role == "pm" and payload.role != "pm":
|
if user.role == "pm" and payload.role != "pm":
|
||||||
await _unassign_projects(db, user.id)
|
await _unassign_projects(db, user.id)
|
||||||
user.role = payload.role
|
user.role = payload.role
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
|
|
||||||
projects = await _user_projects(db, user)
|
projects = await _user_projects(db, user)
|
||||||
return {"data": user_to_dict(user, projects)}
|
return {"data": user_to_dict(user, projects)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
"""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
|
|
||||||
body = {
|
|
||||||
"type": "track",
|
|
||||||
"event": event,
|
|
||||||
"properties": properties or {},
|
|
||||||
}
|
|
||||||
if profile_id:
|
|
||||||
body["profileId"] = profile_id
|
|
||||||
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", "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)
|
|
||||||
@@ -206,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}
|
||||||
@@ -258,9 +253,9 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
|||||||
"updated_at": to_iso(m.updated_at),
|
"updated_at": to_iso(m.updated_at),
|
||||||
}
|
}
|
||||||
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:
|
||||||
@@ -326,9 +321,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:
|
||||||
@@ -450,7 +442,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, [])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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 uuid import UUID
|
from uuid import UUID
|
||||||
@@ -34,7 +33,6 @@ 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
|
||||||
|
|
||||||
@@ -58,19 +56,6 @@ 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:
|
||||||
@@ -918,37 +903,57 @@ async def quote_stats(
|
|||||||
|
|
||||||
|
|
||||||
def _quote_has_category(category_id: UUID):
|
def _quote_has_category(category_id: UUID):
|
||||||
"""EXISTS: quote has a line item whose material belongs to category_id."""
|
"""EXISTS: quote's supplier belongs to category_id."""
|
||||||
from app.models.catalog import Material
|
|
||||||
|
|
||||||
sub = (
|
sub = (
|
||||||
select(1)
|
select(1)
|
||||||
.where(
|
.where(
|
||||||
LineItem.quote_id == Quote.id,
|
Supplier.id == Quote.supplier_id,
|
||||||
Material.id == LineItem.material_id,
|
Supplier.category_id == category_id,
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return func.exists(sub)
|
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]
|
||||||
|
|
||||||
@@ -956,6 +961,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 []
|
||||||
@@ -964,12 +981,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:
|
||||||
@@ -979,8 +1040,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():
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"""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,33 +1,14 @@
|
|||||||
"""Attribution for quote_events.actor — the VERIFIED user, not the raw token.
|
"""Placeholder auth: no middleware logic, only a header read for `actor` attribution."""
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
async def get_actor(request: Request) -> str | None:
|
This is the reserved placeholder from the contract — no authentication
|
||||||
"""Resolve the caller's display name from the Casdoor Bearer token.
|
or authorization is enforced yet.
|
||||||
|
|
||||||
Uses its own session (get_optional_user default) — same reason as
|
|
||||||
get_current_user: quote routers open explicit db.begin() blocks.
|
|
||||||
"""
|
"""
|
||||||
ctx = await get_optional_user(request)
|
auth = request.headers.get("Authorization")
|
||||||
if ctx is not None:
|
if auth and auth.lower().startswith("bearer "):
|
||||||
return ctx.display_name
|
return auth[7:].strip() or None
|
||||||
# Dev-mode header fallback (only active when DEV_LOGIN_ENABLED) carries
|
return None
|
||||||
# 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,13 +10,3 @@ 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
|
|
||||||
|
|||||||
@@ -261,9 +261,7 @@ async def run() -> None:
|
|||||||
material_by_name: dict[str, Material] = {}
|
material_by_name: dict[str, Material] = {}
|
||||||
brand_material_by_material: dict[str, object] = {}
|
brand_material_by_material: dict[str, object] = {}
|
||||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||||
cat = await get_category(db, cat_slug)
|
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=cat.id,
|
|
||||||
name=name,
|
name=name,
|
||||||
canonical_unit_id=unit_ids[unit_code],
|
canonical_unit_id=unit_ids[unit_code],
|
||||||
status="active",
|
status="active",
|
||||||
@@ -289,8 +287,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,
|
||||||
|
|||||||
@@ -20,34 +20,22 @@ 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"
|
||||||
# Must MATCH the API's PROCUREMENT_CASDOOR_CLIENT_SECRET (compose default
|
CLIENT_SECRET = "inventory-app-secret-2026"
|
||||||
# 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)
|
# (name, first_name, last_name, email, password)
|
||||||
("razaei", "آقای", "رضایی", "razaei@sample.ir"),
|
("razaei", "آقای", "رضایی", "razaei@sample.ir", "123456"),
|
||||||
("mousavi", "آقای", "موسوی", "mousavi@sample.ir"),
|
("mousavi", "آقای", "موسوی", "mousavi@sample.ir", "123456"),
|
||||||
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir"),
|
("mohaghegh", "آقای", "محقق", "mohaghegh@sample.ir", "123456"),
|
||||||
("tester", "کاربر", "تست", "tester@sample.ir"),
|
("tester", "کاربر", "تست", "tester@sample.ir", "123456"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -81,21 +69,17 @@ 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 (bcrypt — passwords are never stored plaintext)
|
# 1) organization (passwordType "plain" = dev: passwords stored as-is)
|
||||||
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": "bcrypt",
|
"passwordType": "plain",
|
||||||
}, cookies)
|
}, cookies)
|
||||||
print("add-organization:", status, body.get("msg", body.get("error", "")))
|
print("add-organization:", status, body.get("msg", body.get("error", "")))
|
||||||
|
|
||||||
@@ -118,8 +102,7 @@ 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)
|
||||||
pw_hash = _bcrypt(SEED_PASSWORD)
|
for name, first, last, email, pw in USERS:
|
||||||
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,
|
||||||
@@ -127,23 +110,21 @@ def main():
|
|||||||
"firstName": first,
|
"firstName": first,
|
||||||
"lastName": last,
|
"lastName": last,
|
||||||
"email": email,
|
"email": email,
|
||||||
"password": pw_hash,
|
"password": pw,
|
||||||
"passwordType": "bcrypt",
|
"passwordType": "plain",
|
||||||
"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,password", {
|
status, body = _request("POST", f"/api/update-user?id={ORG}/{name}&columns=password_type", {
|
||||||
"passwordType": "bcrypt",
|
"passwordType": "plain",
|
||||||
"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")
|
print("\nDone. Casdoor console: http://localhost:8080 (admin/123)")
|
||||||
print(f"Seeded users password: {SEED_PASSWORD} (change via CASDOOR_SEED_PASSWORD)")
|
print("Login in the app: http://localhost:3000/login")
|
||||||
print("Login in the app: http://localhost/login")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+61
-145
@@ -1,14 +1,9 @@
|
|||||||
"""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).
|
||||||
|
|
||||||
Dataset-independent: assertions verify BEHAVIOR (status codes, envelopes,
|
Run: .venv/Scripts/python.exe tools/smoke_test.py
|
||||||
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
|
||||||
|
|
||||||
@@ -28,150 +23,84 @@ 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})
|
||||||
units = r.json()["data"] if r.status_code == 200 else []
|
check("GET /units", r.status_code == 200 and len(r.json()["data"]) == 5 and "si_factor" in r.json()["data"][0])
|
||||||
check("GET /units", r.status_code == 200 and len(units) == 5 and "si_factor" in units[0])
|
|
||||||
|
|
||||||
if units:
|
r = c.get("/units", params={"dimension": "mass"})
|
||||||
dim = units[0]["dimension"]
|
check("GET /units?dimension=mass", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
||||||
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")
|
||||||
brands = r.json()["data"] if r.status_code == 200 else []
|
check("GET /brands", r.status_code == 200 and r.json()["meta"]["total"] == 10)
|
||||||
check("GET /brands", r.status_code == 200 and brands and r.json()["meta"]["total"] >= 1)
|
|
||||||
|
|
||||||
# category q-search (only when categories exist)
|
r = c.get("/material-categories", params={"q": "سیمان"})
|
||||||
r = c.get("/material-categories", params={"page_size": 100})
|
check("GET /material-categories?q", r.status_code == 200 and r.json()["meta"]["total"] >= 1)
|
||||||
cats = r.json()["data"] if r.status_code == 200 else []
|
|
||||||
check("GET /material-categories", r.status_code == 200 and isinstance(cats, list))
|
r = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"})
|
||||||
if cats:
|
check("GET /materials?q (unicode)", r.status_code == 200 and r.json()["meta"]["total"] == 1, r.text[:200])
|
||||||
needle = cats[0]["name"][:3]
|
|
||||||
r = c.get("/material-categories", params={"q": needle})
|
r = c.get("/materials", params={"expand": "category,brands,canonical_unit,attributes", "q": "میلگرد A3 سایز ۱۴"})
|
||||||
|
if r.status_code == 200 and r.json()["data"]:
|
||||||
|
m = r.json()["data"][0]
|
||||||
check(
|
check(
|
||||||
"GET /material-categories?q (unicode search)",
|
"GET /materials expand",
|
||||||
r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:200],
|
"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"})
|
||||||
# materials: q-search against a REAL seeded name (fetched, not hard-coded)
|
check("expand brands has catalog_code+is_active", all(set(b) >= {"id", "name", "catalog_code", "is_active"} for b in m["brands"]))
|
||||||
r = c.get("/materials", params={"page_size": 100})
|
check("expand canonical_unit shape", m["canonical_unit"].keys() == {"id", "code", "name_fa"})
|
||||||
materials = r.json()["data"] if r.status_code == 200 else []
|
check("expand attributes EAV flattened", all(set(a) >= {"attribute_key", "label", "data_type", "value", "unit"} for a in m["attributes"]))
|
||||||
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:
|
||||||
print(" SKIP EAV positive filter — seed defines no attribute definitions")
|
check("GET /materials expand", False, r.text[:300])
|
||||||
|
|
||||||
# unknown attr filter -> 400 (dataset-independent)
|
# dynamic EAV filter: attr_rebar_diameter=14
|
||||||
|
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")
|
||||||
suppliers = r.json()["data"] if r.status_code == 200 else []
|
check("GET /suppliers", r.status_code == 200 and r.json()["meta"]["total"] == 6)
|
||||||
check("GET /suppliers", r.status_code == 200 and suppliers)
|
|
||||||
|
|
||||||
r = c.get("/projects")
|
r = c.get("/projects")
|
||||||
projects = r.json()["data"] if r.status_code == 200 else []
|
check("GET /projects", r.status_code == 200 and r.json()["meta"]["total"] == 5)
|
||||||
check("GET /projects", r.status_code == 200 and projects)
|
|
||||||
|
|
||||||
r = c.get("/attribute-definitions")
|
r = c.get("/attribute-definitions", params={"expand": "unit"})
|
||||||
attr_shape_ok = r.status_code == 200 and all(
|
check("GET /attribute-definitions", r.status_code == 200 and r.json()["meta"]["total"] == 28, r.text[:200])
|
||||||
set(a) >= {"attribute_key", "label", "data_type"} for a in r.json().get("data", [])
|
|
||||||
)
|
|
||||||
check("GET /attribute-definitions", attr_shape_ok, r.text[:200])
|
|
||||||
|
|
||||||
if cats:
|
r = c.get("/material-categories/{cid}/attributes".format(cid=c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]["id"]))
|
||||||
r = c.get(f"/material-categories/{cats[0]['id']}/attributes")
|
check("GET category attributes (nested attribute)", r.status_code == 200 and len(r.json()["data"]) == 3 and "attribute" in r.json()["data"][0])
|
||||||
check(
|
|
||||||
"GET category attributes (nested)",
|
r = c.get("/brand-materials", params={"brand_id": brand_id})
|
||||||
r.status_code == 200 and isinstance(r.json()["data"], list)
|
check("GET /brand-materials?brand_id", r.status_code == 200 and r.json()["meta"]["total"] == 3)
|
||||||
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})
|
||||||
quotes = r.json()["data"] if r.status_code == 200 else []
|
check("GET /quotes", r.status_code == 200 and r.json()["meta"]["total"] == 7, r.text[:300])
|
||||||
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"})
|
r = c.get("/quotes", params={"expand": "line_items,termsheets,events,supplier,project", "status": "accepted"})
|
||||||
accepted = [q for q in r.json().get("data", []) if q.get("line_items")] if r.status_code == 200 else []
|
if r.status_code == 200 and r.json()["data"]:
|
||||||
if accepted:
|
q = r.json()["data"][0]
|
||||||
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",
|
||||||
@@ -182,34 +111,21 @@ if accepted:
|
|||||||
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"]]))
|
||||||
if q["termsheets"]:
|
check("termsheet has schedule_steps", "schedule_steps" in q["termsheets"][0])
|
||||||
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")
|
||||||
stats_ok = r.status_code == 200 and "average_lead_time_days" in r.json()["data"]
|
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])
|
||||||
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)
|
||||||
|
|
||||||
if materials:
|
r = c.get("/quotes", params={"q": "میلگرد"})
|
||||||
needle = materials[0]["name"].split()[0]
|
check("GET /quotes?q (searches material names)", r.status_code == 200 and r.json()["meta"]["total"] >= 1, r.text[:300])
|
||||||
r = c.get("/quotes", params={"q": needle})
|
|
||||||
check("GET /quotes?q (searches material names)", r.status_code == 200, r.text[:300])
|
|
||||||
|
|
||||||
if projects:
|
r = c.get("/projects/{pid}/stats".format(pid=c.get("/projects", params={"q": "ونوس"}).json()["data"][0]["id"]))
|
||||||
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])
|
||||||
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,20 +19,9 @@ 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"]["si_factor"] == 1.0, r.text[:200])
|
check("POST /units", r.status_code == 201 and r.json()["data"]["id"] > 19, 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])
|
||||||
@@ -53,28 +42,21 @@ 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)
|
||||||
_bml = c.get("/brand-materials", params={"page_size": 1}).json()["data"]
|
r = c.get("/brands", params={"q": "فولاد مبارکه"})
|
||||||
if _bml:
|
brand = r.json()["data"][0]
|
||||||
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": "دسته تست"})
|
||||||
if r.status_code == 409: # rerun safety — fetch the existing test category
|
check("POST /material-categories", r.status_code == 201, r.text[:200])
|
||||||
cid = c.get("/material-categories", params={"q": "test-cat"}).json()["data"][0]["id"]
|
cid = r.json()["data"]["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)
|
||||||
_mat0 = c.get("/materials", params={"page_size": 1, "expand": "category"}).json()["data"][0]
|
r = c.get("/material-categories", params={"q": "میلگرد"})
|
||||||
cat = _mat0["category"]
|
cat = r.json()["data"][0]
|
||||||
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])
|
||||||
|
|
||||||
@@ -93,16 +75,11 @@ 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
|
||||||
# (dataset-independent: any category; rerun reuses the existing test material)
|
cat = c.get("/material-categories", params={"q": "میلگرد"}).json()["data"][0]
|
||||||
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})
|
||||||
if r.status_code == 409: # rerun
|
check("create test material for EAV tests", r.status_code == 201, r.text[:200])
|
||||||
tmat = c.get("/materials", params={"q": "متریال تست EAV"}).json()["data"][0]
|
tmat = r.json()["data"]
|
||||||
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},
|
||||||
@@ -122,11 +99,7 @@ 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"})
|
||||||
if r.status_code == 409: # rerun safety — fetch the existing definition
|
newdef = r.json()["data"]
|
||||||
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"]})
|
||||||
@@ -141,11 +114,10 @@ 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])
|
||||||
_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": 6, "conversion_factor": 0.001, "is_default": False})
|
||||||
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": _unit_other["id"], "conversion_factor": 2})
|
r = c.post(f"/materials/{mat['id']}/units", json={"unit_id": 6, "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)
|
||||||
@@ -153,10 +125,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 ----------
|
||||||
# dataset-independent: any material + a freshly created brand (no existing link)
|
r = c.get("/materials", params={"q": "سیمان پرتلند تیپ ۲"})
|
||||||
m2 = c.get("/materials", params={"page_size": 1}).json()["data"][0]
|
m2 = r.json()["data"][0]
|
||||||
r = c.post("/brands", json={"name": "برند لینک تست", "country": "ایران"})
|
r = c.get("/brands", params={"q": "Bosch"})
|
||||||
b2 = r.json()["data"] if r.status_code == 201 else c.get("/brands", params={"q": "برند لینک تست"}).json()["data"][0]
|
b2 = r.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"]
|
||||||
@@ -168,18 +140,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 ----------
|
||||||
s1 = c.get("/suppliers", params={"page_size": 1}).json()["data"][0]
|
r = c.get("/suppliers", params={"q": "آریا"})
|
||||||
|
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"})
|
||||||
p1 = c.get("/projects", params={"page_size": 1}).json()["data"][0]
|
r = c.get("/projects", params={"q": "ونوس"})
|
||||||
|
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": p1.get("description")})
|
r = c.patch(f"/projects/{p1['id']}", json={"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})
|
||||||
@@ -194,17 +166,16 @@ 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"]
|
||||||
while len(ts) < 2:
|
if len(ts) == 1:
|
||||||
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 additional termsheet", r.status_code == 201, r.text[:200])
|
check("POST second 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])
|
||||||
_remaining = c.get(f"/quotes/{qid}/termsheets").json()["data"]
|
r = c.delete(f"/quotes/{qid}/termsheets/{ts[1]['id']}")
|
||||||
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}]})
|
||||||
@@ -221,52 +192,31 @@ 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 (need a PENDING decision — rerun-safe pick) ----------
|
# ---------- decision rules (on a pending quote) ----------
|
||||||
_pending = None
|
r = c.get(f"/quotes/{qid}/line-items")
|
||||||
for _qo in c.get("/quotes", params={"status": "offered", "page_size": 20, "expand": "line_items"}).json()["data"]:
|
li = r.json()["data"][0]
|
||||||
for _lix in _qo["line_items"]:
|
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
|
||||||
if _lix.get("decision") and _lix["decision"]["status"] == "pending":
|
check("PUT decision chosen with foreign link -> 400", r.status_code == 400, r.text[:200])
|
||||||
_pending = (_qo["id"], _lix)
|
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "rejected"})
|
||||||
break
|
check("PUT decision rejected", r.status_code == 200 and r.json()["data"]["status"] == "rejected", r.text[:200])
|
||||||
if _pending:
|
r = c.put(f"/quotes/{qid}/line-items/{li['id']}/decision", json={"status": "chosen"})
|
||||||
break
|
check("PUT decision rejected->chosen -> 409", r.status_code == 409, r.text[:200])
|
||||||
if _pending:
|
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/decision/status", json={"status": "pending"})
|
||||||
_qid2, li = _pending
|
# pending is NOT an accepted payload value — the endpoint only transitions to chosen/rejected
|
||||||
r = c.put(f"/quotes/{_qid2}/line-items/{li['id']}/decision", json={"status": "chosen", "chosen_item_termsheet_id": "00000000-0000-0000-0000-000000000000"})
|
check("PATCH decision status to pending -> 400", r.status_code == 400, r.text[:200])
|
||||||
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 ----------
|
||||||
# termsheet-count tolerance marker
|
r = c.get(f"/quotes/{qid}/line-items/{li['id']}/termsheets")
|
||||||
if _pending:
|
its = r.json()["data"]
|
||||||
_its_quote, li = _pending
|
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": 9999})
|
||||||
r = c.get(f"/quotes/{_its_quote}/line-items/{li['id']}/termsheets")
|
check("PATCH item-termsheet", r.status_code == 200 and r.json()["data"]["unit_price"] == 9999.0, r.text[:200])
|
||||||
its = r.json().get("data", [])
|
r = c.patch(f"/quotes/{qid}/line-items/{li['id']}/termsheets/{its[0]['id']}", json={"unit_price": its[0]["unit_price"]})
|
||||||
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,45 +23,16 @@ 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 ----------
|
||||||
# dataset-independent: fetch/create whatever the seed does or doesn't have
|
sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران"}).json()["data"]
|
||||||
cat = c.get("/material-categories", params={"page_size": 1}).json()["data"][0]
|
proj = c.post("/projects", json={"name": "پروژه تست", "description": "تست خودکار"}).json()["data"]
|
||||||
r_sup = c.post("/suppliers", json={"name": "تأمینکننده تست", "location": "تهران", "category_id": cat["id"]})
|
check("POST /suppliers + /projects", True)
|
||||||
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={"page_size": 1}).json()["data"][0]
|
brand = c.get("/brands", params={"q": "فولاد مبارکه"}).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",
|
||||||
@@ -77,15 +48,11 @@ r = c.post(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if r.status_code == 201:
|
check("POST /materials (EAV+brands)", r.status_code == 201, r.text[:300])
|
||||||
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 any(m["id"] == material["id"] for m in r.json()["data"]))
|
check("new material findable via EAV filter", r.status_code == 200 and r.json()["meta"]["total"] == 1)
|
||||||
|
|
||||||
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])
|
||||||
@@ -101,32 +68,21 @@ 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})
|
||||||
if r.status_code == 409: # rerun — reuse the existing conversion
|
check("POST /unit-conversions", r.status_code == 201, r.text[:200])
|
||||||
uc = next(x for x in c.get("/unit-conversions", params={"page_size": 100}).json()["data"]
|
uc = r.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})
|
||||||
if r.status_code == 201:
|
check("POST /standard-conversions", r.status_code == 201, r.text[:200])
|
||||||
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 ----------
|
||||||
_all_materials = c.get("/materials", params={"page_size": 100}).json()["data"]
|
material2 = c.get("/materials", params={"q": "میلگرد A3 سایز ۱۴"}).json()["data"][0]
|
||||||
material2 = next(m for m in _all_materials if m["id"] != material["id"])
|
|
||||||
r = c.post(
|
r = c.post(
|
||||||
"/quotes",
|
"/quotes",
|
||||||
json={
|
json={
|
||||||
@@ -169,11 +125,7 @@ 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": []})
|
||||||
# current contract: termsheets are optional at quote level (item pricing
|
check("POST /quotes empty termsheets -> 400", r.status_code == 400 and r.json()["error"]["field"] == "termsheets")
|
||||||
# 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}],
|
||||||
@@ -281,49 +233,33 @@ 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 (v2.6 rule: quotes WITH inventory receipts
|
# ---------- backward transition ----------
|
||||||
# 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(
|
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])
|
||||||
"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 unchanged after blocked revert", decisions == ["chosen", "chosen", "rejected"], str(decisions))
|
check("decisions reset to pending", decisions == ["pending", "pending", "pending"], 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.patch(f"/quotes/{q3['id']}", json={"status": "accepted"})
|
r = c.get(f"/quotes/{qid}", params={"expand": "line_items"})
|
||||||
|
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/{q3['id']}/events")
|
r = c.get(f"/quotes/{qid}/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/{q3['id']}/expire")
|
r = c.patch(f"/quotes/{qid}/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/{q3['id']}")
|
r = c.delete(f"/quotes/{qid}")
|
||||||
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 1, r.text[:300])
|
check("DELETE expired quote", r.status_code == 200 and r.json()["data"]["cascaded"]["line_items"] == 3, 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"],
|
||||||
@@ -333,13 +269,8 @@ 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 (any-status contract)", r.status_code == 201, r.text[:200])
|
check("POST invoice on offered -> 409", r.status_code == 409, 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])
|
||||||
@@ -351,8 +282,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 ----------
|
# cleanup test material + supplier + project
|
||||||
# q2 (offered) is deletable; qid is a receipts-protected artifact that stays.
|
# q2 still references supplier/project/material -> all deletes must be blocked
|
||||||
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']}")
|
||||||
@@ -360,15 +291,14 @@ 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']}")
|
r = c.delete(f"/quotes/{q2['id']}") # q2 still references the test material
|
||||||
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 (still referenced by receipt-protected quote) -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE material (no line_items refs)", r.status_code == 200, r.text[:200])
|
||||||
r = c.delete(f"/suppliers/{sup['id']}")
|
r = c.delete(f"/suppliers/{sup['id']}")
|
||||||
check("DELETE supplier (still referenced) -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE supplier (no quotes) -> 200", r.status_code == 200, r.text[:200])
|
||||||
r = c.delete(f"/projects/{proj['id']}")
|
r = c.delete(f"/projects/{proj['id']}")
|
||||||
check("DELETE project (still referenced) -> 409", r.status_code == 409, r.text[:200])
|
check("DELETE project (no quotes) -> 200", r.status_code == 200, 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.
+27
-54
@@ -1,72 +1,32 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS
|
# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS
|
||||||
# redirect + security middlewares + required secrets.
|
# redirect. Usage:
|
||||||
#
|
#
|
||||||
# All credentials come from the git-ignored .env NEXT TO THE COMPOSE FILES
|
# APP_HOST=app.example.com API_HOST=api.example.com AUTH_HOST=auth.example.com \
|
||||||
# (compose loads it automatically — keep it on the VPS, never in git):
|
# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \
|
||||||
#
|
# TRAEFIK_ACME_EMAIL=admin@example.com \
|
||||||
# cp .env.example .env # then fill in STRONG values
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
|
||||||
#
|
#
|
||||||
# Requirements: DNS records for every host pointing at the server, ports
|
# Requirements: DNS records for every host pointing at the server, ports
|
||||||
# 80 + 443 open (ACME http-challenge uses port 80). The api's issuer is
|
# 80 + 443 open (ACME http-challenge uses port 80), and the api's
|
||||||
# derived here from AUTH_HOST (see docs/casdoor-auth.md).
|
# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host
|
||||||
#
|
# (see docs/casdoor-auth.md).
|
||||||
# Any `${VAR:?}` below refuses to boot when the variable is missing/empty —
|
|
||||||
# that is deliberate: production must not run on defaults.
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
environment:
|
environment:
|
||||||
# Certificates persist across restarts (acme.json in the traefik_certs volume).
|
# Certificates persist across restarts (acme.json in the traefik_certs volume).
|
||||||
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL in .env}
|
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL for production}
|
||||||
|
|
||||||
postgres:
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
|
||||||
|
|
||||||
minio:
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
|
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
|
|
||||||
labels:
|
|
||||||
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
|
|
||||||
- traefik.http.routers.minio-api.entrypoints=websecure
|
|
||||||
- traefik.http.routers.minio-api.tls=true
|
|
||||||
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
|
|
||||||
- traefik.http.routers.minio-api.middlewares=secure-headers@file
|
|
||||||
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
|
|
||||||
- traefik.http.routers.minio-api-redir.entrypoints=web
|
|
||||||
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
|
|
||||||
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
|
|
||||||
- traefik.http.routers.minio-console.entrypoints=websecure
|
|
||||||
- traefik.http.routers.minio-console.tls=true
|
|
||||||
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
|
|
||||||
- traefik.http.routers.minio-console.middlewares=secure-headers@file
|
|
||||||
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
|
|
||||||
- traefik.http.routers.minio-console-redir.entrypoints=web
|
|
||||||
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
|
|
||||||
|
|
||||||
api:
|
api:
|
||||||
ports: [] # production: only reachable through Traefik
|
ports: [] # production: only reachable through Traefik
|
||||||
environment:
|
|
||||||
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/procurement
|
|
||||||
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER in .env}
|
|
||||||
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD in .env}
|
|
||||||
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:?set CASDOOR_CLIENT_SECRET in .env}
|
|
||||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:?set CASDOOR_ADMIN_PASSWORD in .env}
|
|
||||||
# Issuer must equal the browser-facing Casdoor origin (its discovery doc).
|
|
||||||
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST:?set AUTH_HOST}
|
|
||||||
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:?set CORS_ORIGINS in .env}
|
|
||||||
PROCUREMENT_ENVIRONMENT: prod
|
|
||||||
PROCUREMENT_DEV_LOGIN_ENABLED: "false"
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
|
- traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`)
|
||||||
- traefik.http.routers.api.entrypoints=websecure
|
- traefik.http.routers.api.entrypoints=websecure
|
||||||
- traefik.http.routers.api.tls=true
|
- traefik.http.routers.api.tls=true
|
||||||
- traefik.http.routers.api.tls.certresolver=letsencrypt
|
- traefik.http.routers.api.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file,api-bodylimit@file
|
# HTTP → HTTPS
|
||||||
- traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`)
|
- traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`)
|
||||||
- traefik.http.routers.api-redir.entrypoints=web
|
- traefik.http.routers.api-redir.entrypoints=web
|
||||||
- traefik.http.routers.api-redir.middlewares=redirect-https@file
|
- traefik.http.routers.api-redir.middlewares=redirect-https@file
|
||||||
@@ -81,21 +41,34 @@ services:
|
|||||||
- traefik.http.routers.casdoor.entrypoints=websecure
|
- traefik.http.routers.casdoor.entrypoints=websecure
|
||||||
- traefik.http.routers.casdoor.tls=true
|
- traefik.http.routers.casdoor.tls=true
|
||||||
- traefik.http.routers.casdoor.tls.certresolver=letsencrypt
|
- traefik.http.routers.casdoor.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.casdoor.middlewares=secure-headers@file
|
|
||||||
- traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`)
|
- traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`)
|
||||||
- traefik.http.routers.casdoor-redir.entrypoints=web
|
- traefik.http.routers.casdoor-redir.entrypoints=web
|
||||||
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
|
- traefik.http.routers.casdoor-redir.middlewares=redirect-https@file
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
|
||||||
args:
|
|
||||||
DEV_LOGIN_ENABLED: "false"
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
||||||
- traefik.http.routers.frontend.entrypoints=websecure
|
- traefik.http.routers.frontend.entrypoints=websecure
|
||||||
- traefik.http.routers.frontend.tls=true
|
- traefik.http.routers.frontend.tls=true
|
||||||
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
|
- traefik.http.routers.frontend.tls.certresolver=letsencrypt
|
||||||
- traefik.http.routers.frontend.middlewares=secure-headers@file
|
|
||||||
- traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
- traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`)
|
||||||
- traefik.http.routers.frontend-redir.entrypoints=web
|
- traefik.http.routers.frontend-redir.entrypoints=web
|
||||||
- traefik.http.routers.frontend-redir.middlewares=redirect-https@file
|
- traefik.http.routers.frontend-redir.middlewares=redirect-https@file
|
||||||
|
|
||||||
|
minio:
|
||||||
|
ports: [] # production: only reachable through Traefik
|
||||||
|
labels:
|
||||||
|
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`)
|
||||||
|
- traefik.http.routers.minio-api.entrypoints=websecure
|
||||||
|
- traefik.http.routers.minio-api.tls=true
|
||||||
|
- traefik.http.routers.minio-api.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`)
|
||||||
|
- traefik.http.routers.minio-api-redir.entrypoints=web
|
||||||
|
- traefik.http.routers.minio-api-redir.middlewares=redirect-https@file
|
||||||
|
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
|
||||||
|
- traefik.http.routers.minio-console.entrypoints=websecure
|
||||||
|
- traefik.http.routers.minio-console.tls=true
|
||||||
|
- traefik.http.routers.minio-console.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`)
|
||||||
|
- traefik.http.routers.minio-console-redir.entrypoints=web
|
||||||
|
- traefik.http.routers.minio-console-redir.middlewares=redirect-https@file
|
||||||
|
|||||||
+17
-95
@@ -6,17 +6,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: procurement
|
POSTGRES_DB: procurement
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
# Local dev default; production overrides via .env (required by the
|
POSTGRES_PASSWORD: postgres
|
||||||
# prod overlay) — never commit real passwords.
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- shared # monitoring stack scrapes it (postgres-exporter)
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- postgres_data:/var/lib/postgresql
|
||||||
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
|
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
|
||||||
@@ -29,7 +23,6 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 1g
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
|
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
|
||||||
@@ -43,7 +36,6 @@ services:
|
|||||||
image: traefik:v3.6
|
image: traefik:v3.6
|
||||||
container_name: procurement-traefik
|
container_name: procurement-traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 128m
|
|
||||||
command:
|
command:
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
@@ -77,8 +69,8 @@ services:
|
|||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
MINIO_ROOT_USER: minioadmin
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000" # S3 API
|
- "9000:9000" # S3 API
|
||||||
@@ -105,7 +97,6 @@ services:
|
|||||||
- default
|
- default
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 512m
|
|
||||||
|
|
||||||
# MinIO Client (mc): waits for the server, creates the bucket, exits.
|
# MinIO Client (mc): waits for the server, creates the bucket, exits.
|
||||||
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
|
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
|
||||||
@@ -119,8 +110,8 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
MINIO_ENDPOINT: minio:9000
|
MINIO_ENDPOINT: minio:9000
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
MINIO_ROOT_USER: minioadmin
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
MINIO_BUCKET: procurement-files
|
MINIO_BUCKET: procurement-files
|
||||||
|
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
@@ -134,40 +125,6 @@ services:
|
|||||||
|
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# Scheduled logical backups of BOTH databases (procurement + casdoor).
|
|
||||||
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
|
|
||||||
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
|
|
||||||
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
|
|
||||||
# ==========================================================================
|
|
||||||
db-backup:
|
|
||||||
image: postgres:18
|
|
||||||
container_name: procurement-db-backup
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
|
||||||
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
|
||||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
|
||||||
volumes:
|
|
||||||
- ${BACKUP_DIR:-./backups}:/backups
|
|
||||||
entrypoint: >
|
|
||||||
/bin/sh -c "
|
|
||||||
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
|
|
||||||
while true; do
|
|
||||||
STAMP=$$(date +%Y%m%d-%H%M%S);
|
|
||||||
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
|
|
||||||
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
|
|
||||||
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
|
|
||||||
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
|
|
||||||
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
|
|
||||||
sleep $${BACKUP_INTERVAL_SECONDS};
|
|
||||||
done
|
|
||||||
"
|
|
||||||
restart: unless-stopped
|
|
||||||
mem_limit: 256m
|
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# v2.6 — Casdoor (OIDC identity provider)
|
# v2.6 — Casdoor (OIDC identity provider)
|
||||||
# Console: http://localhost:8080 (admin / 123)
|
# Console: http://localhost:8080 (admin / 123)
|
||||||
@@ -205,7 +162,6 @@ services:
|
|||||||
- default
|
- default
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 768m
|
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
@@ -214,58 +170,38 @@ services:
|
|||||||
container_name: procurement-api
|
container_name: procurement-api
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
|
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||||
PROCUREMENT_MINIO_ENDPOINT: minio:9000
|
PROCUREMENT_MINIO_ENDPOINT: minio:9000
|
||||||
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
|
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
|
||||||
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
||||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||||
PROCUREMENT_MINIO_SECURE: "false"
|
PROCUREMENT_MINIO_SECURE: "false"
|
||||||
# "dev" enables the local defaults; "prod" turns on the fail-fast
|
|
||||||
# startup checks (no wildcard CORS, no default credentials).
|
|
||||||
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
|
|
||||||
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
|
|
||||||
# Comma-separated browser origins (JSON-free .env friendly).
|
|
||||||
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
|
|
||||||
# Casdoor: endpoint is the in-network service URL; the issuer must match
|
# Casdoor: endpoint is the in-network service URL; the issuer must match
|
||||||
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
||||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||||
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
|
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080
|
||||||
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
||||||
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
||||||
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
||||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||||
# Console admin used by POST /users/sync (identity mirroring).
|
# Console admin used by POST /users/sync (identity mirroring).
|
||||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
|
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
||||||
# Observability (Phase 2) — see monitoring/README.md
|
|
||||||
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
|
|
||||||
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
|
|
||||||
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
|
|
||||||
PROCUREMENT_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
|
|
||||||
PROCUREMENT_OPENPANEL_CLIENT_SECRET: ${OPENPANEL_CLIENT_SECRET:-}
|
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
networks:
|
|
||||||
- traefik_network
|
|
||||||
- default
|
|
||||||
- shared # monitoring stack scrapes api:8000/metrics
|
|
||||||
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)
|
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)
|
||||||
- traefik.http.routers.api.entrypoints=web
|
- traefik.http.routers.api.entrypoints=web
|
||||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- traefik_network
|
||||||
|
- default
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -273,21 +209,13 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 768m
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
# Developer Mode panel on the login page — dev-only aid. Production
|
# Developer Mode panel on the login page — set false for production.
|
||||||
# builds MUST leave this false (identity comes from Casdoor tokens).
|
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
|
||||||
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
|
|
||||||
# Registry overridable for networks where npmmirror is unreachable.
|
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
|
|
||||||
# Observability (build-time baked; empty = disabled)
|
|
||||||
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
|
|
||||||
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
|
|
||||||
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
|
|
||||||
container_name: procurement-frontend
|
container_name: procurement-frontend
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
@@ -304,7 +232,6 @@ services:
|
|||||||
- api
|
- api
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: 128m
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||||
@@ -312,11 +239,6 @@ networks:
|
|||||||
traefik_network:
|
traefik_network:
|
||||||
name: traefik_network
|
name: traefik_network
|
||||||
driver: bridge
|
driver: bridge
|
||||||
# Fixed-name network shared with the monitoring stack (monitoring compose
|
|
||||||
# joins it as external) — carries /metrics scrapes and postgres-exporter.
|
|
||||||
shared:
|
|
||||||
name: erp_shared
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
# Operations runbook
|
|
||||||
|
|
||||||
Day-2 operations for the ERP stack: deploys, health, backups, monitoring.
|
|
||||||
Secrets and rotation live in [`SECRET-MANAGEMENT.md`](SECRET-MANAGEMENT.md);
|
|
||||||
Casdoor/auth details in [`casdoor-auth.md`](casdoor-auth.md).
|
|
||||||
|
|
||||||
## Deploy flow (push-to-deploy)
|
|
||||||
|
|
||||||
1. Work lands on `production` only via merge from a feature branch.
|
|
||||||
2. Every push to `production` hits the webhook receiver (host port 9000),
|
|
||||||
which verifies the Gitea HMAC signature, rebuilds
|
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
|
|
||||||
and then polls `/health/ready` (defaults to
|
|
||||||
`https://$API_HOST/health/ready` from `.env`).
|
|
||||||
3. A failed build or a failed readiness check is a **failed deploy**:
|
|
||||||
`journalctl -u erp-deploy-webhook -n 200` on the VPS.
|
|
||||||
Rollback: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-build`
|
|
||||||
after `git revert` + rebuild — or re-push the previous commit.
|
|
||||||
|
|
||||||
## Health endpoints (unauthenticated, for probes)
|
|
||||||
|
|
||||||
| Endpoint | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `GET /health` | liveness — process is up |
|
|
||||||
| `GET /health/ready` | readiness — Postgres + MinIO + Casdoor all reachable (503 + per-check booleans when degraded) |
|
|
||||||
| `GET /metrics` | Prometheus exposition (scraped by the monitoring stack) |
|
|
||||||
|
|
||||||
## Backups
|
|
||||||
|
|
||||||
The `db-backup` sidecar dumps BOTH databases (`procurement`, `casdoor`)
|
|
||||||
with `pg_dump -Fc` every `BACKUP_INTERVAL_SECONDS` (default daily) into
|
|
||||||
`BACKUP_DIR` (default `./backups` → set to `/srv/erp/backups` on the VPS),
|
|
||||||
keeping `BACKUP_KEEP_DAYS` (default 14) days.
|
|
||||||
|
|
||||||
Restore (⚠️ replaces all data — stop `api` + `casdoor` first):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /srv/erp && docker compose -f docker-compose.yml -f docker-compose.prod.yml stop api casdoor
|
|
||||||
docker cp backups/procurement-<stamp>.dump procurement-postgres:/tmp/
|
|
||||||
docker cp backups/casdoor-<stamp>.dump procurement-postgres:/tmp/
|
|
||||||
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement-<stamp>.dump
|
|
||||||
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor-<stamp>.dump
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml start casdoor api
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test the restore quarterly** on a scratch volume — an untested backup is
|
|
||||||
a hope, not a backup. Copy archives off-box periodically (the bind mount is
|
|
||||||
still on the same disk as the volumes).
|
|
||||||
|
|
||||||
## Audit trail
|
|
||||||
|
|
||||||
`audit_log` (in the `procurement` DB) records who changed what for
|
|
||||||
role assignments, user creation, and inventory transactions. Query recent
|
|
||||||
activity:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec procurement-postgres psql -U postgres -d procurement \
|
|
||||||
-c "SELECT occurred_at, actor, action, entity, entity_id FROM audit_log ORDER BY occurred_at DESC LIMIT 50;"
|
|
||||||
```
|
|
||||||
|
|
||||||
`quote_events` continues to hold the quote lifecycle history (its `actor`
|
|
||||||
is now the verified user's display name, not a raw token).
|
|
||||||
|
|
||||||
## Monitoring stack
|
|
||||||
|
|
||||||
Start/update (separate compose project — app rebuilds don't touch it):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /srv/erp
|
|
||||||
docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Grafana** — `127.0.0.1:3001` on the VPS (SSH tunnel), admin password in
|
|
||||||
`.env`; the «ERP — Stack overview» dashboard auto-provisions.
|
|
||||||
- **GlitchTip** — public on `GLITCHTIP_HOST` (browser DSN) + loopback
|
|
||||||
`:8001` for administration. Create backend/frontend projects there and put
|
|
||||||
the DSNs in `.env` (`SENTRY_DSN_BACKEND` / `SENTRY_DSN_FRONTEND`).
|
|
||||||
- **OpenPanel** — see `monitoring/README.md` for the self-hosting setup and
|
|
||||||
the event list the app emits.
|
|
||||||
- **Alerts** — Prometheus rules surface in Grafana alerting (API down,
|
|
||||||
5xx rate, p95 latency, disk, RAM, DB connections). Attach a notification
|
|
||||||
channel in the Grafana UI.
|
|
||||||
|
|
||||||
Logs: every container's stdout ships to Loki via Promtail — in Grafana:
|
|
||||||
`{compose_project="erp", compose_service="api"}`; JSON lines carry
|
|
||||||
`request_id`, `http_status`, `duration_ms`.
|
|
||||||
|
|
||||||
## Routine checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml ps # all Up/healthy?
|
|
||||||
docker compose logs --since 10m api | grep -i error
|
|
||||||
ls -lh /srv/erp/backups | tail # dumps fresh?
|
|
||||||
curl -fsS https://$API_HOST/health/ready | jq # all checks true?
|
|
||||||
df -h / # ≥20% free (builds need headroom)
|
|
||||||
```
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# Secret management & rotation
|
|
||||||
|
|
||||||
**Policy (short):** real credentials live only in git-ignored `.env` files —
|
|
||||||
the server-side `/srv/erp/.env` (used by the deploy webhook and compose) and
|
|
||||||
local dev `.env` copies. Nothing secret goes into git: not in the README,
|
|
||||||
not in dumps, not in docs. The repo is (or was) **public** — assume anything
|
|
||||||
ever committed is compromised and rotate.
|
|
||||||
|
|
||||||
`docker-compose.prod.yml` declares every production secret as
|
|
||||||
`${VAR:?set in .env}` so the stack **refuses to boot** on defaults.
|
|
||||||
|
|
||||||
## Where each secret lives
|
|
||||||
|
|
||||||
| Secret | Used by | Defined in |
|
|
||||||
|---|---|---|
|
|
||||||
| `POSTGRES_PASSWORD` | postgres, api | server `.env` |
|
|
||||||
| `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | minio, minio-init, api | server `.env` |
|
|
||||||
| `CASDOOR_CLIENT_SECRET` | Casdoor application record, api | server `.env` (must match the app in the Casdoor console) |
|
|
||||||
| `CASDOOR_ADMIN_PASSWORD` | Casdoor console admin, api (`/users/sync`) | server `.env` (rotate in the Casdoor console too) |
|
|
||||||
| `CORS_ORIGINS`, `*_HOST`, `TRAEFIK_ACME_EMAIL` | compose / Traefik | server `.env` |
|
|
||||||
| `GITEA_WEBHOOK_SECRET` | deploy-webhook receiver (port 9000) | server `.env` + Gitea webhook config |
|
|
||||||
| Monitoring DSNs/keys | OpenPanel, GlitchTip, Grafana | `monitoring/.env` (server-side) |
|
|
||||||
|
|
||||||
## Rotation checklist — run after any leak suspicion
|
|
||||||
|
|
||||||
The production branch history contained real credentials (MinIO admin,
|
|
||||||
Casdoor passwords, server IP, DB dumps). All of the following must be
|
|
||||||
rotated once, on the VPS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /srv/erp
|
|
||||||
|
|
||||||
# 1) MinIO root — then update MINIO_ROOT_* in /srv/erp/.env
|
|
||||||
mc alias set local http://127.0.0.1:9000 <old-user> '<old-pass>'
|
|
||||||
mc admin user add local <new-user> '<new-pass>' # then:
|
|
||||||
mc admin policy attach local consoleAdmin --user <new-user>
|
|
||||||
mc admin user remove local <old-user>
|
|
||||||
# (simplest equivalent: mc admin user svcacct or change root via env +
|
|
||||||
# `docker compose up -d minio` — MinIO persists root creds from env)
|
|
||||||
|
|
||||||
# 2) PostgreSQL — then update POSTGRES_PASSWORD in .env
|
|
||||||
docker exec -it procurement-postgres psql -U postgres -c \
|
|
||||||
"ALTER USER postgres PASSWORD '<new-strong-password>';"
|
|
||||||
docker compose up -d api # api re-reads the DSN from .env
|
|
||||||
|
|
||||||
# 3) Casdoor console admin (console → Identity → users → admin → reset
|
|
||||||
# password) — then update CASDOOR_ADMIN_PASSWORD in .env
|
|
||||||
# 4) Casdoor application client secret (console → Applications →
|
|
||||||
# inventory-app → edit secret) — then update CASDOOR_CLIENT_SECRET in .env
|
|
||||||
# 5) Seeded human users: reset each password in the Casdoor console.
|
|
||||||
# 6) Gitea webhook secret (Gitea → repo → Settings → Webhooks) — then update
|
|
||||||
# GITEA_WEBHOOK_SECRET in .env and restart the receiver (systemctl restart erp-deploy-webhook)
|
|
||||||
|
|
||||||
# 7) Apply everything:
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
- **SSH**: disable password login (`PasswordAuthentication no` in
|
|
||||||
`/etc/ssh/sshd_config`), use keys only, and prefer a non-root user with
|
|
||||||
sudo over `root` direct login.
|
|
||||||
- **Repo hygiene**: keep the repo private until the git history is either
|
|
||||||
purged (BFG / `git filter-repo` on the README + `backups/` blobs) or every
|
|
||||||
leaked value is rotated. Deleting a file in a later commit does **not**
|
|
||||||
remove it from history.
|
|
||||||
- **Verify**: after rotation, `docker compose logs api | grep -i error`,
|
|
||||||
log into the app, upload + download one attachment, and confirm the
|
|
||||||
webhook deploy still works with a test push.
|
|
||||||
+11
-11
@@ -12,7 +12,7 @@ scenarios, test results, and how to manage the system yourself.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
|
| App (frontend) | http://localhost (Traefik edge; production: your domain) | — |
|
||||||
| API (backend) | http://localhost:8000 (Swagger: `/docs`) | — |
|
| API (backend) | http://localhost:8000 (Swagger: `/docs`) | — |
|
||||||
| Casdoor console | http://localhost:8080 | admin credentials come from `CASDOOR_ADMIN_PW` when running the seeder — never committed |
|
| Casdoor console | http://localhost:8080 | **admin / 123** (built-in org) |
|
||||||
| Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — |
|
| Casdoor OIDC discovery | http://localhost:8080/.well-known/openid-configuration | — |
|
||||||
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
|
| Casdoor public JWKS | http://localhost:8080/.well-known/jwks | — |
|
||||||
| In-network Casdoor (from the api container) | http://casdoor:8000 | — |
|
| In-network Casdoor (from the api container) | http://casdoor:8000 | — |
|
||||||
@@ -22,13 +22,13 @@ scenarios, test results, and how to manage the system yourself.
|
|||||||
|
|
||||||
| Username | Password | Casdoor org | App role (our `users` table) |
|
| Username | Password | Casdoor org | App role (our `users` table) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `razaei` | `CASDOOR_SEED_PASSWORD` | inventory | `admin` (super user) |
|
| `razaei` | `123456` | inventory | `admin` (super user) |
|
||||||
| `mousavi` | `CASDOOR_SEED_PASSWORD` | inventory | `procurement_manager` (مدیر تدارکات) |
|
| `mousavi` | `123456` | inventory | `procurement_manager` (مدیر تدارکات) |
|
||||||
| `mohaghegh` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
|
| `mohaghegh` | `123456` | inventory | `pm` (مدیر پروژه، بدون پروژه) |
|
||||||
| `tester` | `CASDOOR_SEED_PASSWORD` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
|
| `tester` | `123456` | inventory | `pm` (مدیر پروژه — پروژه «تست») |
|
||||||
|
|
||||||
Passwords are stored bcrypt-hashed (passwordType `bcrypt`). These users are
|
These users are created by `backend/tools/seed_casdoor.py` (idempotent — safe
|
||||||
created by `backend/tools/seed_casdoor.py` (idempotent — safe to re-run).
|
to re-run).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ identity only.** No new authorization model was introduced.
|
|||||||
the OIDC client. Grant types: `authorization_code`, `refresh_token`,
|
the OIDC client. Grant types: `authorization_code`, `refresh_token`,
|
||||||
`password`. Token format JWT (so the backend can verify via JWKS).
|
`password`. Token format JWT (so the backend can verify via JWKS).
|
||||||
- **Casdoor Organization** (`inventory`): the tenant; every app user belongs
|
- **Casdoor Organization** (`inventory`): the tenant; every app user belongs
|
||||||
to it. (The Casdoor console admin lives in the separate
|
to it. (The Casdoor console admin `admin/123` lives in the separate
|
||||||
`built-in` org.)
|
`built-in` org.)
|
||||||
- **User — two records per person:**
|
- **User — two records per person:**
|
||||||
1. Casdoor user (login credential: name + password).
|
1. Casdoor user (login credential: name + password).
|
||||||
@@ -158,7 +158,7 @@ After that, log in as the admin and use the Organization panel
|
|||||||
|
|
||||||
| Test | Action | Result | Evidence |
|
| Test | Action | Result | Evidence |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Login: `POST /api/v1/auth/login` (tester / CASDOOR_SEED_PASSWORD) | **PASS** | 200, JWT + profile returned |
|
| 1 | Login: `POST /api/v1/auth/login` (tester/123456) | **PASS** | 200, JWT + profile returned |
|
||||||
| 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» |
|
| 2 | Authenticated API: `GET /users/me` with token | **PASS** | 200, role `pm` + project «تست» |
|
||||||
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
|
| 3 | No token: `GET /inventory/warehouses` | **PASS** | 401 `UNAUTHORIZED` |
|
||||||
| 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» |
|
| 4 | Invalid token: corrupted Bearer | **PASS** | 401 `UNAUTHORIZED` «توکن نامعتبر است» |
|
||||||
@@ -179,7 +179,7 @@ After that, log in as the admin and use the Organization panel
|
|||||||
| 6 | Organization exists | ✅ `inventory` |
|
| 6 | Organization exists | ✅ `inventory` |
|
||||||
| 7 | Application exists | ✅ `inventory-app` |
|
| 7 | Application exists | ✅ `inventory-app` |
|
||||||
| 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) |
|
| 8 | Client exists | ✅ `inventory-app-client` (grants incl. `password`) |
|
||||||
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password: CASDOOR_SEED_PASSWORD) |
|
| 9 | Test users exist | ✅ razaei, mousavi, mohaghegh, tester (password 123456) |
|
||||||
| 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` |
|
| 10 | Redirect URI matches | ✅ N/A for the in-page flow (redirects unused). The seeded app holds the legacy `http://localhost:3000/login`; the seeder now defaults to `http://localhost/login` |
|
||||||
| 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified |
|
| 11 | OIDC works | ✅ discovery doc + token endpoint + JWT issuance verified |
|
||||||
| 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify |
|
| 12 | PKCE works | ➖ **N/A** — the PKCE/redirect flow was replaced by the in-page password-grant login (per the redesign spec); nothing to verify |
|
||||||
@@ -270,7 +270,7 @@ After that, log in as the admin and use the Organization panel
|
|||||||
|
|
||||||
## 9. Managing Casdoor yourself
|
## 9. Managing Casdoor yourself
|
||||||
|
|
||||||
- **Console:** http://localhost:8080 — log in as the admin (credentials from the seeder run). From there
|
- **Console:** http://localhost:8080 — log in as **admin / 123**. From there
|
||||||
you can manage Organizations, Users, Applications, Providers, Certificates.
|
you can manage Organizations, Users, Applications, Providers, Certificates.
|
||||||
- **Seeding** (org `inventory`, app `inventory-app`, the four users) —
|
- **Seeding** (org `inventory`, app `inventory-app`, the four users) —
|
||||||
re-runnable at any time:
|
re-runnable at any time:
|
||||||
|
|||||||
+3
-10
@@ -5,13 +5,6 @@
|
|||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# Show the "Developer Mode" panel on the login page.
|
# Show the "Developer Mode" panel on the login page.
|
||||||
# true (default in local dev) / false — in Docker builds this is controlled
|
# true (default) / false — in Docker builds this is controlled by the
|
||||||
# by the DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file.
|
# DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file.
|
||||||
VITE_ENABLE_DEV_LOGIN=
|
VITE_ENABLE_DEV_LOGIN=true
|
||||||
|
|
||||||
# ---- Observability (all optional — empty = disabled, no network calls) ----
|
|
||||||
# OpenPanel: browser origin of the self-hosted dashboard/API + project client id.
|
|
||||||
VITE_OPENPANEL_URL=
|
|
||||||
VITE_OPENPANEL_CLIENT_ID=
|
|
||||||
# GlitchTip/Sentry DSN for frontend exception capture.
|
|
||||||
VITE_SENTRY_DSN=
|
|
||||||
|
|||||||
+3
-13
@@ -13,21 +13,11 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
||||||
|
|
||||||
# Developer Mode panel on the login page (dev-only aid). Production builds
|
# Developer Mode panel on the login page (default enabled; set to
|
||||||
# MUST keep this "false" — identity comes from Casdoor tokens, and the
|
# "false" to build a production login without the dev panel).
|
||||||
# backend rejects the X-User-* header path unless DEV_LOGIN_ENABLED=true.
|
ARG DEV_LOGIN_ENABLED=true
|
||||||
ARG DEV_LOGIN_ENABLED=false
|
|
||||||
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED}
|
||||||
|
|
||||||
# Observability (Phase 2) — baked into the bundle at build time.
|
|
||||||
# Empty values disable the feature (analytics.ts is a no-op then).
|
|
||||||
ARG VITE_OPENPANEL_URL=""
|
|
||||||
ARG VITE_OPENPANEL_CLIENT_ID=""
|
|
||||||
ARG VITE_SENTRY_DSN=""
|
|
||||||
ENV VITE_OPENPANEL_URL=${VITE_OPENPANEL_URL} \
|
|
||||||
VITE_OPENPANEL_CLIENT_ID=${VITE_OPENPANEL_CLIENT_ID} \
|
|
||||||
VITE_SENTRY_DSN=${VITE_SENTRY_DSN}
|
|
||||||
|
|
||||||
# Source code + build
|
# Source code + build
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
Generated
+78
-198
@@ -11,13 +11,11 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "3.10",
|
"@hookform/resolvers": "3.10",
|
||||||
"@openpanel/web": "^1.0.1",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.23",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-select": "^2.3.7",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-slot": "^1.3.3",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@sentry/react": "^10.0.0",
|
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -174,23 +172,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@openpanel/sdk": {
|
|
||||||
"version": "1.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.1.tgz",
|
|
||||||
"integrity": "sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@openpanel/web": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@openpanel/sdk": "1.3.1",
|
|
||||||
"@rrweb/types": "2.0.0-alpha.20",
|
|
||||||
"rrweb": "2.0.0-alpha.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.143.0",
|
"version": "0.143.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
||||||
@@ -327,6 +308,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -344,6 +328,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -361,6 +348,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -378,6 +368,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -395,6 +388,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -412,6 +408,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -429,6 +428,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -446,6 +448,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1202,6 +1207,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1218,6 +1226,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1234,6 +1245,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1250,6 +1264,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1266,6 +1283,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1282,6 +1302,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1345,124 +1368,6 @@
|
|||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rrweb/types": {
|
|
||||||
"version": "2.0.0-alpha.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.20.tgz",
|
|
||||||
"integrity": "sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rrweb/utils": {
|
|
||||||
"version": "2.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.1.6.tgz",
|
|
||||||
"integrity": "sha512-BIRXcxiytJs5Guxw0tzsnVS7ukcMqtnxixjIGvV49VdJeOEG6NJeMU7o8efbHW1D52a12Y8QsgDjgDgblg3org==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-CKGSJLWISQ6LgMaGnMpY+XvwWQ2iiHTayUs6SGLIvYeaGo2GTjS/Z4pnbCVHb2t1VxLZksyehgcC6ZYGJeQpzQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser-utils": "10.75.0",
|
|
||||||
"@sentry/conventions": "^0.16.0",
|
|
||||||
"@sentry/core": "10.75.0",
|
|
||||||
"@sentry/feedback": "10.75.0",
|
|
||||||
"@sentry/replay": "10.75.0",
|
|
||||||
"@sentry/replay-canvas": "10.75.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser-utils": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-yZfdQ87RLat/7retF8zA6Z9zCAT12IyaPvIiu+bbi1E1V1OqDpS8MKNa/6LF69Ap7DVTORi9ITEgjlAqTXF0xQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/conventions": "^0.16.0",
|
|
||||||
"@sentry/core": "10.75.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/conventions": {
|
|
||||||
"version": "0.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
|
||||||
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/core": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-5wDQpQqjJ6RHdPR+z6Q+47XlwoxRKBv0yyB0LKHqL/1azPOQbR+nllyLmmQDcoJpaksuLSmLA1q6hFX7gjDT2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/conventions": "^0.16.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/feedback": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-YLVAFQXELJAOg7ke4oLkd8t5l0fJUockLlTQJg/2Trh7SB5Q4mkSwlnAkEAqDQ618vnpFsG/tnYMwtfZeffHjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.75.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/react": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-ghWv99h7k4WgMucqBhf4jZn+G0Owm5gCBCVKOKu88UDCWUd9aVJ8FGosAkATp/x/ogcZTnqG2dcXBD+1U+BJeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser": "10.75.0",
|
|
||||||
"@sentry/conventions": "^0.16.0",
|
|
||||||
"@sentry/core": "10.75.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/replay": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-p4xxEo/ayiMZG9tygr09YjqYAd52uq8Qi0EVoRRgUe70nQda0HVPVW519JLq0G+Th/5O5sKME9y1layLU3xlPg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser-utils": "10.75.0",
|
|
||||||
"@sentry/core": "10.75.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/replay-canvas": {
|
|
||||||
"version": "10.75.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.75.0.tgz",
|
|
||||||
"integrity": "sha512-LNokI5Ud22nZ/o2il/2SfbNIARupWrbS6Mh9jZroZTjD38FN0Oi9vIexj6h1HN81C6+lP2Tmpaj+fl3/wOizzA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.75.0",
|
|
||||||
"@sentry/replay": "10.75.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||||
@@ -1588,6 +1493,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1604,6 +1512,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1620,6 +1531,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1636,6 +1550,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1746,12 +1663,6 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/css-font-loading-module": {
|
|
||||||
"version": "0.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
|
|
||||||
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
@@ -1808,12 +1719,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xstate/fsm": {
|
|
||||||
"version": "1.6.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
|
|
||||||
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/aria-hidden": {
|
"node_modules/aria-hidden": {
|
||||||
"version": "1.2.6",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||||
@@ -1826,15 +1731,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/base64-arraybuffer": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -2095,6 +1991,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2115,6 +2014,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2135,6 +2037,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2155,6 +2060,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2226,12 +2134,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mitt": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.18",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
@@ -2521,40 +2423,6 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.2.3"
|
"@rolldown/binding-win32-x64-msvc": "1.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rrdom": {
|
|
||||||
"version": "2.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.1.6.tgz",
|
|
||||||
"integrity": "sha512-EMRrTs1GRsmb8bNyAs107wG7eRwVEOvDUBsnCyvQnVV6elrmrua/AeS0tdcA0gLW4eptr4l6bRsS/2QRICvdqQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"rrweb-snapshot": "^2.1.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rrweb": {
|
|
||||||
"version": "2.0.0-alpha.20",
|
|
||||||
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.20.tgz",
|
|
||||||
"integrity": "sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@rrweb/types": "^2.0.0-alpha.20",
|
|
||||||
"@rrweb/utils": "^2.0.0-alpha.20",
|
|
||||||
"@types/css-font-loading-module": "0.0.7",
|
|
||||||
"@xstate/fsm": "^1.4.0",
|
|
||||||
"base64-arraybuffer": "^1.0.1",
|
|
||||||
"mitt": "^3.0.0",
|
|
||||||
"rrdom": "^2.0.0-alpha.20",
|
|
||||||
"rrweb-snapshot": "^2.0.0-alpha.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/rrweb-snapshot": {
|
|
||||||
"version": "2.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.1.6.tgz",
|
|
||||||
"integrity": "sha512-X03+x3AdWTbpD4Swb54CCJoEauYTv/O2ah0d3ZVv5rgr6bv0Ty5mxNjEyEEfNC1dQ8VdyjKKJgDG3A00FV0lNg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"postcss": "^8.4.38"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2920,6 +2788,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2940,6 +2811,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2960,6 +2834,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2980,6 +2857,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -10,11 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/react": "^10.0.0",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "3.10",
|
"@hookform/resolvers": "3.10",
|
||||||
"@openpanel/web": "^1.0.1",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.11",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.23",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
|
openOnFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
||||||
@@ -49,6 +52,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -132,9 +136,13 @@ export function Combobox({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
|
if (!open) setOpen(true);
|
||||||
onInputChange?.(e.target.value);
|
onInputChange?.(e.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
|
if (openOnFocus) setOpen(true);
|
||||||
|
}}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
import { captureError } from "../lib/analytics";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -20,8 +19,6 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
console.error("ErrorBoundary caught:", error, info);
|
console.error("ErrorBoundary caught:", error, info);
|
||||||
// GlitchTip/Sentry (no-op بدون VITE_SENTRY_DSN)
|
|
||||||
captureError(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private retry = () => {
|
private retry = () => {
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ export interface FileListProps {
|
|||||||
upload: (file: File) => Promise<Attachment>;
|
upload: (file: File) => Promise<Attachment>;
|
||||||
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده میشود)
|
||||||
onDelete?: (a: Attachment) => void;
|
onDelete?: (a: Attachment) => void;
|
||||||
// دانلود احراز هویتشده (توکن Bearer + blob — لینک خام توکن نمیفرستد)
|
// لینک دانلود؛ null یعنی هنوز آپلود نشده
|
||||||
onDownload?: (a: Attachment) => void | Promise<void>;
|
urlOf?: (a: Attachment) => string | null;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
||||||
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
|
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const remove = (i: number) => {
|
const remove = (i: number) => {
|
||||||
@@ -45,19 +45,10 @@ export function FileList({ files, onChange, upload, onDelete, onDownload, label
|
|||||||
{files.map((inv, i) => (
|
{files.map((inv, i) => (
|
||||||
<div key={i} className="inv-row">
|
<div key={i} className="inv-row">
|
||||||
<span className="inv-name">📎 {inv.name}</span>
|
<span className="inv-name">📎 {inv.name}</span>
|
||||||
{onDownload && (
|
{urlOf?.(inv) && (
|
||||||
<button
|
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
|
||||||
type="button"
|
|
||||||
className="inv-open"
|
|
||||||
title="دانلود"
|
|
||||||
onClick={() => {
|
|
||||||
Promise.resolve(onDownload(inv)).catch((err) =>
|
|
||||||
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
↓
|
↓
|
||||||
</button>
|
</a>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// Product analytics + error capture (Phase 2)
|
|
||||||
//
|
|
||||||
// - OpenPanel (@openpanel/web): funnels/usage — initialized only when
|
|
||||||
// VITE_OPENPANEL_URL and VITE_OPENPANEL_CLIENT_ID are set (build args),
|
|
||||||
// so local dev and air-gapped environments stay silent.
|
|
||||||
// - GlitchTip (@sentry/react): frontend exception capture, only when
|
|
||||||
// VITE_SENTRY_DSN is set.
|
|
||||||
//
|
|
||||||
// Both are no-ops without configuration; every helper is safe to call
|
|
||||||
// unconditionally from anywhere in the app.
|
|
||||||
// ============================================================
|
|
||||||
import * as Sentry from "@sentry/react";
|
|
||||||
|
|
||||||
type Props = Record<string, unknown>;
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
op?: (...args: unknown[]) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPENPANEL_URL = (import.meta.env?.VITE_OPENPANEL_URL as string | undefined)?.trim();
|
|
||||||
const OPENPANEL_CLIENT_ID = (import.meta.env?.VITE_OPENPANEL_CLIENT_ID as string | undefined)?.trim();
|
|
||||||
const SENTRY_DSN = (import.meta.env?.VITE_SENTRY_DSN as string | undefined)?.trim();
|
|
||||||
|
|
||||||
export function initAnalytics(): void {
|
|
||||||
if (SENTRY_DSN) {
|
|
||||||
Sentry.init({
|
|
||||||
dsn: SENTRY_DSN,
|
|
||||||
environment: (import.meta.env?.MODE as string | undefined) ?? "production",
|
|
||||||
// Frontend traces are expensive and rarely needed — errors only.
|
|
||||||
tracesSampleRate: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (OPENPANEL_URL && OPENPANEL_CLIENT_ID) {
|
|
||||||
try {
|
|
||||||
window.op?.("init", {
|
|
||||||
apiUrl: OPENPANEL_URL,
|
|
||||||
clientId: OPENPANEL_CLIENT_ID,
|
|
||||||
trackScreenViews: true, // pageview → which pages users visit
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* analytics must never break the app */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Attach the ERP user identity to analytics (after login / app load). */
|
|
||||||
export function identifyUser(profile: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
role?: string | null;
|
|
||||||
}): void {
|
|
||||||
if (!profile.id) return;
|
|
||||||
try {
|
|
||||||
window.op?.("identify", {
|
|
||||||
profileId: profile.id,
|
|
||||||
firstName: profile.name || undefined,
|
|
||||||
data: { role: profile.role ?? undefined },
|
|
||||||
});
|
|
||||||
track("user_identified", { role: profile.role ?? undefined });
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function track(event: string, properties?: Props): void {
|
|
||||||
try {
|
|
||||||
window.op?.("track", event, properties ?? {});
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureError(err: unknown): void {
|
|
||||||
if (SENTRY_DSN) Sentry.captureException(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const analyticsEnabled = Boolean(OPENPANEL_URL && OPENPANEL_CLIENT_ID);
|
|
||||||
+4
-34
@@ -1108,12 +1108,6 @@ export const api = {
|
|||||||
},
|
},
|
||||||
create: async (m: Material): Promise<Material> => {
|
create: async (m: Material): Promise<Material> => {
|
||||||
const lookup = await fetchLookup();
|
const lookup = await fetchLookup();
|
||||||
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
|
||||||
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
|
||||||
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
|
||||||
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
|
||||||
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
|
||||||
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
|
||||||
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
||||||
// (بکاند canonical_unit_id را الزامی میداند)
|
// (بکاند canonical_unit_id را الزامی میداند)
|
||||||
const unitName = (m.base_unit || "").trim();
|
const unitName = (m.base_unit || "").trim();
|
||||||
@@ -1138,7 +1132,6 @@ export const api = {
|
|||||||
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
||||||
}
|
}
|
||||||
const raw = await postData<RawMaterial>("/materials", {
|
const raw = await postData<RawMaterial>("/materials", {
|
||||||
category_id: categoryId ?? null,
|
|
||||||
name,
|
name,
|
||||||
topic: m.topic || null,
|
topic: m.topic || null,
|
||||||
canonical_unit_id: unitId,
|
canonical_unit_id: unitId,
|
||||||
@@ -1282,31 +1275,8 @@ export const api = {
|
|||||||
remove: async (quoteId: string, kind: FileKind, id: string): Promise<void> => {
|
remove: async (quoteId: string, kind: FileKind, id: string): Promise<void> => {
|
||||||
await deleteData(`/quotes/${quoteId}/${kind}/${id}`);
|
await deleteData(`/quotes/${quoteId}/${kind}/${id}`);
|
||||||
},
|
},
|
||||||
// دانلود احراز هویتشده: <a href> توکن Authorization نمیفرستد، پس بایتها
|
downloadUrl: (quoteId: string, kind: FileKind, id: string): string =>
|
||||||
// را با توکن میگیریم و به شکل blob در مرورگر ذخیره میکنیم.
|
`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`,
|
||||||
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
|
|
||||||
headers: authHeaders(),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
try {
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName || "attachment";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
} finally {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------- برندها (از بکاند) ----------
|
// ---------- برندها (از بکاند) ----------
|
||||||
@@ -1705,7 +1675,8 @@ export const apiV26 = {
|
|||||||
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
||||||
return rawWarehouse(d);
|
return rawWarehouse(d);
|
||||||
},
|
},
|
||||||
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
|
// project_id عمداً در تایپ نیست — انبار برای همیشه به پروژهی والدش تعلق دارد
|
||||||
|
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
|
||||||
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
||||||
return rawWarehouse(d);
|
return rawWarehouse(d);
|
||||||
},
|
},
|
||||||
@@ -1783,7 +1754,6 @@ export const apiV26 = {
|
|||||||
}): Promise<{ id: string; name: string }> =>
|
}): Promise<{ id: string; name: string }> =>
|
||||||
postData("/materials", {
|
postData("/materials", {
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
|
||||||
topic: null,
|
topic: null,
|
||||||
canonical_unit_id: opts.canonical_unit_id,
|
canonical_unit_id: opts.canonical_unit_id,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
|
|||||||
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
// specها با جداکنندهی « · »
|
// specها با جداکنندهی « . »
|
||||||
export function specsLabel(specs: string[]): string {
|
export function specsLabel(specs: string[]): string {
|
||||||
return specs.join(" · ");
|
return specs.join(" . ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* عنوان تمیز و بدون تکرار کالا:
|
* عنوان تمیز و بدون تکرار کالا:
|
||||||
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
* ترتیب اکید با جداکنندهی نقطه:
|
||||||
|
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||||
*/
|
*/
|
||||||
export function formatMaterialTitle(
|
export function formatMaterialTitle(
|
||||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||||
extraSpecs?: string[],
|
extraSpecs?: string[],
|
||||||
): string {
|
): string {
|
||||||
if (!m) return "کالا";
|
if (!m) return "کالا";
|
||||||
const rawName = (m.name || "").trim();
|
let rawName = (m.name || "").trim();
|
||||||
const rawBrand = (m.brand || "").trim();
|
const rawBrand = (m.brand || "").trim();
|
||||||
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||||
.map((s) => (s || "").trim())
|
.map((s) => (s || "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const normName = normalize(rawName);
|
|
||||||
const normBrand = normalize(rawBrand);
|
const normBrand = normalize(rawBrand);
|
||||||
|
|
||||||
if (!rawName) {
|
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمیداریم تا تکرار نشود
|
||||||
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
if (normBrand && rawName) {
|
||||||
if (rawBrand) return rawBrand;
|
const normName = normalize(rawName);
|
||||||
if (rawSpecs.length) return rawSpecs.join(" · ");
|
if (normName.startsWith(normBrand)) {
|
||||||
return m.category && m.category !== "؟" ? m.category : "کالا";
|
const rest = rawName.slice(rawBrand.length).trim();
|
||||||
|
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = rawName;
|
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
|
||||||
if (normBrand && !normName.includes(normBrand)) {
|
const parts: string[] = [];
|
||||||
title = `${rawBrand} ${title}`;
|
if (rawBrand) {
|
||||||
|
parts.push(rawBrand);
|
||||||
|
}
|
||||||
|
if (rawName) {
|
||||||
|
parts.push(rawName);
|
||||||
|
} else if (!rawBrand) {
|
||||||
|
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
|
||||||
}
|
}
|
||||||
|
|
||||||
const normTitle = normalize(title);
|
// اضافه کردن مشخصهها بدون تکرار
|
||||||
const distinctSpecs: string[] = [];
|
const normCurrent = normalize(parts.join(" "));
|
||||||
const seenNormSpecs = new Set<string>();
|
const seenNormSpecs = new Set<string>();
|
||||||
|
|
||||||
for (const s of rawSpecs) {
|
for (const s of rawSpecs) {
|
||||||
@@ -79,15 +87,12 @@ export function formatMaterialTitle(
|
|||||||
if (!ns || seenNormSpecs.has(ns)) continue;
|
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||||
seenNormSpecs.add(ns);
|
seenNormSpecs.add(ns);
|
||||||
|
|
||||||
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمیکنیم
|
||||||
if (normTitle.includes(ns)) continue;
|
if (normCurrent.includes(ns)) continue;
|
||||||
distinctSpecs.push(s);
|
parts.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distinctSpecs.length > 0) {
|
return parts.join(" . ");
|
||||||
return `${title} · ${distinctSpecs.join(" · ")}`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export interface Supplier {
|
|||||||
// ماده/کالا
|
// ماده/کالا
|
||||||
export interface Material {
|
export interface Material {
|
||||||
id: string; // "m1", "m2", ...
|
id: string; // "m1", "m2", ...
|
||||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه)
|
name: string; // نام کامل ذخیرهشده در بکاند (برند + مشخصه)
|
||||||
topic: string;
|
topic: string;
|
||||||
category: string; // مثلاً "میلگرد"
|
category: string; // اختیاری در بکاند؛ همیشه string در فرانت (default: "")
|
||||||
brand: string; // مثلاً "ذوب آهن"
|
brand: string; // مثلاً "ذوب آهن"
|
||||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||||
base_unit: string; // واحد پایه
|
base_unit: string; // واحد پایه
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Toaster } from "sonner";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles/globals.css";
|
import "./styles/globals.css";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import { initAnalytics } from "./lib/analytics";
|
|
||||||
|
|
||||||
// بعد از استقرار نسخهی جدید، مرورگری که index.html قدیمی را باز کرده برای
|
// بعد از استقرار نسخهی جدید، مرورگری که index.html قدیمی را باز کرده برای
|
||||||
// بارگذاری chunkهای حذفشده 404 میگیرد و صفحه خالی میماند. این شنونده،
|
// بارگذاری chunkهای حذفشده 404 میگیرد و صفحه خالی میماند. این شنونده،
|
||||||
@@ -16,9 +15,6 @@ window.addEventListener("vite:preloadError", () => {
|
|||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Product analytics + GlitchTip error capture — no-ops without VITE_ config.
|
|
||||||
initAnalytics();
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
||||||
import { api, apiV26 } from "../../lib/api";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
import { identifyUser, track } from "../../lib/analytics";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import type { Project } from "../../lib/types";
|
import type { Project } from "../../lib/types";
|
||||||
|
|
||||||
@@ -79,9 +78,6 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(user);
|
setUser(user);
|
||||||
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
|
|
||||||
identifyUser({ id: user.id, name: user.display_name, role: user.role });
|
|
||||||
track("login_success", { role: user.role });
|
|
||||||
toast.success(`خوش آمدید، ${user.display_name}`);
|
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||||
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||||
navigate(user.role === "pm" ? "/inventory" : "/");
|
navigate(user.role === "pm" ? "/inventory" : "/");
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -414,7 +414,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
||||||
label="افزودن پیشفاکتور"
|
label="افزودن پیشفاکتور"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||||
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||||
@@ -168,7 +169,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||||||
cat: m.category || "",
|
cat: m.name || "", // نام خود متریال — نه دسته/برند
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
qty: li.quantity,
|
qty: li.quantity,
|
||||||
@@ -197,7 +198,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q, suppliers, materials]);
|
}, [q, suppliers, materials]);
|
||||||
|
|
||||||
// بارگذاری فاکتورها
|
// بارگذاری فاکتورها و پیشفاکتورها
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -205,6 +206,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
.list(quoteId, "invoice")
|
.list(quoteId, "invoice")
|
||||||
.then((list) => alive && setInvoices(list))
|
.then((list) => alive && setInvoices(list))
|
||||||
.catch(() => alive && setInvoices([]));
|
.catch(() => alive && setInvoices([]));
|
||||||
|
void api.files
|
||||||
|
.list(quoteId, "proforma")
|
||||||
|
.then((list) => alive && setProformas(list))
|
||||||
|
.catch(() => alive && setProformas([]));
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
@@ -314,7 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
let material = materialsCopy.find(
|
let material = materialsCopy.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
normalize(m.category) === normalize(cat) &&
|
normalize(m.name) === normalize(cat) &&
|
||||||
normalize(m.brand) === normalize(brand) &&
|
normalize(m.brand) === normalize(brand) &&
|
||||||
specsKey(m.specs) === specsKey(specs),
|
specsKey(m.specs) === specsKey(specs),
|
||||||
);
|
);
|
||||||
@@ -460,6 +465,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||||
placeholder="نام تأمینکننده"
|
placeholder="نام تأمینکننده"
|
||||||
onInputChange={(v) => setSupplierName(v)}
|
onInputChange={(v) => setSupplierName(v)}
|
||||||
|
openOnFocus={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,7 +726,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* پیشفاکتورها — در تمامی مراحل چرخه عمر پیشنهاد قابل افزودن/ویرایش است */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||||
|
<FileList
|
||||||
|
files={proformas}
|
||||||
|
onChange={setProformas}
|
||||||
|
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||||
|
onDelete={(a) =>
|
||||||
|
a.id &&
|
||||||
|
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
|
|||||||
import type { Quote, Store } from "../../lib/types";
|
import type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
isExpired,
|
isExpired,
|
||||||
itemsSummary,
|
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
projectCell,
|
projectCell,
|
||||||
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
const pc = priceCell(total);
|
const pc = priceCell(total);
|
||||||
const ts = termsheetSummary(store, q);
|
const ts = termsheetSummary(store, q);
|
||||||
const unitWarns = unitWarning(store, q);
|
const unitWarns = unitWarning(store, q);
|
||||||
const summary = itemsSummary(store, q);
|
|
||||||
|
|
||||||
// ستون / دکمه پذیرش
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
{sup.name.slice(0, 1)}
|
{sup.name.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
<Link
|
||||||
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
to={`/suppliers/${q.supplier_id}`}
|
||||||
>
|
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
||||||
{sup.name}
|
>
|
||||||
</Link>
|
{sup.name}
|
||||||
|
</Link>
|
||||||
|
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
||||||
|
q.is_official
|
||||||
|
? "bg-green-bg text-green"
|
||||||
|
: "bg-slate-bg text-muted",
|
||||||
|
)}
|
||||||
|
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
||||||
|
>
|
||||||
|
{q.is_official ? "رسمی" : "غیررسمی"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||||
@@ -188,47 +200,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
||||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||||
{summary.count > 1 ? (
|
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||||
<div className="flex flex-col gap-1.5">
|
{activeItems.length > 0 ? (
|
||||||
<span className="font-bold text-xs text-ink sm:text-sm">
|
activeItems.map((li) => {
|
||||||
{fmtNum(summary.count)} قلم کالا
|
const m = materialById(store.materials, li.material_id);
|
||||||
</span>
|
return (
|
||||||
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
<span key={li.id} className="break-words">
|
||||||
{store.quotes
|
{materialLabel(m)}
|
||||||
.find((x) => x.id === q.id)
|
|
||||||
?.line_items?.filter((li) => !li.rejected)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((li) => {
|
|
||||||
const m = materialById(store.materials, li.material_id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={li.id}
|
|
||||||
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
|
||||||
• {materialLabel(m)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
|
||||||
{fmtNum(li.quantity)}{" "}
|
|
||||||
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(q.line_items?.length || 0) > 3 && (
|
|
||||||
<span className="pt-0.5 text-[10px] text-muted">
|
|
||||||
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
</div>
|
})
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<span>—</span>
|
||||||
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
)}
|
||||||
<span className="break-words">{summary.text}</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unitWarns.map((w) => (
|
{unitWarns.map((w) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -243,32 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="پروژه">
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
||||||
<span className="truncate">
|
|
||||||
{(() => {
|
|
||||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
|
||||||
if (q.status === "accepted" || q.status === "arrived") {
|
|
||||||
// فقط پروژهی هدف پذیرفتهشده — همان منطق itemProjects در وضعیت accepted
|
|
||||||
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
|
|
||||||
return targetProj?.name ?? projectCell(q);
|
|
||||||
}
|
|
||||||
// وضعیت offered: همهی پروژههای کاندیدا از line_items (همان منطق itemProjects)
|
|
||||||
const names = new Set<string>();
|
|
||||||
activeItems.forEach((li) =>
|
|
||||||
(li.projects || []).forEach((p) => {
|
|
||||||
const name = typeof p === "string" ? p : p.name;
|
|
||||||
if (name) names.add(name);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (names.size > 0) return [...names].join("، ");
|
|
||||||
return projectCell(q);
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</InfoCell>
|
|
||||||
|
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -144,31 +144,19 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
attachments.proformas.forEach((pf, i) => {
|
attachments.proformas.forEach((pf, i) => {
|
||||||
if (!pf.id) return;
|
if (!pf.id) return;
|
||||||
attachParts.push(
|
attachParts.push(
|
||||||
<button
|
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
|
||||||
key={"pf" + i}
|
|
||||||
type="button"
|
|
||||||
className="detail-link"
|
|
||||||
title="دانلود پیشفاکتور"
|
|
||||||
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
|
||||||
>
|
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
پیشفاکتور: {pf.name}
|
پیشفاکتور: {pf.name}
|
||||||
</button>,
|
</a>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
attachments.invoices.forEach((inv, i) => {
|
attachments.invoices.forEach((inv, i) => {
|
||||||
if (!inv.id) return;
|
if (!inv.id) return;
|
||||||
attachParts.push(
|
attachParts.push(
|
||||||
<button
|
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
|
||||||
key={"inv" + i}
|
|
||||||
type="button"
|
|
||||||
className="detail-link"
|
|
||||||
title="دانلود فاکتور"
|
|
||||||
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
|
||||||
>
|
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
فاکتور اصلی: {inv.name}
|
فاکتور اصلی: {inv.name}
|
||||||
</button>,
|
</a>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -148,16 +148,8 @@ export function OrganizationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveWarehouse = async (id: string, projectId: string) => {
|
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
||||||
try {
|
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
||||||
await apiV26.warehouses.update(id, { project_id: projectId });
|
|
||||||
toast.success("انبار منتقل شد ✓");
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeWarehouse = async (id: string) => {
|
const removeWarehouse = async (id: string) => {
|
||||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||||
try {
|
try {
|
||||||
@@ -329,16 +321,6 @@ export function OrganizationPage() {
|
|||||||
>
|
>
|
||||||
📊 داشبورد
|
📊 داشبورد
|
||||||
</button>
|
</button>
|
||||||
<select
|
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
|
||||||
value={w.project_id}
|
|
||||||
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
|
|
||||||
title="انتقال به پروژه دیگر"
|
|
||||||
>
|
|
||||||
{data.lookup.projects.map((p2) => (
|
|
||||||
<option key={p2.id} value={p2.id}>{p2.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import {
|
import {
|
||||||
activeItems,
|
|
||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
@@ -29,25 +28,45 @@ import { EmptyState } from "../../components/EmptyState";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||||
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
|
return quotes.filter(
|
||||||
|
(q) =>
|
||||||
|
(q.projects || []).includes(name) ||
|
||||||
|
q.project === name ||
|
||||||
|
(q.line_items || []).some(
|
||||||
|
(li) =>
|
||||||
|
li.target_project?.name === name ||
|
||||||
|
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اقلام مربوط به این پروژه در یک quote
|
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
||||||
|
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
||||||
|
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
||||||
|
// برای این پروژه همچنان «پیشنهاد شده» است.
|
||||||
|
function acceptedForProject(q: Quote, name: string): boolean {
|
||||||
|
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
||||||
|
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
||||||
|
function hasTargets(q: Quote): boolean {
|
||||||
|
return (q.line_items || []).some((li) => !!li.target_project);
|
||||||
|
}
|
||||||
|
|
||||||
|
// اقلام مربوط به این پروژه در یک quote:
|
||||||
|
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
||||||
|
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
||||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
|
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||||
|
if (targeted.length) return targeted;
|
||||||
if (items.some((li) => li.projects && li.projects.length)) {
|
if (items.some((li) => li.projects && li.projects.length)) {
|
||||||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function qTotal(q: Quote): number {
|
|
||||||
return activeItems(q).reduce((sum, li) => {
|
|
||||||
const t = lineItemTotal(li);
|
|
||||||
return sum + (t || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysSince(iso: string | null): number | null {
|
function daysSince(iso: string | null): number | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -147,10 +166,11 @@ export function ProjectsPage() {
|
|||||||
// ---------- پنل جزئیات یک پروژه ----------
|
// ---------- پنل جزئیات یک پروژه ----------
|
||||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||||
const quotes = projectQuotes(store.quotes, name);
|
const quotes = projectQuotes(store.quotes, name);
|
||||||
const sumBy = (st: Quote["status"]) =>
|
|
||||||
quotes
|
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
||||||
.filter((q) => q.status === st)
|
const itemsTotal = (q: Quote) =>
|
||||||
.reduce((s, q) => s + qTotal(q), 0);
|
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||||
|
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||||
|
|
||||||
const matName = (mid: string) => {
|
const matName = (mid: string) => {
|
||||||
const m = materialById(store.materials, mid);
|
const m = materialById(store.materials, mid);
|
||||||
@@ -184,11 +204,19 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// دستهبندی ایزوله per-project:
|
||||||
|
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
||||||
|
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
||||||
|
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
||||||
const offered = quotes
|
const offered = quotes
|
||||||
.filter((q) => q.status === "offered")
|
.filter(
|
||||||
|
(q) =>
|
||||||
|
q.status === "offered" ||
|
||||||
|
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
|
||||||
|
)
|
||||||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted");
|
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
|
||||||
const arrived = quotes.filter((q) => q.status === "arrived");
|
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
|
||||||
|
|
||||||
const offeredRow = (q: Quote) => {
|
const offeredRow = (q: Quote) => {
|
||||||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||||||
@@ -199,7 +227,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
جمع: <b>{faNum(qTotal(q))} تومان</b>
|
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||||||
@@ -240,7 +268,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
هزینه: <b>{faNum(qTotal(q))} تومان</b>
|
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,13 +283,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||||||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||||||
ارزش باز: {faNum(sumBy("offered"))} تومان
|
ارزش باز: {faNum(sumBy(offered))} تومان
|
||||||
</span>
|
</span>
|
||||||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||||||
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
|
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
|
||||||
</span>
|
</span>
|
||||||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||||||
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
|
ارزش رسیده: {faNum(sumBy(arrived))} تومان
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
|||||||
@@ -112,28 +112,28 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{profs.map((pf) => (
|
{profs.map((pf) => (
|
||||||
<button
|
<a
|
||||||
key={"pf-" + pf.id}
|
key={"pf-" + pf.id}
|
||||||
type="button"
|
|
||||||
className="detail-link"
|
className="detail-link"
|
||||||
|
href={api.files.downloadUrl(q.id, "proforma", pf.id!)}
|
||||||
|
download={pf.name}
|
||||||
title="دانلود پیشفاکتور"
|
title="دانلود پیشفاکتور"
|
||||||
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
|
|
||||||
>
|
>
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
پیشفاکتور: {pf.name}
|
پیشفاکتور: {pf.name}
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
{invs.map((inv) => (
|
{invs.map((inv) => (
|
||||||
<button
|
<a
|
||||||
key={"inv-" + inv.id}
|
key={"inv-" + inv.id}
|
||||||
type="button"
|
|
||||||
className="detail-link"
|
className="detail-link"
|
||||||
|
href={api.files.downloadUrl(q.id, "invoice", inv.id!)}
|
||||||
|
download={inv.name}
|
||||||
title="دانلود فاکتور"
|
title="دانلود فاکتور"
|
||||||
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
|
|
||||||
>
|
>
|
||||||
<Paperclip className="me-1 inline h-3 w-3" />
|
<Paperclip className="me-1 inline h-3 w-3" />
|
||||||
فاکتور: {inv.name}
|
فاکتور: {inv.name}
|
||||||
</button>
|
</a>
|
||||||
))}
|
))}
|
||||||
{!loading && !profs.length && !invs.length && (
|
{!loading && !profs.length && !invs.length && (
|
||||||
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ export function UnitsPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{g.base}</span>
|
<span className="whitespace-normal break-words text-start">{g.base}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -519,7 +519,7 @@ export function UnitsPage() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setMaterialId(v.id)}
|
onClick={() => setMaterialId(v.id)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{v.label}</span>
|
<span className="whitespace-normal break-words text-start">{v.label}</span>
|
||||||
<span className="ut-ref">{v.refUnit || "—"}</span>
|
<span className="ut-ref">{v.refUnit || "—"}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category
|
|||||||
return base || fallback;
|
return base || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
|
||||||
export function materialOptions(
|
export function materialOptions(
|
||||||
materials: Material[],
|
materials: Material[],
|
||||||
topic: string,
|
_topic: string,
|
||||||
item: ItemDraft,
|
item: ItemDraft,
|
||||||
field: "cat" | "brand" | "spec",
|
field: "cat" | "brand" | "spec",
|
||||||
allItems: ItemDraft[] = [],
|
allItems: ItemDraft[] = [],
|
||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
// متریالها سراسری هستند — مستقل از تاپیک
|
||||||
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||||
@@ -92,7 +93,7 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "cat") {
|
if (field === "cat") {
|
||||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
|
||||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { track } from "../../lib/analytics";
|
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -119,7 +118,6 @@ export function WizardPage() {
|
|||||||
const goToStep = (n: number) => {
|
const goToStep = (n: number) => {
|
||||||
setCurrentStep(n);
|
setCurrentStep(n);
|
||||||
setHighestVisited((h) => Math.max(h, n));
|
setHighestVisited((h) => Math.max(h, n));
|
||||||
track("wizard_step", { step: n, direction: n > currentStep ? "forward" : "back" });
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -407,7 +405,6 @@ export function WizardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
toast.success("پیشنهاد ذخیره شد.");
|
toast.success("پیشنهاد ذخیره شد.");
|
||||||
track("quote_submitted", { quote_id: created.id, items: lineItems.length });
|
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const target = resolveErrorTarget(e);
|
const target = resolveErrorTarget(e);
|
||||||
|
|||||||
Vendored
-9
@@ -1,10 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
// Observability build-time configuration (empty = feature disabled)
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
readonly VITE_OPENPANEL_URL?: string;
|
|
||||||
readonly VITE_OPENPANEL_CLIENT_ID?: string;
|
|
||||||
readonly VITE_SENTRY_DSN?: string;
|
|
||||||
readonly VITE_ENABLE_DEV_LOGIN?: string;
|
|
||||||
readonly VITE_API_BASE_URL?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# Deploy webhook receiver (push-to-deploy, hardened)
|
|
||||||
|
|
||||||
Every push to the `production` branch triggers a rebuild on the VPS through
|
|
||||||
this receiver (host port 9000). The receiver must **verify the Gitea
|
|
||||||
webhook signature** — an open deploy endpoint is effectively remote code
|
|
||||||
execution on the production server.
|
|
||||||
|
|
||||||
## VPS setup (one time)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) Files live here:
|
|
||||||
sudo mkdir -p /srv/erp && sudo chown $USER /srv/erp
|
|
||||||
# copy the repo to /srv/erp (the receiver runs docker compose in this dir)
|
|
||||||
|
|
||||||
# 2) Secrets — add to /srv/erp/.env (git-ignored):
|
|
||||||
# GITEA_WEBHOOK_SECRET=<long random string>
|
|
||||||
|
|
||||||
# 3) Install as a systemd service:
|
|
||||||
sudo cp infra/deploy-webhook/erp-deploy-webhook.service /etc/systemd/system/
|
|
||||||
# adjust User= and paths inside, then:
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now erp-deploy-webhook
|
|
||||||
curl http://127.0.0.1:9000/ping # → {"status":"ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gitea webhook
|
|
||||||
|
|
||||||
Repo → Settings → Webhooks → Add webhook → Gitea:
|
|
||||||
|
|
||||||
- Target URL: `http://<server-ip>:9000/deploy`
|
|
||||||
- Method: `POST` — Secret: same value as `GITEA_WEBHOOK_SECRET`
|
|
||||||
- Trigger: Push Events, Branch filter: `production`
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- Verifies `X-Gitea-Signature` (HMAC-SHA256) — rejects everything else with 401.
|
|
||||||
- Only pushes to `refs/heads/production` deploy; other branches are acknowledged and ignored.
|
|
||||||
- Deploys are serialized with a lock (concurrent pushes don't race).
|
|
||||||
- Runs `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` in `/srv/erp`.
|
|
||||||
- After a successful build, polls the API `/health/ready` endpoint and logs
|
|
||||||
a failed post-deploy check (surfaced by the monitoring stack).
|
|
||||||
- Logs to journald (`journalctl -u erp-deploy-webhook -f`).
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Hardened Gitea push-to-deploy webhook receiver.
|
|
||||||
|
|
||||||
Replaces an unverified "POST /deploy" endpoint. Runs on the VPS (host port
|
|
||||||
9000 is reserved for it) and rebuilds the stack when a push to the
|
|
||||||
production branch arrives — but ONLY after verifying the Gitea webhook
|
|
||||||
signature (HMAC-SHA256 of the raw body with GITEA_WEBHOOK_SECRET).
|
|
||||||
|
|
||||||
Why this matters: an unauthenticated deploy endpoint lets anyone on the
|
|
||||||
internet restart/rebuild the production stack (and, if it shells out to a
|
|
||||||
script, is effectively remote code execution).
|
|
||||||
|
|
||||||
Setup on the VPS (see infra/deploy-webhook/README.md):
|
|
||||||
|
|
||||||
GITEA_WEBHOOK_SECRET=... DEPLOY_DIR=/srv/erp DEPLOY_BRANCH=production \
|
|
||||||
python3 deploy_webhook.py # or run under systemd (provided)
|
|
||||||
|
|
||||||
Gitea side: repo → Settings → Webhooks → Add webhook → Gitea
|
|
||||||
Target URL: http://<server>:9000/deploy
|
|
||||||
Method: POST, Secret: same value as GITEA_WEBHOOK_SECRET
|
|
||||||
Trigger on: Push Events, Branch filter: production
|
|
||||||
|
|
||||||
Stdlib only — no dependencies to install.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1")
|
|
||||||
LISTEN_PORT = int(os.environ.get("WEBHOOK_PORT", "9000"))
|
|
||||||
SECRET = os.environ.get("GITEA_WEBHOOK_SECRET", "")
|
|
||||||
DEPLOY_DIR = os.environ.get("DEPLOY_DIR", "/srv/erp")
|
|
||||||
DEPLOY_BRANCH = os.environ.get("DEPLOY_BRANCH", "production")
|
|
||||||
# Post-deploy readiness gate: defaults to the public API host from .env
|
|
||||||
# (both DEPLOY_HEALTH_URL and API_HOST live in the same EnvironmentFile).
|
|
||||||
HEALTH_URL = (
|
|
||||||
os.environ.get("DEPLOY_HEALTH_URL")
|
|
||||||
or (f"https://{os.environ['API_HOST']}/health/ready" if os.environ.get("API_HOST") else "")
|
|
||||||
)
|
|
||||||
LOCK_FILE = os.environ.get("DEPLOY_LOCK", "/tmp/erp-deploy.lock")
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s %(levelname)s deploy-webhook — %(message)s",
|
|
||||||
handlers=[logging.StreamHandler(sys.stdout)],
|
|
||||||
)
|
|
||||||
log = logging.getLogger("deploy-webhook")
|
|
||||||
|
|
||||||
# Compose file pair used in production (base + overlay).
|
|
||||||
DEPLOY_CMD = os.environ.get(
|
|
||||||
"DEPLOY_CMD",
|
|
||||||
"docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_signature(body: bytes, header: str | None) -> bool:
|
|
||||||
"""Gitea sends X-Gitea-Signature: hex HMAC-SHA256 of the raw body."""
|
|
||||||
if not SECRET:
|
|
||||||
log.error("GITEA_WEBHOOK_SECRET is not set — refusing ALL requests")
|
|
||||||
return False
|
|
||||||
if not header:
|
|
||||||
return False
|
|
||||||
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
|
|
||||||
return hmac.compare_digest(expected, header.strip().lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _deploy() -> tuple[bool, str]:
|
|
||||||
"""Run the rebuild. Serialized: concurrent pushes queue/drop, not race."""
|
|
||||||
if os.path.exists(LOCK_FILE):
|
|
||||||
age = time.time() - os.path.getmtime(LOCK_FILE)
|
|
||||||
if age < 1800: # a deploy is already running (or crashed <30min ago)
|
|
||||||
return False, f"deploy already in progress (lock age {int(age)}s)"
|
|
||||||
try:
|
|
||||||
with open(LOCK_FILE, "w") as f:
|
|
||||||
f.write(str(os.getpid()))
|
|
||||||
log.info("deploy started: %s", DEPLOY_CMD)
|
|
||||||
proc = subprocess.run(
|
|
||||||
DEPLOY_CMD, shell=True, cwd=DEPLOY_DIR,
|
|
||||||
capture_output=True, text=True, timeout=1800,
|
|
||||||
)
|
|
||||||
tail = "\n".join((proc.stdout + proc.stderr).splitlines()[-40:])
|
|
||||||
ok = proc.returncode == 0
|
|
||||||
log.info("deploy finished rc=%s\n%s", proc.returncode, tail)
|
|
||||||
if ok and HEALTH_URL:
|
|
||||||
ok = _wait_healthy()
|
|
||||||
return ok, ("ok" if ok else "deploy or health check failed")
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
log.error("deploy timed out after 1800s")
|
|
||||||
return False, "deploy timed out"
|
|
||||||
except Exception as exc:
|
|
||||||
log.exception("deploy failed")
|
|
||||||
return False, str(exc)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(LOCK_FILE)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _wait_healthy(timeout: int = 180) -> bool:
|
|
||||||
"""Poll the app's readiness endpoint after a rebuild."""
|
|
||||||
import urllib.request
|
|
||||||
log.info("waiting for %s", HEALTH_URL)
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(HEALTH_URL, timeout=5) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
log.info("post-deploy health check: OK")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(5)
|
|
||||||
log.error("post-deploy health check FAILED after %ss", timeout)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_POST(self):
|
|
||||||
if self.path != "/deploy":
|
|
||||||
self._json(404, {"error": "not found"})
|
|
||||||
return
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
if length > 1_000_000:
|
|
||||||
self._json(413, {"error": "payload too large"})
|
|
||||||
return
|
|
||||||
body = self.rfile.read(length)
|
|
||||||
if not _verify_signature(body, self.headers.get("X-Gitea-Signature")):
|
|
||||||
log.warning("rejected request: bad/missing signature from %s", self.client_address[0])
|
|
||||||
self._json(401, {"error": "invalid signature"})
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
payload = json.loads(body)
|
|
||||||
ref = payload.get("ref", "")
|
|
||||||
pushed = (payload.get("ref") or "").rsplit("/", 1)[-1]
|
|
||||||
if ref != f"refs/heads/{DEPLOY_BRANCH}":
|
|
||||||
log.info("ignoring push to %s (deploys trigger on %s only)", ref, DEPLOY_BRANCH)
|
|
||||||
self._json(200, {"status": "ignored", "ref": ref})
|
|
||||||
return
|
|
||||||
pusher = payload.get("pusher", {}).get("login", "unknown")
|
|
||||||
log.info("push to %s by %s — deploying", pushed, pusher)
|
|
||||||
except Exception:
|
|
||||||
log.exception("bad payload")
|
|
||||||
self._json(400, {"error": "bad payload"})
|
|
||||||
return
|
|
||||||
# Answer the webhook quickly; deploy runs detached from the response.
|
|
||||||
ok, msg = _deploy()
|
|
||||||
self._json(200 if ok else 500, {"status": "ok" if ok else "failed", "detail": msg})
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
# Minimal liveness for the receiver itself (monitoring can scrape it).
|
|
||||||
if self.path == "/ping":
|
|
||||||
self._json(200, {"status": "ok", "branch": DEPLOY_BRANCH})
|
|
||||||
return
|
|
||||||
self._json(404, {"error": "not found"})
|
|
||||||
|
|
||||||
def _json(self, code: int, obj: dict):
|
|
||||||
data = json.dumps(obj).encode()
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(data)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(data)
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args): # route stdlib access logs to our logger
|
|
||||||
log.debug("access: " + fmt, *args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if not SECRET:
|
|
||||||
log.warning("starting WITHOUT signature verification is not supported — set GITEA_WEBHOOK_SECRET")
|
|
||||||
sys.exit(1)
|
|
||||||
server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
|
|
||||||
log.info("listening on %s:%s (branch=%s dir=%s)", LISTEN_HOST, LISTEN_PORT, DEPLOY_BRANCH, DEPLOY_DIR)
|
|
||||||
server.serve_forever()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=ERP push-to-deploy webhook receiver (Gitea -> docker compose)
|
|
||||||
After=network-online.target docker.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
# Run as a dedicated non-root user with docker group membership:
|
|
||||||
# sudo useradd -r -s /usr/sbin/nologin erpdeploy && sudo usermod -aG docker erpdeploy
|
|
||||||
# sudo chown -R erpdeploy /srv/erp
|
|
||||||
User=erpdeploy
|
|
||||||
Group=erpdeploy
|
|
||||||
WorkingDirectory=/srv/erp
|
|
||||||
# EnvironmentFile carries GITEA_WEBHOOK_SECRET (same .env used by compose).
|
|
||||||
EnvironmentFile=/srv/erp/.env
|
|
||||||
ExecStart=/usr/bin/python3 /srv/erp/infra/deploy-webhook/deploy_webhook.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
# Minimal exposure: no write access outside /srv/erp, no new privileges.
|
|
||||||
NoNewPrivileges=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=/srv/erp /tmp
|
|
||||||
ProtectHome=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -7,31 +7,3 @@ http:
|
|||||||
redirectScheme:
|
redirectScheme:
|
||||||
scheme: https
|
scheme: https
|
||||||
permanent: true
|
permanent: true
|
||||||
|
|
||||||
# Baseline security headers for every public router (attached in the
|
|
||||||
# prod overlay; local http://localhost stays untouched).
|
|
||||||
secure-headers:
|
|
||||||
headers:
|
|
||||||
stsSeconds: 31536000
|
|
||||||
stsIncludeSubdomains: true
|
|
||||||
contentTypeNosniff: true
|
|
||||||
referrerPolicy: strict-origin-when-cross-origin
|
|
||||||
customFrameOptionsValue: DENY
|
|
||||||
browserXssFilter: true
|
|
||||||
customResponseHeaders:
|
|
||||||
X-Powered-By: ""
|
|
||||||
Server: ""
|
|
||||||
|
|
||||||
# Edge rate limiting — coarse abuse brake for the API host
|
|
||||||
# (average 50 req/s sustained, burst 150). Fine-grained login limiting
|
|
||||||
# lives in the app (slowapi).
|
|
||||||
api-ratelimit:
|
|
||||||
rateLimit:
|
|
||||||
average: 50
|
|
||||||
burst: 150
|
|
||||||
|
|
||||||
# Cap request bodies at the edge (attachments are ≤5 MB per file;
|
|
||||||
# 12 MB leaves room for multipart overhead).
|
|
||||||
api-bodylimit:
|
|
||||||
buffering:
|
|
||||||
maxRequestBodyBytes: 12582912
|
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
# Monitoring & analytics stack
|
|
||||||
|
|
||||||
What runs where, and why — the ERP gets the three missing observability
|
|
||||||
layers on the same 8 GB VPS:
|
|
||||||
|
|
||||||
| Layer | Tool | What it answers | Budget |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Product analytics | **OpenPanel** (self-hosted) | Which features are used? Where do users drop out of the quote wizard? Funnel: login → wizard steps → quote submitted → accepted → received. Session replay **off** by design (heavy; revisit with sampling later) | ~1–1.5 GB |
|
|
||||||
| Error tracking | **GlitchTip** (Sentry-compatible) | What broke, for whom, with which stack trace — frontend (ErrorBoundary + fetch errors) and backend (all unhandled exceptions) | ~750 MB |
|
|
||||||
| Metrics + logs | **Prometheus + Grafana + Loki/Promtail + node/postgres-exporters + cAdvisor** | Request rate/latency/5xx, container RAM, Postgres connections, disk pressure; full container logs searchable in Grafana | ~1.4 GB |
|
|
||||||
|
|
||||||
The monitoring compose is a **separate compose project** — push-to-deploy
|
|
||||||
rebuilds of the app never restart monitoring containers.
|
|
||||||
|
|
||||||
## First-time deployment (VPS)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /srv/erp # the repo checkout used by the deploy webhook
|
|
||||||
|
|
||||||
# 1) monitoring section of .env (see .env.example) — fill in real values:
|
|
||||||
# GLITCHTIP_SECRET_KEY, GLITCHTIP_DOMAIN/HOST, GLITCHTIP_DB_PASSWORD,
|
|
||||||
# GRAFANA_ADMIN_PASSWORD, POSTGRES_PASSWORD (already set), LOG_FORMAT=json
|
|
||||||
|
|
||||||
# 2) the main compose gained a shared network — re-up once so it exists:
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
|
||||||
|
|
||||||
# 3) start monitoring:
|
|
||||||
docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env up -d
|
|
||||||
|
|
||||||
# 4) GlitchTip is routed publicly through Traefik for the browser SDK:
|
|
||||||
docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env \
|
|
||||||
--profile public up -d glitchtip
|
|
||||||
```
|
|
||||||
|
|
||||||
Admin UIs stay **loopback-only** (same posture as the MinIO console):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
ssh -L 3001:127.0.0.1:3001 -L 8001:127.0.0.1:8001 <user>@<server-host>
|
|
||||||
# Grafana → http://localhost:3001 (dashboards auto-provisioned)
|
|
||||||
# GlitchTip → http://localhost:8001 (create the first admin on first open)
|
|
||||||
```
|
|
||||||
|
|
||||||
In GlitchTip: create an **organization → project** for the backend and one
|
|
||||||
for the frontend, copy the DSNs into `/srv/erp/.env`
|
|
||||||
(`SENTRY_DSN_BACKEND`, `SENTRY_DSN_FRONTEND`) and redeploy the app once.
|
|
||||||
|
|
||||||
## Product analytics (OpenPanel)
|
|
||||||
|
|
||||||
Deployed from the upstream self-hosting branch (kept outside this repo —
|
|
||||||
it ships its own compose + update tooling):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt
|
|
||||||
git clone -b self-hosting https://github.com/Openpanel-dev/openpanel.git
|
|
||||||
cd openpanel/self-hosting
|
|
||||||
./setup # interactive
|
|
||||||
./start
|
|
||||||
```
|
|
||||||
|
|
||||||
Then:
|
|
||||||
|
|
||||||
1. Open the OpenPanel dashboard (expose it via Traefik on
|
|
||||||
`stats.bennu-api-bahrestan.ir` following its docs — add the container to
|
|
||||||
`traefik_network` and give the dashboard + its `/api` path labels),
|
|
||||||
create a project → copy the **client id / client secret**.
|
|
||||||
2. Put them in `/srv/erp/.env`:
|
|
||||||
`OPENPANEL_API_URL` (in-network URL of its api service),
|
|
||||||
`OPENPANEL_CLIENT_ID`, `OPENPANEL_CLIENT_SECRET`, and
|
|
||||||
`OPENPANEL_PUBLIC_URL` (browser-facing origin) — then rebuild the app:
|
|
||||||
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build api frontend`.
|
|
||||||
|
|
||||||
What gets tracked automatically once configured:
|
|
||||||
|
|
||||||
- **Frontend**: page views, `login_success`, `wizard_step` (funnel across
|
|
||||||
the 5 quote-wizard steps), `quote_submitted`, `user_identified`.
|
|
||||||
- **Backend** (server-side, cannot be blocked by the browser):
|
|
||||||
`login_success`, `quote_offered/edited/accepted/arrived/expired/rejected`,
|
|
||||||
`receipt_confirmed` — see `backend/app/services/analytics.py`.
|
|
||||||
|
|
||||||
Without the env vars everything is a no-op — the app never waits on
|
|
||||||
analytics (3 s timeout, swallow-all).
|
|
||||||
|
|
||||||
## Session replay
|
|
||||||
|
|
||||||
Deliberately **off** (records full DOM of every session → storage + privacy
|
|
||||||
costs; low value for a small internal team at launch). If wanted later:
|
|
||||||
enable OpenPanel replay with **10–25% sampling** and review retention —
|
|
||||||
budget +1 GB storage and re-check RAM headroom first.
|
|
||||||
|
|
||||||
## Alerting
|
|
||||||
|
|
||||||
`monitoring/prometheus/alerts.yml` defines: API down, 5xx > 5%, p95 > 2 s,
|
|
||||||
root disk < 10%, host RAM < 10%, Postgres > 80 connections. They appear in
|
|
||||||
Grafana alerting; wire a notification channel (email/Telegram) from the
|
|
||||||
Grafana UI when ready.
|
|
||||||
|
|
||||||
## Lean mode (if RAM gets tight)
|
|
||||||
|
|
||||||
Drop prometheus+grafana+loki+promtail+cadvisor+node-exporter (~1.1 GB) and
|
|
||||||
keep GlitchTip + OpenPanel only; add `uptime-kuma` (~100 MB) for
|
|
||||||
up/down pings. The app keeps `/health/ready` either way — the deploy
|
|
||||||
webhook uses it post-rebuild regardless of the metrics stack.
|
|
||||||
|
|
||||||
## What the app exposes for this stack
|
|
||||||
|
|
||||||
- `GET /health` — liveness (process up).
|
|
||||||
- `GET /health/ready` — readiness: Postgres + MinIO + Casdoor checks.
|
|
||||||
Used by the deploy webhook after every rebuild.
|
|
||||||
- `GET /metrics` — Prometheus format (unauthenticated, for scraping).
|
|
||||||
- JSON request logs with `X-Request-ID` (set `LOG_FORMAT=json` in prod)
|
|
||||||
→ shipped to Loki by Promtail.
|
|
||||||
|
|
||||||
## Local runs (Docker Desktop)
|
|
||||||
|
|
||||||
Start WITHOUT `--profile host-metrics` — node-exporter and cAdvisor need
|
|
||||||
Linux rootfs mounts and will fail to start on Windows/macOS hosts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-container and host metrics are absent locally; API metrics, Postgres
|
|
||||||
metrics, logs, GlitchTip and Grafana all work the same. On the Linux VPS
|
|
||||||
always include `--profile host-metrics`.
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# ============================================================================
|
|
||||||
# Monitoring stack — SEPARATE compose project from the ERP app on purpose:
|
|
||||||
# app rebuilds (push-to-deploy) must never restart monitoring containers.
|
|
||||||
#
|
|
||||||
# cd /srv/erp # repo root, so --env-file picks up the app's .env
|
|
||||||
# docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env up -d
|
|
||||||
#
|
|
||||||
# Included (≈2.2 GB RAM budgeted):
|
|
||||||
# GlitchTip (errors, Sentry-compatible) + its own small Postgres
|
|
||||||
# Prometheus (metrics) + Grafana (dashboards) + Loki/Promtail (logs)
|
|
||||||
# node-exporter (host) + postgres-exporter (app DB) + cAdvisor (containers)
|
|
||||||
#
|
|
||||||
# Admin UIs (Grafana :3001, GlitchTip :8001) bind to 127.0.0.1 only —
|
|
||||||
# reach them via SSH tunnel exactly like the MinIO console. GlitchTip ALSO
|
|
||||||
# needs a public origin for the browser SDK: set GLITCHTIP_HOST and enable
|
|
||||||
# the "public" profile (Traefik route + TLS) — see monitoring/README.md.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
services:
|
|
||||||
# ---------------------------------------------------------------- errors --
|
|
||||||
glitchtip:
|
|
||||||
image: glitchtip/glitchtip:latest # pinned upstream: version tags are dated, keep an eye on updates
|
|
||||||
container_name: monitoring-glitchtip
|
|
||||||
mem_limit: 640m
|
|
||||||
environment:
|
|
||||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY:?set GLITCHTIP_SECRET_KEY in .env}
|
|
||||||
GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN:?set GLITCHTIP_DOMAIN in .env}
|
|
||||||
DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASSWORD:-glitchtip}@glitchtip-postgres:5432/glitchtip
|
|
||||||
# Single-container mode (embedded worker) — no redis needed at this scale.
|
|
||||||
GLITCHTIP_EMBED_WORKER: "true"
|
|
||||||
VALKEY_URL: ""
|
|
||||||
ENABLE_USER_REGISTRATION: "false"
|
|
||||||
ENABLE_ORGANIZATION_CREATION: "false"
|
|
||||||
DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-noreply@bennu-api-bahrestan.ir}
|
|
||||||
EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://}
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:8001:8000"
|
|
||||||
depends_on:
|
|
||||||
glitchtip-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
- shared
|
|
||||||
- traefik # public DSN route (profile "public")
|
|
||||||
labels:
|
|
||||||
- traefik.enable=true
|
|
||||||
- traefik.http.routers.glitchtip.rule=Host(`${GLITCHTIP_HOST:-errors.localhost}`)
|
|
||||||
- traefik.http.routers.glitchtip.entrypoints=web
|
|
||||||
- traefik.http.services.glitchtip.loadbalancer.server.port=8000
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
glitchtip-postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: monitoring-glitchtip-postgres
|
|
||||||
mem_limit: 128m
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: glitchtip
|
|
||||||
POSTGRES_USER: glitchtip
|
|
||||||
POSTGRES_PASSWORD: ${GLITCHTIP_DB_PASSWORD:-glitchtip}
|
|
||||||
volumes:
|
|
||||||
- glitchtip_postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U glitchtip -d glitchtip"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- metrics --
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:v3.4.1
|
|
||||||
container_name: monitoring-prometheus
|
|
||||||
mem_limit: 512m
|
|
||||||
command:
|
|
||||||
- --config.file=/etc/prometheus/prometheus.yml
|
|
||||||
- --storage.tsdb.retention.time=14d
|
|
||||||
- --storage.tsdb.retention.size=2GB
|
|
||||||
volumes:
|
|
||||||
- ./prometheus:/etc/prometheus:ro
|
|
||||||
- prometheus_data:/prometheus
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
- shared
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:11.6.3
|
|
||||||
container_name: monitoring-grafana
|
|
||||||
mem_limit: 256m
|
|
||||||
environment:
|
|
||||||
GF_SECURITY_ADMIN_USER: admin
|
|
||||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD in .env}
|
|
||||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
|
||||||
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3001/}
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./grafana/provisioning:/etc/grafana/provisioning:ro
|
|
||||||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3001:3000"
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ logs --
|
|
||||||
loki:
|
|
||||||
image: grafana/loki:3.5.0
|
|
||||||
container_name: monitoring-loki
|
|
||||||
mem_limit: 384m
|
|
||||||
command: -config.file=/etc/loki/loki-config.yml
|
|
||||||
volumes:
|
|
||||||
- ./loki/loki-config.yml:/etc/loki/loki-config.yml:ro
|
|
||||||
- loki_data:/loki
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
promtail:
|
|
||||||
image: grafana/promtail:3.5.0
|
|
||||||
container_name: monitoring-promtail
|
|
||||||
mem_limit: 64m
|
|
||||||
command: -config.file=/etc/promtail/promtail-config.yml
|
|
||||||
volumes:
|
|
||||||
- ./promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
|
||||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# -------------------------------------------------------------- exporters --
|
|
||||||
# Linux-host only (see cadvisor note): local runs skip it via the profile.
|
|
||||||
node-exporter:
|
|
||||||
image: prom/node-exporter:v1.9.1
|
|
||||||
container_name: monitoring-node-exporter
|
|
||||||
mem_limit: 32m
|
|
||||||
profiles: ["host-metrics"]
|
|
||||||
command:
|
|
||||||
- --path.rootfs=/host
|
|
||||||
volumes:
|
|
||||||
- /:/host:ro,rslave
|
|
||||||
pid: host
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
postgres-exporter:
|
|
||||||
image: prometheuscommunity/postgres-exporter:v0.17.1
|
|
||||||
container_name: monitoring-postgres-exporter
|
|
||||||
mem_limit: 48m
|
|
||||||
environment:
|
|
||||||
# App database on the main stack's postgres (reaches it via erp_shared).
|
|
||||||
DATA_SOURCE_NAME: postgres://postgres:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/procurement?sslmode=disable
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
- shared
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
# Linux-host only: the rootfs bind mounts fail on Docker Desktop (Windows/macOS).
|
|
||||||
# VPS: docker compose -f monitoring/docker-compose.monitoring.yml --env-file .env \
|
|
||||||
# --profile host-metrics up -d
|
|
||||||
# Local: started WITHOUT the profile — per-container metrics are simply absent.
|
|
||||||
cadvisor:
|
|
||||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
|
||||||
container_name: monitoring-cadvisor
|
|
||||||
mem_limit: 128m
|
|
||||||
profiles: ["host-metrics"]
|
|
||||||
privileged: true
|
|
||||||
devices:
|
|
||||||
- /dev/kmsg
|
|
||||||
volumes:
|
|
||||||
- /:/rootfs:ro
|
|
||||||
- /var/run:/var/run:ro
|
|
||||||
- /sys:/sys:ro
|
|
||||||
- /var/lib/docker/:/var/lib/docker:ro
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
|
||||||
monitoring:
|
|
||||||
driver: bridge
|
|
||||||
# Reaches the ERP containers (api:8000/metrics, postgres:5432) — the main
|
|
||||||
# compose declares the same fixed-name network as "shared".
|
|
||||||
shared:
|
|
||||||
name: erp_shared
|
|
||||||
external: true
|
|
||||||
# Created by the main compose; required only for the public GlitchTip route.
|
|
||||||
traefik:
|
|
||||||
name: traefik_network
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
glitchtip_postgres_data:
|
|
||||||
prometheus_data:
|
|
||||||
grafana_data:
|
|
||||||
loki_data:
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
{
|
|
||||||
"annotations": { "list": [] },
|
|
||||||
"editable": true,
|
|
||||||
"fiscalYearStartMonth": 0,
|
|
||||||
"graphTooltip": 1,
|
|
||||||
"links": [],
|
|
||||||
"liveNow": false,
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": { "fillOpacity": 15, "lineWidth": 2, "showPoints": "never" },
|
|
||||||
"unit": "reqps"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
|
||||||
"id": 1,
|
|
||||||
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" } },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"expr": "sum by (status) (rate(http_requests_total{job=\"erp-api\"}[5m]))",
|
|
||||||
"legendFormat": "HTTP {{status}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "API — request rate by status",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": { "fillOpacity": 10, "lineWidth": 2, "showPoints": "never" },
|
|
||||||
"unit": "s",
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{ "color": "green", "value": null },
|
|
||||||
{ "color": "orange", "value": 1 },
|
|
||||||
{ "color": "red", "value": 2 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
|
||||||
"id": 2,
|
|
||||||
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" } },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket{job=\"erp-api\"}[5m])) by (le))",
|
|
||||||
"legendFormat": "p95"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"expr": "histogram_quantile(0.5, sum(rate(http_request_duration_seconds_bucket{job=\"erp-api\"}[5m])) by (le))",
|
|
||||||
"legendFormat": "p50"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "API — latency (p50 / p95)",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"custom": { "fillOpacity": 15, "lineWidth": 2, "showPoints": "never" },
|
|
||||||
"unit": "bytes"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
|
||||||
"id": 3,
|
|
||||||
"options": { "legend": { "calcs": ["lastNotNull", "max"], "displayMode": "table", "placement": "bottom" } },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"expr": "sum by (name) (container_memory_working_set_bytes{job=\"cadvisor\", name!=\"\", image!=\"\"})",
|
|
||||||
"legendFormat": "{{name}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Containers — memory in use",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] },
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
|
||||||
"id": 4,
|
|
||||||
"options": { "legend": { "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom" } },
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"expr": "sum(pg_stat_activity_count)",
|
|
||||||
"legendFormat": "postgres connections"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
|
||||||
"expr": "node_memory_MemAvailable_bytes",
|
|
||||||
"legendFormat": "host RAM available"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Database connections / host RAM",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
|
||||||
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 16 },
|
|
||||||
"id": 5,
|
|
||||||
"options": {
|
|
||||||
"sortOrder": "Descending",
|
|
||||||
"wrapLogMessage": true,
|
|
||||||
"showTime": true,
|
|
||||||
"enableLogDetails": true
|
|
||||||
},
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
|
||||||
"expr": "{compose_project=\"erp\"}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "All ERP container logs (Loki)",
|
|
||||||
"type": "logs"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"refresh": "30s",
|
|
||||||
"schemaVersion": 39,
|
|
||||||
"tags": ["erp"],
|
|
||||||
"templating": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"name": "DS_PROMETHEUS",
|
|
||||||
"label": "Prometheus",
|
|
||||||
"type": "datasource",
|
|
||||||
"query": "prometheus",
|
|
||||||
"hide": 2,
|
|
||||||
"current": { "text": "Prometheus", "value": "Prometheus" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DS_LOKI",
|
|
||||||
"label": "Loki",
|
|
||||||
"type": "datasource",
|
|
||||||
"query": "loki",
|
|
||||||
"hide": 2,
|
|
||||||
"current": { "text": "Loki", "value": "Loki" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"time": { "from": "now-6h", "to": "now" },
|
|
||||||
"timepicker": {},
|
|
||||||
"timezone": "browser",
|
|
||||||
"title": "ERP — Stack overview",
|
|
||||||
"uid": "erp-overview",
|
|
||||||
"version": 1
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
apiVersion: 1
|
|
||||||
|
|
||||||
providers:
|
|
||||||
- name: erp-dashboards
|
|
||||||
orgId: 1
|
|
||||||
folder: ERP
|
|
||||||
type: file
|
|
||||||
disableDeletion: false
|
|
||||||
updateIntervalSeconds: 60
|
|
||||||
options:
|
|
||||||
path: /var/lib/grafana/dashboards
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
apiVersion: 1
|
|
||||||
|
|
||||||
datasources:
|
|
||||||
- name: Prometheus
|
|
||||||
type: prometheus
|
|
||||||
access: proxy
|
|
||||||
url: http://prometheus:9090
|
|
||||||
isDefault: true
|
|
||||||
editable: false
|
|
||||||
|
|
||||||
- name: Loki
|
|
||||||
type: loki
|
|
||||||
access: proxy
|
|
||||||
url: http://loki:3100
|
|
||||||
editable: false
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
auth_enabled: false
|
|
||||||
|
|
||||||
server:
|
|
||||||
http_listen_port: 3100
|
|
||||||
|
|
||||||
common:
|
|
||||||
path_prefix: /loki
|
|
||||||
storage:
|
|
||||||
filesystem:
|
|
||||||
chunks_directory: /loki/chunks
|
|
||||||
rules_directory: /loki/rules
|
|
||||||
replication_factor: 1
|
|
||||||
ring:
|
|
||||||
kvstore:
|
|
||||||
store: inmemory
|
|
||||||
|
|
||||||
schema_config:
|
|
||||||
configs:
|
|
||||||
- from: 2024-01-01
|
|
||||||
store: tsdb
|
|
||||||
object_store: filesystem
|
|
||||||
schema: v13
|
|
||||||
index:
|
|
||||||
prefix: index_
|
|
||||||
period: 24h
|
|
||||||
|
|
||||||
limits_config:
|
|
||||||
# Keep the log store bounded — this box shares 8 GB RAM / limited disk.
|
|
||||||
retention_period: 14d
|
|
||||||
ingestion_rate_mb: 4
|
|
||||||
ingestion_burst_size_mb: 8
|
|
||||||
max_query_series: 500
|
|
||||||
|
|
||||||
compactor:
|
|
||||||
working_directory: /loki/compactor
|
|
||||||
retention_enabled: true
|
|
||||||
delete_request_store: filesystem
|
|
||||||
|
|
||||||
ruler:
|
|
||||||
storage:
|
|
||||||
type: local
|
|
||||||
local:
|
|
||||||
directory: /loki/rules
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Alert rules surfaced in Grafana's alerting UI (no Alertmanager needed at
|
|
||||||
# this scale — Grafana can route them later via email/Telegram).
|
|
||||||
groups:
|
|
||||||
- name: erp-availability
|
|
||||||
rules:
|
|
||||||
- alert: ErpApiDown
|
|
||||||
expr: up{job="erp-api"} == 0
|
|
||||||
for: 2m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "ERP API is not scraping (down or /metrics failing)"
|
|
||||||
|
|
||||||
- alert: ErpApiHighErrorRate
|
|
||||||
expr: |
|
|
||||||
sum(rate(http_requests_total{job="erp-api", status=~"5.."}[5m]))
|
|
||||||
/ clamp_min(sum(rate(http_requests_total{job="erp-api"}[5m])), 0.001) > 0.05
|
|
||||||
for: 5m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "ERP API 5xx rate above 5% for 5 minutes"
|
|
||||||
|
|
||||||
- alert: ErpApiSlowRequests
|
|
||||||
expr: |
|
|
||||||
histogram_quantile(0.95,
|
|
||||||
sum(rate(http_request_duration_seconds_bucket{job="erp-api"}[5m])) by (le)) > 2
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "ERP API p95 latency above 2s for 10 minutes"
|
|
||||||
|
|
||||||
- name: host
|
|
||||||
rules:
|
|
||||||
- alert: HostDiskAlmostFull
|
|
||||||
expr: (node_filesystem_avail_bytes{mountpoint="/", fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{mountpoint="/", fstype!~"tmpfs|overlay"}) < 0.1
|
|
||||||
for: 15m
|
|
||||||
labels:
|
|
||||||
severity: critical
|
|
||||||
annotations:
|
|
||||||
summary: "Root filesystem below 10% free (deploy builds will fail)"
|
|
||||||
|
|
||||||
- alert: HostMemoryPressure
|
|
||||||
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "Host RAM below 10% available (check monitoring stack limits)"
|
|
||||||
|
|
||||||
- alert: PostgresTooManyConnections
|
|
||||||
expr: sum(pg_stat_activity_count) > 80
|
|
||||||
for: 10m
|
|
||||||
labels:
|
|
||||||
severity: warning
|
|
||||||
annotations:
|
|
||||||
summary: "PostgreSQL connection count above 80"
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
global:
|
|
||||||
scrape_interval: 30s
|
|
||||||
evaluation_interval: 30s
|
|
||||||
|
|
||||||
rule_files:
|
|
||||||
- /etc/prometheus/alerts.yml
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
# The ERP API — /metrics is exposed by prometheus-fastapi-instrumentator
|
|
||||||
# (request rate, latency histogram, error counts). Reached over the
|
|
||||||
# erp_shared network the main compose also joins.
|
|
||||||
- job_name: erp-api
|
|
||||||
metrics_path: /metrics
|
|
||||||
static_configs:
|
|
||||||
- targets: ["api:8000"]
|
|
||||||
|
|
||||||
# Host-level metrics (CPU, RAM, disk, network).
|
|
||||||
- job_name: node
|
|
||||||
static_configs:
|
|
||||||
- targets: ["node-exporter:9100"]
|
|
||||||
|
|
||||||
# Per-container metrics (CPU, memory, restarts) for every container.
|
|
||||||
- job_name: cadvisor
|
|
||||||
static_configs:
|
|
||||||
- targets: ["cadvisor:8080"]
|
|
||||||
|
|
||||||
# App database metrics (connections, cache hit ratio, WAL, ...).
|
|
||||||
- job_name: postgres
|
|
||||||
static_configs:
|
|
||||||
- targets: ["postgres-exporter:9187"]
|
|
||||||
|
|
||||||
- job_name: prometheus
|
|
||||||
static_configs:
|
|
||||||
- targets: ["localhost:9090"]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Ships every container's JSON logs to Loki (docker service discovery),
|
|
||||||
# tagging streams with the compose project / service / container name so
|
|
||||||
# logs are filterable in Grafana, e.g. {compose_service="api"}.
|
|
||||||
server:
|
|
||||||
http_listen_port: 9080
|
|
||||||
|
|
||||||
positions:
|
|
||||||
filename: /tmp/positions.yaml
|
|
||||||
|
|
||||||
clients:
|
|
||||||
- url: http://loki:3100/loki/api/v1/push
|
|
||||||
|
|
||||||
scrape_configs:
|
|
||||||
- job_name: docker
|
|
||||||
docker_sd_configs:
|
|
||||||
- host: unix:///var/run/docker.sock
|
|
||||||
refresh_interval: 30s
|
|
||||||
filters:
|
|
||||||
- name: label
|
|
||||||
values: ["com.docker.compose.service"]
|
|
||||||
relabel_configs:
|
|
||||||
- source_labels: ["__meta_docker_container_label_com_docker_compose_project"]
|
|
||||||
target_label: compose_project
|
|
||||||
- source_labels: ["__meta_docker_container_label_com_docker_compose_service"]
|
|
||||||
target_label: compose_service
|
|
||||||
- source_labels: ["__meta_docker_container_name"]
|
|
||||||
target_label: container
|
|
||||||
pipeline_stages:
|
|
||||||
- json:
|
|
||||||
expressions:
|
|
||||||
ts: time
|
|
||||||
stream: stream
|
|
||||||
log: log
|
|
||||||
- labels:
|
|
||||||
stream:
|
|
||||||
- timestamp:
|
|
||||||
source: ts
|
|
||||||
format: RFC3339Nano
|
|
||||||
- output:
|
|
||||||
source: log
|
|
||||||
+2
-33
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
|
|||||||
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
|
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
|
||||||
-- category_id is NULLABLE and DERIVED automatically from the first linked
|
-- Materials are global, category-free entities. name is GLOBALLY
|
||||||
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
|
|
||||||
-- unique (uq_material_name). topic is a legacy display column no longer
|
-- unique (uq_material_name). topic is a legacy display column no longer
|
||||||
-- written by the app.
|
-- written by the app.
|
||||||
CREATE TABLE materials (
|
CREATE TABLE materials (
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
|
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
topic TEXT, -- LEGACY display column
|
topic TEXT, -- LEGACY display column
|
||||||
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
@@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
|
|||||||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
|
|
||||||
-- Idempotent: only touches rows where category_id IS NULL.
|
|
||||||
CREATE OR REPLACE FUNCTION fn_derive_material_category()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
UPDATE materials
|
|
||||||
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = NEW.material_id
|
|
||||||
AND category_id IS NULL;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
|
||||||
AFTER INSERT ON supplier_materials
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
|
|
||||||
|
|
||||||
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
||||||
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
||||||
-- inserts decisions directly as 'chosen').
|
-- inserts decisions directly as 'chosen').
|
||||||
@@ -634,15 +614,4 @@ CREATE TRIGGER trg_item_decisions_sync_price
|
|||||||
-- double-applied. Arrival auto-posting (POST /pm/receipts) goes through
|
-- double-applied. Arrival auto-posting (POST /pm/receipts) goes through
|
||||||
-- the same service path and is idempotent via receipt_allocation_id.
|
-- the same service path and is idempotent via receipt_allocation_id.
|
||||||
|
|
||||||
-- ---------- AUDIT LOG (general activity trail, v2.7) ----------
|
|
||||||
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 '{}'
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
Reference in New Issue
Block a user