Merge feature/show-material-suppliers into the all-branches integration

Conflict in supplier_materials.py resolved by keeping HEAD's auth
(_procurement_writer) and list route while adopting the branch's UUID
parameter annotations.
This commit is contained in:
MMD
2026-10-01 15:44:12 +03:30
6 changed files with 472 additions and 235 deletions
+109 -93
View File
@@ -1,3 +1,4 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO) # Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this Self-contained ship package: everything needed to run the stack is in this
@@ -12,19 +13,22 @@ folder — no external files or sibling folders required.
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik | | **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) | | **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
``` ```
Browser ──► Traefik :80 (edge) Browser ──► Traefik :80 (edge)
│ │
├── Host(localhost) ──► frontend (nginx SPA :80) ├── Host(localhost) ──► frontend (nginx SPA :80)
│ │ │ │
│ └── /api/* proxy ──► api :8000 (/api/v1) │ └── /api/* proxy ──► api :8000 (/api/v1)
│ │
├── Host(api.localhost) ──► api :8000 ├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login) ├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console) └── Host(s3/storage.localhost) ──► minio (API / console)
│ │
├── postgres :5432 (procurement + casdoor DBs) ├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files) └── minio :9000 (invoice/proforma files)
``` ```
--- ---
@@ -74,6 +78,7 @@ v1.3/
├── Projects/ (projects) ├── Projects/ (projects)
├── Units/ (unit conversions) ├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port) └── Inventory/ (warehouse management panel — React port)
``` ```
--- ---
@@ -86,12 +91,12 @@ the form (username / password) is sent to
(password grant), verifies the returned JWT, and starts the session — (password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor. no browser redirect to Casdoor.
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record * **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»). stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage * **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control, quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses). «کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only. The «انبارها» and «تغییر موجودی» sections are PM-only.
**Developer Mode** (top-left button on the login page) is a 3-step flow for **Developer Mode** (top-left button on the login page) is a 3-step flow for
the admin (super user): ① superuser auth gate (admin credentials only) → the admin (super user): ① superuser auth gate (admin credentials only) →
@@ -103,7 +108,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
for production builds). for production builds).
| What | Where | Credentials | | What | Where | Credentials |
|---|---|---| | --- | --- | --- |
| App | http://localhost (via Traefik) | — | | App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them | | 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`) | | Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
@@ -111,11 +116,11 @@ for production builds).
Run `backend/tools/seed_casdoor.py` once after the first boot to create the Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details — Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist — addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md). are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a > The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see > single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](#restoring-the-committed-seed-data). > [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
--- ---
@@ -125,13 +130,13 @@ The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS): at these public addresses (HTTPS):
| What | Address | | What | Address |
|---|---| | --- | --- |
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ | | **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ | | **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)). [`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel) ### MinIO console on the server (SSH tunnel)
@@ -140,21 +145,20 @@ The MinIO console is deliberately **loopback-only** on the server
is an SSH tunnel from your machine: is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you 1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel): use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
```powershell ```
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
```
You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open.
You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open.
2. In your browser, open: **http://localhost:9001** 2. In your browser, open: **http://localhost:9001**
3. Log in with the production MinIO root credentials from the server's 3. Log in with the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are `/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See **never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md). [`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there. ERP stores, browsable and downloadable from there.
@@ -168,22 +172,22 @@ tunnel.
Notes from the server setup: Notes from the server setup:
- The panel is published only on the server's loopback, so the internet * The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser. cannot reach it at all — the tunnel is what carries it to your browser.
- Host port `9000` stays reserved for the deploy-webhook receiver, which is * Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`. why the console uses `9001`.
- If you ever need the raw S3 API from your machine, or a clickable URL for * If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default. tunnel remains the safer default.
--- ---
## Prerequisites (target machine) ## Prerequisites (target machine)
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS) * **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`) * ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
- Internet access on first build (image + `npm ci` downloads) * Internet access on first build (image + `npm ci` downloads)
--- ---
@@ -192,6 +196,7 @@ Notes from the server setup:
```powershell ```powershell
cd <this-folder> cd <this-folder>
docker compose up -d --build docker compose up -d --build
``` ```
First run downloads base images and builds the frontend (takes a few minutes). First run downloads base images and builds the frontend (takes a few minutes).
@@ -201,6 +206,7 @@ Wait until all services are up:
```powershell ```powershell
docker compose ps docker compose ps
``` ```
All should show `Up` (postgres also `(healthy)`). All should show `Up` (postgres also `(healthy)`).
@@ -210,7 +216,7 @@ All should show `Up` (postgres also `(healthy)`).
## Verify ## Verify
| What | Where | | What | Where |
|---|---| | --- | --- |
| Frontend UI | http://localhost | | Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs | | API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` | | API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
@@ -225,23 +231,24 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell ```powershell
backend\tools\smoke_test.py # read paths backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py backend\tools\smoke_test_write.py / smoke_test_misc.py
``` ```
> Every API route now requires a Casdoor JWT — a bare > Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design. > `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md). > Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
--- ---
## MinIO (object storage for invoice / proforma files) ## MinIO (object storage for invoice / proforma files)
| Item | Value | | Item | Value |
|---|---| | --- | --- |
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) | | S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
| Web console | http://localhost:9001 | | Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) | | Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) | | Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` | | Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata. File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell ```powershell
.\mc.cmd ls --recursive .\mc.cmd ls --recursive
``` ```
--- ---
@@ -261,10 +269,12 @@ Port: 5433
Database: procurement Database: procurement
Username: postgres Username: postgres
Password: postgres Password: postgres
``` ```
```powershell ```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement docker exec -it procurement-postgres psql -U postgres -d procurement
``` ```
--- ---
@@ -276,26 +286,28 @@ database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up: material). Two supported ways to bring a fresh stack up:
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty 1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders: `postgres_data` volume; then run the seeders:
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
2. **Restore your own dump** — create dumps on the server (kept server-side, 2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git: e.g. `/srv/erp/backups/`), never in git:
```powershell
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```powershell ```
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
(see `docs/OPERATIONS.md`) automates this. first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations ## Common operations
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
docker compose down # stop (keep data) docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
``` ```
--- ---
## Known behavior ## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on * Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login` every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback (401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless (`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production. `PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`, * Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC); `procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers. the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor + The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»). grant/revoke «مدیر تدارکات»).
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»: * **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
at least one item with a valid quantity is enough to submit; other items at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z» stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote. badges and a «رسید جزیی» badge on the quote.
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor * **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
(`casdoor`) — the separate casdoor-postgres container is gone (data was (`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`). dumped/restored; see `docs/casdoor-auth.md`).
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only * **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. Production (real domains, serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect): HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build` `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars). (see the prod file header for the required env vars).
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume * Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS` (see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup. for the units-dashboard tables and `quote_attachments` at startup.
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction. * Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
- The supplier detail page (`/suppliers/:id`) shows the purchase history with * The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO. invoice/proforma download links from MinIO.
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild. * **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامین‌کننده‌ای برای این متریال ثبت نشده است») if none exist.
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not * If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
reachable; check `docker compose ps` and `docker compose logs api`. * If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`.
```
+122 -25
View File
@@ -2,8 +2,10 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog. GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink. DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — price-comparison data source. GET /materials/{id}/suppliers — suppliers linked to a material.
""" """
from uuid import UUID
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,29 +24,47 @@ router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_procurement_writer() _procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(
category: MaterialCategory | None = None, link: SupplierMaterial,
supplier: Supplier | None = None) -> dict: material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None,
) -> dict:
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
return { return {
"id": str(link.id), "id": str(link.id),
"supplier_id": str(link.supplier_id), "supplier_id": str(link.supplier_id),
"material_id": str(link.material_id), "material_id": str(link.material_id),
"is_active": link.is_active, "is_active": link.is_active,
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None, "last_quoted_price": (
float(link.last_quoted_price) if link.last_quoted_price is not None else None
),
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None, "last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at), "created_at": to_iso(link.created_at),
"material": { "material": (
"id": str(material.id), {
"name": material.name, "id": str(material.id),
"category": {"id": str(category.id), "name": category.name} if category else None, "name": material.name,
} if material else None, "category": (
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None, {"id": str(category.id), "name": category.name} if category else None
),
}
if material
else None
),
"supplier": (
{"id": str(supplier.id), "name": supplier.name} if supplier else None
),
} }
# ---------------------------------------------------------------------------
# GET /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.get("/suppliers/{supplier_id}/materials") @router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials( async def list_supplier_materials(
supplier_id, supplier_id: UUID,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -58,14 +78,24 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at) .order_by(SupplierMaterial.created_at)
) )
items, meta = await paginate(stmt, page, page_size, db) items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items} mat_ids = [lnk.material_id for lnk in items]
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} materials: dict = {}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta} if mat_ids:
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
materials = {m.id: m for m in result.scalars()}
return {
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
"meta": meta,
}
# ---------------------------------------------------------------------------
# POST /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.post("/suppliers/{supplier_id}/materials", status_code=201) @router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material( async def create_supplier_material(
supplier_id, supplier_id: UUID,
payload: SupplierMaterialCreate, payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer), _auth=Depends(_procurement_writer),
@@ -73,13 +103,17 @@ 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)
return {"data": link_to_dict(link, material, None)} return {"data": link_to_dict(link, material)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}") @router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material( async def delete_supplier_material(
supplier_id, supplier_id: UUID,
material_id, material_id: UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer), _auth=Depends(_procurement_writer),
): ):
@@ -93,17 +127,80 @@ async def list_material_suppliers(
material_id, material_id,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
# ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers(
material_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Return the suppliers linked to a material, straight from PostgreSQL.
The path parameter is typed as UUID so FastAPI validates and coerces it
before the handler runs; a malformed id is rejected with 422, an unknown
material with 404.
Atomic JOIN — a supplier is returned only through its supplier_materials
link, so the response can never contain a supplier that is not actually
linked to this material:
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
Response envelope (frontend MaterialSupplier binds these fields directly):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
]
}
"""
material = await db.get(Material, material_id) material = await db.get(Material, material_id)
if material is None: if material is None:
raise NotFoundError("متریال یافت نشد") raise NotFoundError("متریال یافت نشد")
stmt = ( stmt = (
select(SupplierMaterial) select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id) .where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last()) .order_by(Supplier.name)
) )
items, meta = await paginate(stmt, page, page_size, db) suppliers = (await db.execute(stmt)).scalars().all()
sup_ids = {l.supplier_id for l in items}
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()} return {
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta} "data": [
{
"id": str(supplier.id),
"name": supplier.name,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"location": supplier.location,
}
for supplier in suppliers
]
}
+43
View File
@@ -480,6 +480,39 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" }; return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
} }
// تأمین‌کنندگان متصل به یک متریال — پاسخ GET /materials/{id}/suppliers
// Raw shape mirrors the backend dict exactly: clean supplier objects produced
// by an atomic JOIN (select(Supplier).join(SupplierMaterial)…) — the name
// always comes from the suppliers table, never from the material row.
interface RawMaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
/** Clean UI-facing type for one supplier linked to a material. */
export interface MaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
return {
id: raw.id,
// Bound straight from the supplier object in the API response — there is
// no fallback, so a supplier row can never render the material's name.
name: raw.name,
phone: raw.phone,
contact_person: raw.contact_person,
location: raw.location,
};
}
// فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند) // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment { interface RawAttachment {
id: string; id: string;
@@ -1232,6 +1265,16 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => { updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id }); await patchData(`/materials/${id}`, { canonical_unit_id });
}, },
// فهرست تأمین‌کننده‌های متصل به یک متریال (GET /materials/{id}/suppliers)
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
// The backend returns { data: [...clean supplier objects] } from an
// atomic JOIN; getData<T> unwraps the envelope. No fallback data —
// an empty array means the material genuinely has no suppliers.
const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers`,
);
return raw.map(adaptMaterialSupplier);
},
}, },
projects: { projects: {
+194 -114
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Building2,
ChevronDown, ChevronDown,
Link2, Link2,
Pencil, Pencil,
@@ -12,7 +13,7 @@ import {
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import type { StandardConversion } from "../../lib/types"; import type { StandardConversion } from "../../lib/types";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialUnitRow } from "../../lib/api"; import { api, apiV26, nextId, type BrandLinkRow, type MaterialSupplier, type MaterialUnitRow } from "../../lib/api";
import { import {
refreshProcurementStore, refreshProcurementStore,
useDeleteStandardConversion, useDeleteStandardConversion,
@@ -102,6 +103,11 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false); const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]); const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
// تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false);
const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null);
// واحدها و برندهای سراسری (فهرست دیتابیس) // واحدها و برندهای سراسری (فهرست دیتابیس)
const [units, setUnits] = useState<UnitLite[]>([]); const [units, setUnits] = useState<UnitLite[]>([]);
const [allBrands, setAllBrands] = useState<string[]>([]); const [allBrands, setAllBrands] = useState<string[]>([]);
@@ -253,6 +259,7 @@ export function UnitsPage() {
if (!materialId) { if (!materialId) {
setRows([]); setRows([]);
setBrandLinks([]); setBrandLinks([]);
setMaterialSuppliers([]);
return; return;
} }
void loadRows(materialId); void loadRows(materialId);
@@ -263,6 +270,35 @@ export function UnitsPage() {
setFormBrand(""); setFormBrand("");
}, [materialId, loadRows]); }, [materialId, loadRows]);
// واکشی تأمین‌کننده‌های متصل به متریال انتخاب‌شده
useEffect(() => {
if (!materialId) {
setMaterialSuppliers([]);
setSuppliersLoadedId(null);
return;
}
let alive = true;
setSuppliersLoading(true);
api.materials
.listSuppliers(materialId)
.then((list) => {
if (alive) {
setMaterialSuppliers(list);
setSuppliersLoadedId(materialId);
}
})
.catch((err: unknown) => {
if (!alive) return;
setMaterialSuppliers([]);
setSuppliersLoadedId(materialId);
// Surface the real error so backend issues are not silently hidden
const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود";
toast.error(msg);
})
.finally(() => { if (alive) setSuppliersLoading(false); });
return () => { alive = false; };
}, [materialId]);
const brandNameFor = (row: MaterialUnitRow): string => { const brandNameFor = (row: MaterialUnitRow): string => {
if (!row.brand_material_id) return ""; if (!row.brand_material_id) return "";
return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || ""; return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || "";
@@ -546,6 +582,48 @@ export function UnitsPage() {
</div> </div>
) : ( ) : (
<> <>
{/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */}
<div className="mb-6 flex flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<div className="shrink-0 border-b border-line bg-panel px-4 py-3">
<h4 className="flex items-center gap-2 text-[14px] font-bold text-accent">
<Building2 className="h-4 w-4" /> تامین‌کنندگان متریال
</h4>
</div>
<div className="flex w-full flex-1 flex-col p-4 transition-opacity duration-300 ease-in-out opacity-100 min-h-[80px] md:min-h-[100px]">
{suppliersLoading || materialId !== suppliersLoadedId ? (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-muted">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent"></div>
</div>
) : materialSuppliers.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center text-center text-sm text-gray-400 md:text-base">
تامین‌کننده‌ای برای این متریال ثبت نشده است
</div>
) : (
<div className="flex flex-col gap-2 max-h-[200px] overflow-y-auto custom-scrollbar pr-1">
{materialSuppliers.map((s) => (
<div
key={s.id}
className="rounded-lg border border-line bg-slate-bg px-3 py-2.5 text-sm shrink-0"
>
{/* نام تأمین‌کننده — مستقیم از پاسخ API، بدون fallback */}
<div className="flex items-center gap-1.5 text-[13px] font-semibold">
<Building2 className="h-3.5 w-3.5 shrink-0 text-accent" />
{s.name}
</div>
{(s.location || s.phone || s.contact_person) && (
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11.5px] text-muted">
{s.location && <span>{s.location}</span>}
{s.phone && <span dir="ltr">{s.phone}</span>}
{s.contact_person && <span>{s.contact_person}</span>}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* واحد مرجع متریال */} {/* واحد مرجع متریال */}
<div className="ref-unit-box"> <div className="ref-unit-box">
<div className="ref-title"> <div className="ref-title">
@@ -587,119 +665,6 @@ export function UnitsPage() {
</div> </div>
</div> </div>
{/* ضرایب متریال */}
<h4 className="sec-title">
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل
</h4>
{rowsLoading ? (
<div className="conv-empty">در حال بارگذاری…</div>
) : !rows.length ? (
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
) : (
rows.map((r) => (
<div key={r.id}>
<div className="cg-head">
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
</div>
<div className="conv-row">
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
<span className="cv-arrow">×</span>
<span className="cv-unit">{ref}</span>
<span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
<Pencil className="h-3.5 w-3.5" />
</button>
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
))
)}
{/* فرم افزودن ضریب */}
<div className="add-conv">
<h4 className="mb-2 text-[13.5px] font-bold">
{editingId ? (
<>
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
</>
) : (
<>
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
</>
)}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
<div className="field">
<label>واحد پیشنهادشده</label>
<Combobox
value={formUnit}
onChange={(it) => setFormUnit(it.label)}
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="مثلاً حلقه"
onInputChange={setFormUnit}
/>
</div>
<div className="field">
<label>ضریب</label>
<NumberInput
value={factorNum()}
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
placeholder="مثلاً 0.98"
inputMode="decimal"
/>
</div>
<div className="field">
<label>برند (خالی = همه برندها)</label>
<Combobox
value={formBrand}
onChange={(it) => setFormBrand(it.label)}
items={allBrands}
allowAdd
disabled={!!editingId}
addSuffix="به عنوان برند جدید"
placeholder="مثلاً راجین"
onInputChange={setFormBrand}
/>
</div>
<div className="field">
<label>به واحد (مرجع)</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
value={ref || "مرجع"}
placeholder="مرجع"
readOnly
/>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveConv()}>
{editingId ? "ذخیره تغییرات" : "افزودن"}
</Button>
{editingId && (
<Button
size="sm"
variant="slate"
onClick={() => {
setEditingId(null);
setFormUnit("");
setFormFactor("");
setFormBrand("");
}}
>
انصراف
</Button>
)}
</div>
</div>
<div className="conv-live">{convLive}</div>
</div>
{/* واحدهای ناشناخته */} {/* واحدهای ناشناخته */}
{unknownUnits.length > 0 && ( {unknownUnits.length > 0 && (
<> <>
@@ -829,6 +794,121 @@ export function UnitsPage() {
تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
</div> </div>
</div> </div>
{/* ضرایب متریال */}
<h4 className="sec-title">
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل
</h4>
{rowsLoading ? (
<div className="conv-empty">در حال بارگذاری…</div>
) : !rows.length ? (
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
) : (
rows.map((r) => (
<div key={r.id}>
<div className="cg-head">
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
</div>
<div className="conv-row">
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
<span className="cv-arrow">×</span>
<span className="cv-unit">{ref}</span>
<span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
<Pencil className="h-3.5 w-3.5" />
</button>
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
))
)}
{/* فرم افزودن ضریب */}
<div className="add-conv">
<h4 className="mb-2 text-[13.5px] font-bold">
{editingId ? (
<>
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
</>
) : (
<>
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
</>
)}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
<div className="field">
<label>واحد پیشنهادشده</label>
<Combobox
value={formUnit}
onChange={(it) => setFormUnit(it.label)}
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="مثلاً حلقه"
onInputChange={setFormUnit}
/>
</div>
<div className="field">
<label>ضریب</label>
<NumberInput
value={factorNum()}
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
placeholder="مثلاً 0.98"
inputMode="decimal"
/>
</div>
<div className="field">
<label>برند (خالی = همه برندها)</label>
<Combobox
value={formBrand}
onChange={(it) => setFormBrand(it.label)}
items={allBrands}
allowAdd
disabled={!!editingId}
addSuffix="به عنوان برند جدید"
placeholder="مثلاً راجین"
onInputChange={setFormBrand}
/>
</div>
<div className="field">
<label>به واحد (مرجع)</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
value={ref || "مرجع"}
placeholder="مرجع"
readOnly
/>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveConv()}>
{editingId ? "ذخیره تغییرات" : "افزودن"}
</Button>
{editingId && (
<Button
size="sm"
variant="slate"
onClick={() => {
setEditingId(null);
setFormUnit("");
setFormFactor("");
setFormBrand("");
}}
>
انصراف
</Button>
)}
</div>
</div>
<div className="conv-live">{convLive}</div>
</div>
</> </>
)} )}
</section> </section>
+4 -3
View File
@@ -1,10 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023", "target": "ESNext",
"lib": ["ES2023", "DOM"], "lib": ["ESNext", "DOM"],
"module": "esnext", "module": "esnext",
"types": ["vite/client"], "types": ["vite/client"],
"strict": true,
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -23,4 +24,4 @@
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"]
} }
View File