v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.
Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.
Backend:
- expand_service: category_by_material() resolves a material's display category from its
FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.
Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
the backend's quote filtering); supplier categories now come from the supplier FK
instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
materialOptions' unused "cat" branch.
Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this folder — no external files or sibling folders required.
| Service | Container | Tech | Exposed port |
|---|---|---|---|
| postgres | procurement-postgres |
PostgreSQL 18 (one instance: procurement + casdoor DBs) |
5433 |
| minio | procurement-minio |
MinIO object storage (S3-compatible) | 9000 (API), 9001 (console) |
| casdoor | procurement-casdoor |
Casdoor (OIDC identity provider) | 8080 |
| api | procurement-api |
FastAPI + SQLAlchemy (asyncpg) | 8000 |
| frontend | procurement-frontend |
React 19 + Vite, served by nginx (internal) | via Traefik |
| traefik | procurement-traefik |
Traefik v3 — public reverse proxy (edge) | 80 (web), 443 (websecure) |
Browser ──► Traefik :80 (edge)
│
├── Host(localhost) ──► frontend (nginx SPA :80)
│ │
│ └── /api/* proxy ──► api :8000 (/api/v1)
│
├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console)
│
├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files)
📁 Layout
v1.3/
├── docker-compose.yml ← all paths are relative to this folder
├── docker-compose.prod.yml ← production overlay (real domains + HTTPS via Let's Encrypt)
├── start.ps1 ← PowerShell helper (build + start + status)
├── mc.cmd ← MinIO Client helper (inspect the bucket)
├── README.md
├── sql/
│ ├── migration.sql ← DB schema v2.6 (auto-runs on first boot)
│ └── 02-casdoor-db.sql ← creates the `casdoor` role+DB on the same instance
├── infra/
│ └── traefik-dynamic.yml ← Traefik shared middlewares (HTTP→HTTPS redirect)
├── docs/ ← ERDs + migration plan + auth guide
│ ├── erd.txt (v2.4 — old procurement schema)
│ ├── new erd.txt (v2.5 — material → supplier → category)
│ ├── NewERD.txt (v2.6 — integrated procurement + inventory)
│ ├── casdoor-auth.md (v2.6 — Casdoor login, roles, failure scenarios)
│ └── material-supplier-category-migration-plan.md
├── backend/ ← build context of the `api` service
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── .dockerignore
│ ├── app/ (source code — procurement + inventory + auth)
│ └── tools/ (smoke-test + seed scripts, copied into the image)
└── frontend/ ← build context of the `frontend` service
├── Dockerfile
├── nginx.conf
├── .dockerignore
├── .env.example (copy to .env if you need a custom API base URL)
├── index.html
├── package.json
├── package-lock.json
├── tsconfig*.json
├── vite.config.ts
├── public/
└── src/
└── pages/
├── Dashboard/ (procurement office)
├── Wizard/ (add new offer)
├── Suppliers/ (suppliers & topics)
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
🔐 Login
The whole system is behind a login page (/login). Login is in-page:
the form (username / password) is sent to
POST /api/v1/auth/login; the backend exchanges it with Casdoor
(password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor.
- مدیر پروژه (PM) → lands in the inventory panel (their project) — can record stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
- مدیر تدارکات / ادمین → lands in the procurement office — can manage quotes, suppliers, projects, and in the inventory panel: dashboard, daily control, «کالاهای در راه» (receiving included) and «سازماندهی» (users, PMs, warehouses). The «انبارها» and «تغییر موجودی» sections are PM-only.
Developer Mode (top-left button on the login page) is a 3-step flow for
the admin (super user): ① superuser auth gate (admin credentials only) →
② role switcher (ادمین / مدیر تدارکات / مدیر پروژه + project) → ③ session
activation by role (admin → پنل مدیریت ادمین at /admin, مدیر تدارکات →
procurement office, مدیر پروژه → their project's inventory panel). It is
controlled by the VITE_ENABLE_DEV_LOGIN env var (in Docker: the
DEV_LOGIN_ENABLED compose build arg, default true — set it to false
for production builds).
| What | Where | Credentials |
|---|---|---|
| App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (CASDOOR_ADMIN_PW) — never commit them |
Seeded app users (org inventory) |
razaei (admin), mousavi (مدیر تدارکات), mohaghegh (مدیر پروژه), tester (مدیر پروژه، پروژه «تست») |
password from CASDOOR_SEED_PASSWORD (bcrypt-hashed at rest — see backend/tools/seed_casdoor.py) |
Run backend/tools/seed_casdoor.py once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist —
are in docs/casdoor-auth.md.
The currently committed seed (
backups/علی محقق/) instead ships a single admin —ali.mohaghegh(«علی محقق») — see Restoring the committed seed data.
🌐 Working with the deployed system (server)
The production deployment of this stack runs on the server and is reachable at these public addresses (HTTPS):
| What | Address |
|---|---|
| App (ERP) | https://aramesh.bennu-api-bahrestan.ir/ |
| Casdoor admin panel | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org inventory); the role is
assigned inside the app (users.role — see «Login» above and
docs/casdoor-auth.md).
MinIO console on the server (SSH tunnel)
The MinIO console is deliberately loopback-only on the server
(127.0.0.1:9001) — verified unreachable from the internet — so the way in
is an SSH tunnel from your machine:
-
Open PowerShell and start the tunnel (keep the window open while you use the panel):
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>You will also land in a normal SSH session — that is fine; the tunnel lives as long as the window stays open.
-
In your browser, open: http://localhost:9001
-
Log in with the production MinIO root credentials from the server's
/srv/erp/.env(MINIO_ROOT_USER/MINIO_ROOT_PASSWORD) — they are never written in this repo or its history. Seedocs/SECRET-MANAGEMENT.md.
You will see the procurement-files bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
⚠️ Those credentials are full admin access to the file storage. They live
only in the server-side .env; if one ever leaks, rotate it immediately
(checklist in docs/SECRET-MANAGEMENT.md).
Close the PowerShell window (or Ctrl+C) when you are done to drop the
tunnel.
Notes from the server setup:
- The panel is published only on the server's loopback, so the internet cannot reach it at all — the tunnel is what carries it to your browser.
- Host port
9000stays reserved for the deploy-webhook receiver, which is why the console uses9001. - If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g.
storage.aramesh.bennu-api-bahrestan.irbehind Traefik with a password prompt on top), that can be arranged on request — the tunnel remains the safer default.
Prerequisites (target machine)
- Docker Desktop (Windows) or Docker Engine + Compose plugin (Linux/macOS)
- ~3–4 GB free disk for images (
postgres:18,python:3.13-slim,node:22-alpine,nginx:alpine,minio/minio,minio/mc) - Internet access on first build (image +
npm cidownloads)
Start the whole stack
cd <this-folder>
docker compose up -d --build
First run downloads base images and builds the frontend (takes a few minutes).
Subsequent runs: docker compose up -d.
Wait until all services are up:
docker compose ps
All should show Up (postgres also (healthy)).
Verify
| What | Where |
|---|---|
| Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → {"status":"ok","service":"procurement-api"} |
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready |
| Metrics | http://localhost:8000/metrics |
| 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) |
Automated end-to-end checks (authenticates via SMOKE_USER /
SMOKE_PASSWORD — the seeder users):
backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py
Every API route now requires a Casdoor JWT — a bare
curl http://localhost/api/v1/quotesreturns 401 by design. Day-2 operations (deploys, backups, monitoring):docs/OPERATIONS.md.
MinIO (object storage for invoice / proforma files)
| Item | Value |
|---|---|
| S3 API endpoint | localhost:9000 (from the API container: minio:9000) |
| Web console | http://localhost:9001 |
| Root user / password | minioadmin / minioadmin (dev — change for production) |
| Bucket | procurement-files (created automatically by the minio-init container) |
| Object key layout | {quote_id}/{kind}/{attachment_id} where kind = invoice | proforma |
File bytes live in MinIO; the quote_attachments table holds the metadata.
Ad-hoc bucket inspection from the host (see mc.cmd):
.\mc.cmd ls --recursive
Database credentials / connection
Host: localhost
Port: 5433
Database: procurement
Username: postgres
Password: postgres
docker exec -it procurement-postgres psql -U postgres -d procurement
Recovery / seeding
The databases are not in git — Docker volumes hold the data, and database dumps must never be committed (they contain user rows and signing material). Two supported ways to bring a fresh stack up:
-
Empty boot — schema v2.6 auto-applies on the first boot of an empty
postgres_datavolume; then run the seeders:# 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" -
Restore your own dump — create dumps on the server (kept server-side, e.g.
/srv/erp/backups/), never in git:docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dumpRestore with
pg_restore --clean --if-exists(stopapi+casdoorfirst — this replaces all data). The scheduled backup sidecar (seedocs/OPERATIONS.md) automates this.
Common operations
docker compose logs -f api # see logs
docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS) and required on
every API route except
/health,/health/readyand/auth/login(401 without a token, 403 without a role). The former dev-header fallback (X-User-Role/X-User-Name-B64) is disabled unlessPROCUREMENT_DEV_LOGIN_ENABLED=true— never enable it in production. - Roles live in the app's
userstable (Casdoor is identity-only):pm,procurement_manager,admin. Role changes are admin-only (RBAC); the procurement manager can only assign projects to project managers. The admin has a dedicated panel at/admin(user sync from Casdoor + grant/revoke «مدیر تدارکات»). - Partial goods receipt («رسید جزیی») is supported in «کالاهای در راه»: at least one item with a valid quantity is enough to submit; other items stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z» badges and a «رسید جزیی» badge on the quote.
- One PostgreSQL instance serves both the app (
procurement) and Casdoor (casdoor) — the separate casdoor-postgres container is gone (data was dumped/restored; seedocs/casdoor-auth.md). - Traefik v3 is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies
/apiinternally. Production (real domains, HTTPS via Let's Encrypt, HTTP→HTTPS redirect):docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build(see the prod file header for the required env vars). - Schema runs automatically ONLY on the first boot of an empty
postgres_datavolume (seedocker-entrypoint-initdb.d); the API also runsCREATE TABLE IF NOT EXISTSfor the units-dashboard tables andquote_attachmentsat startup. - Invoice upload requires an
accepted/arrivedquote; proforma has no restriction. - The supplier detail page (
/suppliers/:id) shows the purchase history with invoice/proforma download links from MinIO. - If builds fail with disk-full errors:
docker system prune -a, then rebuild. - If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check
docker compose psanddocker compose logs api.