7 changed files with 592 additions and 239 deletions
+45 -29
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,7 +13,9 @@ 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)
@@ -25,6 +28,7 @@ Browser ──► Traefik :80 (edge)
│ │
├── 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,9 +91,9 @@ 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.
@@ -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).
--- ---
@@ -131,7 +136,7 @@ at these public addresses (HTTPS):
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)
@@ -141,20 +146,19 @@ 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 ```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host> 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 You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open. 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,11 +172,11 @@ 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.
@@ -181,9 +185,9 @@ Notes from the server setup:
## 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)`).
@@ -225,11 +231,12 @@ 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).
--- ---
@@ -241,7 +248,7 @@ backend\tools\smoke_test_write.py / smoke_test_misc.py
| 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
``` ```
--- ---
@@ -277,22 +287,24 @@ 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 ```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them) # 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 docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional): # 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. **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 ```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 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 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` Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this. (see `docs/OPERATIONS.md`) automates this.
@@ -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.
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`. reachable; check `docker compose ps` and `docker compose logs api`.
```
+214 -24
View File
@@ -2,10 +2,12 @@
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 func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db from app.db.engine import get_db
@@ -20,29 +22,51 @@ from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(
link: SupplierMaterial,
material: Material | None = None,
category: MaterialCategory | None = None, category: MaterialCategory | None = None,
supplier: Supplier | None = None) -> dict: supplier: Supplier | None = None,
) -> dict:
<<<<<<< Updated upstream
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
=======
"""Serialize a SupplierMaterial ORM row to a plain dict."""
>>>>>>> Stashed changes
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), "id": str(material.id),
"name": material.name, "name": material.name,
"category": {"id": str(category.id), "name": category.name} if category else None, "category": (
} if material else None, {"id": str(category.id), "name": category.name} if category else None
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None, ),
}
if material
else None
),
"supplier": (
{"id": str(supplier.id), "name": supplier.name} if supplier else None
),
} }
# ---------------------------------------------------------------------------
# GET /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.get("/suppliers/{supplier_id}/materials") @router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials( async def list_supplier_materials(
supplier_id, supplier_id: UUID,
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -56,44 +80,210 @@ 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, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) supplier_id: UUID,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
): ):
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(supplier_id, material_id, db: AsyncSession = Depends(get_db)): async def delete_supplier_material(
supplier_id: UUID,
material_id: UUID,
<<<<<<< Updated upstream
db: AsyncSession = Depends(get_db),
):
async with db.begin(): async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id) await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
@router.get("/materials/{material_id}/suppliers") # ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers( async def list_material_suppliers(
material_id, material_id: UUID,
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
}
]
=======
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,
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),
): ):
"""Return a paginated list of suppliers for a given material.
Uses explicit column-level SELECT with named labels so SQLAlchemy returns
plain Row tuples — no ORM identity-map involvement — eliminating the
string-vs-UUID comparison bug and the stale-entity collision that occurred
when selecting two mapped entities in the same session.
Response shape (matched by the frontend RawMaterialSupplier interface):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
],
"meta": {"total": 1, "page": 1, "page_size": 20}
>>>>>>> Stashed changes
}
"""
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("متریال یافت نشد")
<<<<<<< Updated upstream
stmt = ( stmt = (
select(SupplierMaterial) select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
.order_by(Supplier.name)
)
suppliers = (await db.execute(stmt)).scalars().all()
return {
"data": [
{
"id": str(supplier.id),
"name": supplier.name,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"location": supplier.location,
}
for supplier in suppliers
]
=======
total: int = (
await db.scalar(
select(func.count(SupplierMaterial.id)).where(
SupplierMaterial.material_id == material_id
)
)
) or 0
if total == 0:
return {"data": [], "meta": {"total": 0, "page": page, "page_size": page_size}}
# Explicit column-level SELECT — returns plain Row objects keyed by label,
# never ORM entities, so no identity-map collision is possible.
stmt = (
select(
Supplier.id.label("sup_id"),
Supplier.name.label("sup_name"),
Supplier.phone.label("sup_phone"),
Supplier.contact_person.label("sup_contact_person"),
Supplier.location.label("sup_location"),
)
.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(SupplierMaterial.last_quoted_at.desc().nulls_last())
.offset((page - 1) * page_size)
.limit(page_size)
) )
items, meta = await paginate(stmt, page, page_size, db)
sup_ids = {l.supplier_id for l in items} rows = (await db.execute(stmt)).all()
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta} data = [
{
"id": str(row.sup_id),
"name": row.sup_name,
"phone": row.sup_phone,
"contact_person": row.sup_contact_person,
"location": row.sup_location,
}
for row in rows
]
return {
"data": data,
"meta": {"total": total, "page": page, "page_size": page_size},
>>>>>>> Stashed changes
}
+37
View File
@@ -458,6 +458,36 @@ 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. The backend uses an atomic
// column-level JOIN so `name` always comes from the suppliers table.
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,
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;
@@ -1126,6 +1156,13 @@ 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[]> => {
const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers?page_size=100`,
);
return raw.map(adaptMaterialSupplier);
},
}, },
projects: { projects: {
@@ -110,7 +110,7 @@ export function DashboardPage() {
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline" className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
> >
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" /> <Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>ضرایب تبدیل</span> <span>اقلام و ضرایب</span>
</Link> </Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */} {/* دکمه اصلی: افزودن پیشنهاد جدید */}
+224 -115
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Building2,
ChevronDown, ChevronDown,
Link2, Link2,
Pencil, Pencil,
@@ -12,7 +14,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 +104,12 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false); const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]); const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
// تأمین‌کننده‌های متریال انتخاب‌شده — داده از GET /materials/{id}/suppliers
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false);
// کلید پیشگیری از flicker: قبل از رسیدن پاسخ جدید، empty state نشان نمی‌دهیم
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 +261,7 @@ export function UnitsPage() {
if (!materialId) { if (!materialId) {
setRows([]); setRows([]);
setBrandLinks([]); setBrandLinks([]);
setMaterialSuppliers([]);
return; return;
} }
void loadRows(materialId); void loadRows(materialId);
@@ -263,6 +272,33 @@ 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);
toast.error(err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود");
})
.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 || "";
@@ -449,7 +485,7 @@ export function UnitsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header <Header
title="ضرایب تبدیل" title="اقلام و ضرایب"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال" sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/" backTo="/"
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
@@ -546,6 +582,77 @@ export function UnitsPage() {
</div> </div>
) : ( ) : (
<> <>
{/* ── تامین‌کنندگان متریال ── */}
<div className="mb-5 overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
{/* سرصفحه — همیشه نمایش داده می‌شود */}
<div className="border-b border-line px-4 py-3">
<h4 className="text-[14px] font-bold text-gray-900">تامین‌کنندگان متریال</h4>
</div>
{/* محتوا — min-h برای جلوگیری از layout shift */}
<div className="min-h-[80px] p-4 transition-opacity duration-300 ease-in-out">
{suppliersLoading || materialId !== suppliersLoadedId ? (
/* اسپینر — نه empty state — تا پاسخ API برسد */
<div className="flex h-[80px] items-center justify-center">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent" />
</div>
) : materialSuppliers.length === 0 ? (
<p className="flex h-[80px] items-center justify-center text-sm text-muted">
تامین‌کننده‌ای برای این متریال ثبت نشده است
</p>
) : (
<div className="flex flex-col gap-3 mt-4 max-h-[320px] overflow-y-auto">
{materialSuppliers.map((s) => (
<div
key={s.id}
className="flex flex-col md:flex-row md:items-center justify-between p-4 bg-white border border-gray-200 rounded-xl shadow-sm gap-4 w-full"
>
{/* نام تامین‌کننده */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام تامین‌کننده</span>
<Link
to={`/suppliers/${s.id}`}
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.name}
</Link>
</div>
{/* نام مسئول */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام مسئول</span>
<span className="text-gray-500">
{s.contact_person || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div>
{/* تلفن */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">تلفن</span>
{s.phone ? (
<a
href={`tel:${s.phone}`}
dir="ltr"
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.phone}
</a>
) : (
<span className="text-gray-500">ثبت نشده</span>
)}
</div>
{/* محل */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">محل</span>
<span className="text-gray-500">
{s.location || <span className="text-gray-500">ثبت نشده</span>}
</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 +694,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 +823,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>
+3 -2
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,
View File