Author SHA1 Message Date
whqreza d85ede7d11 feat(inventory): display all warehouse items on selection and track withdrawal recipient
- Show all available inventory items immediately when selecting a warehouse in withdrawal form
- Add vertical scrolling container with max height to prevent layout overflow for large item lists
- Add Material column to the inventory rows table for clear item identification
- Add required 'delivered_to' recipient field in withdrawal form with frontend validation
- Track delivered_to in database, models, schemas, and API
- Display recipient badge on withdrawal cards in daily control page
- Group daily control withdrawals by inventory ID and recipient
2026-09-28 22:54:02 +03:30
16 changed files with 379 additions and 642 deletions
+93 -109
View File
@@ -1,4 +1,3 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this
@@ -13,22 +12,19 @@ folder — no external files or sibling folders required.
| **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)
│
├── 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)
```
---
@@ -78,7 +74,6 @@ v1.3/
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
```
---
@@ -91,12 +86,12 @@ the form (username / password) is sent to
(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.
- **مدیر پروژه (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) →
@@ -108,7 +103,7 @@ controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
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`) |
@@ -116,11 +111,11 @@ for production builds).
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`](https://www.google.com/search?q=docs/casdoor-auth.md).
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
---
@@ -130,13 +125,13 @@ 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`](https://www.google.com/search?q=docs/casdoor-auth.md)).
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
@@ -145,20 +140,21 @@ The MinIO console is deliberately **loopback-only** on the server
is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 <user>@<server-host>
use the panel):
```
```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**
3. 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. See
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
@@ -172,22 +168,22 @@ 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 `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
* 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
with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default.
- 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 `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
- 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
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 ci` downloads)
- **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 ci` downloads)
---
@@ -196,7 +192,6 @@ tunnel remains the safer default.
```powershell
cd <this-folder>
docker compose up -d --build
```
First run downloads base images and builds the frontend (takes a few minutes).
@@ -206,7 +201,6 @@ Wait until all services are up:
```powershell
docker compose ps
```
All should show `Up` (postgres also `(healthy)`).
@@ -216,7 +210,7 @@ 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"}` |
@@ -231,24 +225,23 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell
backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py
```
> Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](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` |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -256,7 +249,6 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell
.\mc.cmd ls --recursive
```
---
@@ -269,12 +261,10 @@ Port: 5433
Database: procurement
Username: postgres
Password: postgres
```
```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement
```
---
@@ -286,28 +276,26 @@ database dumps must never be committed (they contain user rows and signing
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
`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"
```
`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"
```
2. **Restore your own dump** — create dumps on the server (kept server-side,
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
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
```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
## Common operations
@@ -316,44 +304,40 @@ 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/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
* Roles live in the app's `users` table (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; see `docs/casdoor-auth.md`).
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. 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_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup.
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO.
* **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 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`.
```
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (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; see `docs/casdoor-auth.md`).
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. 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_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup.
- Invoice upload requires an `accepted`/`arrived` quote; 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 ps` and `docker compose logs api`.
+4
View File
@@ -247,6 +247,7 @@ _V26_DDL = [
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -258,6 +259,9 @@ _V26_DDL = [
"""
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
""",
"""
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
""",
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
"""
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
+1
View File
@@ -156,6 +156,7 @@ class InventoryTransaction(Base):
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
delivered_to: Mapped[str | None] = mapped_column(Text)
transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+1
View File
@@ -197,6 +197,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"note": tx.note,
}
+27 -217
View File
@@ -2,12 +2,10 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — suppliers linked to a material.
GET /materials/{id}/suppliers — price-comparison data source.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
@@ -22,51 +20,29 @@ from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"])
def link_to_dict(
link: SupplierMaterial,
material: Material | None = None,
category: MaterialCategory | None = None,
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
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None) -> dict:
return {
"id": str(link.id),
"supplier_id": str(link.supplier_id),
"material_id": str(link.material_id),
"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,
"created_at": to_iso(link.created_at),
"material": (
{
"id": str(material.id),
"name": material.name,
"category": (
{"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
),
"material": {
"id": str(material.id),
"name": material.name,
"category": {"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")
async def list_supplier_materials(
supplier_id: UUID,
supplier_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
@@ -80,210 +56,44 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at)
)
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = [lnk.material_id for lnk in items]
materials: dict = {}
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,
}
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
# ---------------------------------------------------------------------------
# POST /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material(
supplier_id: UUID,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
):
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
return {"data": link_to_dict(link, material)}
return {"data": link_to_dict(link, material, None)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(
supplier_id: UUID,
material_id: UUID,
<<<<<<< Updated upstream
db: AsyncSession = Depends(get_db),
):
async def delete_supplier_material(supplier_id, material_id, 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,
}
}
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",
)
@router.get("/materials/{material_id}/suppliers")
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
}
]
=======
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,
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
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)
if material is None:
raise NotFoundError("متریال یافت نشد")
<<<<<<< Updated upstream
stmt = (
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)
select(SupplierMaterial)
.where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
.offset((page - 1) * page_size)
.limit(page_size)
)
rows = (await db.execute(stmt)).all()
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
}
items, meta = await paginate(stmt, page, page_size, db)
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 {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
+1
View File
@@ -64,6 +64,7 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
@@ -290,6 +290,7 @@ async def apply_transaction(
transaction_type=t,
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
@@ -319,6 +320,7 @@ async def apply_transaction(
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
+1 -38
View File
@@ -458,36 +458,6 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
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 دانلود می‌آیند)
interface RawAttachment {
id: string;
@@ -1156,13 +1126,6 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
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: {
@@ -1715,7 +1678,7 @@ export const apiV26 = {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null;
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; transaction_date?: string | null;
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
+1
View File
@@ -163,6 +163,7 @@ export interface InventoryTransaction {
physical_qty?: number | null;
transfer_pair_id?: string | null;
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
note?: string | null;
material?: { id: string; name: string } | null;
@@ -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"
>
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>اقلام و ضرایب</span>
<span>ضرایب تبدیل</span>
</Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
@@ -62,6 +62,7 @@ export function ChangeInventoryPage() {
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false);
@@ -86,10 +87,20 @@ export function ChangeInventoryPage() {
);
const formRecords = useMemo(() => {
const txt = matTxt.trim();
const txt = matTxt.trim().toLowerCase();
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => {
if (r.warehouse?.id !== wid) return false;
if (type === "WITHDRAWAL") {
if (r.quantity_number <= 0) return false;
if (txt) {
const mMatch = r.material.name.toLowerCase().includes(txt);
const bMatch = r.brand?.name?.toLowerCase().includes(txt) ?? false;
const sMatch = Object.values(r.specs || {}).some((v) => String(v).toLowerCase().includes(txt));
if (!mMatch && !bMatch && !sMatch) return false;
}
return true;
}
if (txt && !r.material.name.includes(txt)) return false;
return true;
});
@@ -186,6 +197,7 @@ export function ChangeInventoryPage() {
setQty("");
setSpecsChips([]);
setNote("");
setDeliveredTo("");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
@@ -235,6 +247,9 @@ export function ChangeInventoryPage() {
};
const submitList = async (t: TransactionType) => {
if (t === "WITHDRAWAL" && !deliveredTo.trim()) {
return toast.error("نام تحویل‌گیرنده اقلام الزامی است");
}
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
@@ -244,7 +259,18 @@ export function ChangeInventoryPage() {
if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
}
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
return {
transaction_type: t,
warehouse_id: whId,
material_id: rec.material.id,
brand_id: rec.brand?.id || null,
quantity: n,
unit_id: rec.unit?.id ?? null,
specs: rec.specs || {},
note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(),
};
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -370,6 +396,9 @@ export function ChangeInventoryPage() {
if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "WITHDRAWAL") {
return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0));
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") {
@@ -486,7 +515,36 @@ export function ChangeInventoryPage() {
</div>
</div>
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
{type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
list="dl-mat"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
</label>
<input
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…"
value={deliveredTo}
onChange={(e) => setDeliveredTo(e.target.value)}
/>
</div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field">
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
@@ -653,10 +711,11 @@ export function ChangeInventoryPage() {
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={type === "WITHDRAWAL" || !matTxt.trim()}
emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: (type !== "WITHDRAWAL" && !matTxt.trim()) ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? (matTxt.trim() ? "برای این عبارت در انبار انتخابی رکوردی یافت نشد." : "هیچ کالای دارای موجودی در این انبار یافت نشد.")
: ""
}
/>
@@ -673,6 +732,7 @@ export function ChangeInventoryPage() {
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
showMaterial={!matTxt.trim()}
emptyMsg={
!fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
@@ -778,57 +838,69 @@ export function ChangeInventoryPage() {
);
}
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg, showMaterial = true }: {
isCount: boolean;
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
records: { id: string; material?: { id: string; name: string }; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
showMaterial?: boolean;
}) {
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">موجودی فعلی</th>
<th className="py-2 pe-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={r.id} className="border-b border-line/50">
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
<div>
<div className="overflow-x-auto max-h-[420px] overflow-y-auto border border-line rounded-lg">
<table className="w-full text-[12.5px] min-w-[560px]">
<thead className="sticky top-0 bg-panel z-10 shadow-2xs border-b border-line">
<tr className="text-right text-muted">
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
))}
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r, i) => (
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
{showMaterial && (
<td className="py-2 px-3 font-bold text-ink">
{r.material?.name || "—"}
</td>
)}
<td className="py-2 px-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="py-2 px-3">
<input
inputMode="decimal"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2 px-3">
<input
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div>
);
}
@@ -129,9 +129,10 @@ export function DailyControlPage() {
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => {
const g = gmap.get(t.inventory_id);
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
const g = gmap.get(gKey);
if (!g) {
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
@@ -324,6 +325,12 @@ export function DailyControlPage() {
whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")}
</span>
{g.tx.delivered_to && (
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
<span className="text-slate-500 font-normal">تحویل به:</span>
<span>{g.tx.delivered_to}</span>
</span>
)}
</span>
<span className="text-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
+115 -224
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import {
Building2,
ChevronDown,
Link2,
Pencil,
@@ -14,7 +12,7 @@ import {
TriangleAlert,
} from "lucide-react";
import type { StandardConversion } from "../../lib/types";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialSupplier, type MaterialUnitRow } from "../../lib/api";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialUnitRow } from "../../lib/api";
import {
refreshProcurementStore,
useDeleteStandardConversion,
@@ -104,12 +102,6 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false);
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 [allBrands, setAllBrands] = useState<string[]>([]);
@@ -261,7 +253,6 @@ export function UnitsPage() {
if (!materialId) {
setRows([]);
setBrandLinks([]);
setMaterialSuppliers([]);
return;
}
void loadRows(materialId);
@@ -272,33 +263,6 @@ export function UnitsPage() {
setFormBrand("");
}, [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 => {
if (!row.brand_material_id) return "";
return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || "";
@@ -485,7 +449,7 @@ export function UnitsPage() {
return (
<div className="min-h-screen">
<Header
title="اقلام و ضرایب"
title="ضرایب تبدیل"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/"
backLabel="بازگشت به دفتر"
@@ -582,77 +546,6 @@ export function UnitsPage() {
</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-title">
@@ -694,6 +587,119 @@ export function UnitsPage() {
</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 && (
<>
@@ -823,121 +829,6 @@ export function UnitsPage() {
تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
</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>
+3 -4
View File
@@ -1,11 +1,10 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"strict": true,
"allowArbitraryExtensions": true,
"skipLibCheck": true,
@@ -24,4 +23,4 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
}
View File
+1
View File
@@ -455,6 +455,7 @@ CREATE TABLE inventory_transactions (
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()