20 Commits
Author SHA1 Message Date
whqreza 31300728b6 fix(inventory): fix table column shift, show transaction date/time on dashboard cards, and enforce strict multi-layer future date prevention 2026-10-19 23:48:42 +03:30
whqreza 42fb314834 feat(inventory): add mandatory per-section date pickers, prevent future dates, sync transfer dates, and sort by transaction_date 2026-10-19 15:07:11 +03:30
whqreza 264c7cc906 docs: update root, bot, and pm README files for integrated ERP architecture 2026-10-19 11:09:58 +03:30
MMD 707e40e840 fix(org): show the project-membership editor for supervisor/pm roles without panels
Supervisors are scoped by project membership (and are blocked at login
without one), but the editor was hidden unless the user had a panel —
a coordinator could never be given a project. Backend always allowed it.
2026-10-08 11:13:53 +03:30
MMD ca2c1bb8ce fix(db): add missing 'supervisor' value to the user_role enum at startup
CREATE TYPE ... EXCEPTION WHEN duplicate_object no-ops on existing types, so
volumes created before 'supervisor' existed kept an enum without it and
assigning the role 500'd: invalid input value for enum user_role: "supervisor".
ALTER TYPE ... ADD VALUE IF NOT EXISTS is idempotent and safe on any volume.
2026-10-08 11:04:39 +03:30
MMD d38cf38bd9 fix(pm): project tiles link by project.id (op_project_id is a dropped legacy column)
Prefers id like ProjectsPage does; the backend serializer returns id (UUID)
and schema_sync removes the legacy op_project_id column at startup.
2026-10-07 15:35:56 +03:30
MMD a1220db844 build(pm): mirror-aware pip/npm registries in PM Dockerfiles (same pattern as api/frontend/bot) 2026-10-07 15:28:43 +03:30
MMD 1190ec42eb feat(pm): add pm-api/pm-frontend services to the ERP stack (serves /pm for the PM panel)
PM joins the ERP traefik (PathPrefix /pm, priority 100), shares the ERP database (additive tables at startup), and reads ERP panels for SSO. PIP_INDEX_URL build arg mirrors the api/bot images.
2026-10-07 15:19:11 +03:30
MMD 2747f9d4bb chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
2026-10-07 15:14:20 +03:30
MMD 934b74ebbd Squashed 'pm/' content from commit 91f3271
git-subtree-dir: pm
git-subtree-split: 91f3271d866fa8ee471cfb6793e334b83e6d2b6e
2026-10-07 15:14:20 +03:30
MMD dcc4d693f5 fix(frontend): declare pathname alongside location in RequireAuth (pm merge semantic conflict) 2026-10-06 15:47:32 +03:30
MMD 64bd292335 merge: pm (unified access matrix, limited procurement flow, platform rebrand)
# Conflicts:
#	frontend/src/App.tsx
#	frontend/src/pages/Auth/LoginPage.tsx
#	frontend/src/pages/Wizard/WizardPage.tsx
2026-10-06 15:45:23 +03:30
MMD 98c4b28815 merge: prod-v10 (prod-v8 + prod-v9 consolidation, list-based ChangeInventoryPage + return ledger)
# Conflicts:
#	frontend/src/pages/Inventory/ChangeInventoryPage.tsx
2026-10-06 15:41:28 +03:30
MMD aba9ba6486 merge: prod-v7 (bot/ build-context rename) into the all-branches integration 2026-10-06 15:40:05 +03:30
MMD 965054093e fix(frontend): repair merge-damaged syntax left by the pm/RBAC revert cycle
Stray braces in App.tsx / ChangeInventoryPage.tsx, an orphaned admin gate in ArrivalsPage.tsx, and a literal revert commit subject pasted into LoginPage.tsx — all made the committed tree unbuildable.
2026-10-06 15:39:43 +03:30
whqreza 3931baadd5 feat(inventory): list-based ChangeInventoryPage + mobile-first UX + return ledger controls
- All ChangeInventoryPage sections (withdrawal/damage/waste/loss/transfer/
  stock-count/project-transfer) become list-based like the withdrawal
  section: pick a warehouse, see its stock rows, material name is only an
  optional filter; manual material/brand/unit entry removed everywhere
  except PURCHASE.
- RETURN redesigned: day-range presets (today/yesterday/7d/30d + Jalali
  custom range) with server-side filters, immutable-ledger returnable
  remainder per withdrawal (REF:[tx-id] note tag + FIFO attribution for
  legacy returns, round4 float-safe), destination warehouse selector.
- PROJECT_TRANSFER: row-based list with one destination project per batch;
  sequential submits keep failed rows inline with errors and drop
  succeeded rows; history dashboard untouched.
- PURCHASE: unit locked to catalog (search-only Combobox), wizard-style
  '+ register new material' option with yellow warning, optional Key-Value
  specs editor (replaces mandatory text chips), continuous-entry UX
  (warehouse/date kept, auto-focus, session log, duplicate warning).
- Mobile-first: responsive table/cards core (StockTable), numeric keyboards
  (inputMode+pattern), sticky bottom submit bar with dynamic counter above
  MobileTabBar incl. safe-area inset, horizontally scrollable touch chips.
- Backend fix (prereq of RETURN): GET /inventory/transactions date filters
  crashed with 500 under asyncpg (string vs timestamptz); parse to aware
  UTC datetimes (DB runs Etc/UTC).
- docs/inventory-ledger-gaps.md: ledger gap report (server-side return cap,
  standalone TRANSFER_IN, transfer row fallback, audit coverage, legacy
  REF backfill, UTC day semantics, pagination gaps).
2026-10-03 21:56:46 +03:30
whqreza 1bf55ba8a6 merge: integrate prod-v8 (inter-project transfers + multi-project allocation)
Conflict resolved in ChangeInventoryPage.tsx (canSubmit): kept both the
WITHDRAWAL branch (prod-v9: delivered-to validation) and the
PROJECT_TRANSFER branch (prod-v8: inter-project transfer validation).
All other files auto-merged; both feature sets fully preserved.
2026-10-03 19:43:24 +03:30
whqreza 5458a2f99f merge: integrate prod-v9 (show all warehouse items on selection + track withdrawal recipient) 2026-10-03 19:40:03 +03:30
whqreza 638076dbaa refactor(bot)!: rename 'ai procurement' -> 'bot/' so the compose build context is committed
The bot service in docker-compose.yml builds from ./bot, but the agent code lived in 'ai procurement/' and ./bot was only a Windows junction on reza.g's machine (and was gitignored) — exactly as the team reported: 'bot/ was never committed — prod-v7's bot is un-buildable from the repo.'

- junction removed; 'ai procurement/' renamed to 'bot/' via git mv (all 41 files keep their history as pure renames)
- 'bot/' dropped from .gitignore (voice/ stays ignored — runtime voice notes; bot/.env stays ignored by bot/.gitignore)
- compose needs no change: context: ./bot and ./bot/app now resolve to committed files
- bot/INTEGRATION-REPORT.md added: full story (problem, root cause, git state, security note on the committed BALE_BOT_TOKEN / AI_API_KEY compose defaults, and usage instructions for the team)

Verified: 41 renames staged with zero secret/cache files; voice/ and bot/.env still ignored.
2026-10-02 12:52:25 +03:30
Milad df3ff4ea14 feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform 2026-10-02 03:47:33 +03:30
169 changed files with 27697 additions and 1633 deletions
-1
View File
@@ -30,7 +30,6 @@ frontend/.env
scratch/
v1.2 - Copy/
inv dash/
bot/
voice/
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
+150 -318
View File
@@ -1,359 +1,191 @@
# ERP & PM Integrated Platform — Dockerized
## سامانه یکپارچه تدارکات، انبارداری، مدیریت پروژه (PM) و ربات هوشمند
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
مجموعه نرم‌افزاری یکپارچه و خودکفا (Self-contained) شامل تمامی سرویس‌ها، ماژول‌ها و تنظیمات زیرساختی مورد نیاز برای اجرا در محیط توسعه و عملیات:
Self-contained ship package: everything needed to run the stack is in this
folder — no external files or sibling folders required.
| Service | Container | Tech | Exposed port |
| سرویس | نام کانتینر | فناوری / کاربرد | پورت در دسترس |
|---|---|---|---|
| **postgres** | `procurement-postgres` | PostgreSQL 18 (one instance: `procurement` + `casdoor` DBs) | `5433` |
| **minio** | `procurement-minio` | MinIO object storage (S3-compatible) | `9000` (API), `9001` (console) |
| **casdoor** | `procurement-casdoor` | Casdoor (OIDC identity provider) | `8080` |
| **api** | `procurement-api` | FastAPI + SQLAlchemy (asyncpg) | `8000` |
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
```
Browser ──► Traefik :80 (edge)
│
├── Host(localhost) ──► frontend (nginx SPA :80)
│ │
│ └── /api/* proxy ──► api :8000 (/api/v1)
│
├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console)
│
├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files)
```
| **postgres** | `procurement-postgres` | PostgreSQL 18 (دیتابیس مشترک `procurement` + `casdoor`) | `5455:5432` |
| **minio** | `procurement-minio` | MinIO Object Storage (S3-compatible) برای فایل‌ها و پیش‌فاکتورها | `9000` (API), `9001` (کنسول) |
| **casdoor** | `procurement-casdoor` | ارائه‌دهنده متمرکز هویت سازمانی (OIDC Provider) | `8080:8000` |
| **api** | `procurement-api` | بک‌اند اصلی تدارکات و انبار (FastAPI + Asyncpg) | `8000:8000` |
| **frontend** | `procurement-frontend` | فرانت‌اند وب ERP (React 19 + Vite + Tailwind) | سرو از طریق Traefik روی پورت `80` |
| **pm-api** | `pm-api` | بک‌اند بومی مدیریت پروژه ساخت (FastAPI + Python 3.12) | درون‌شبکه‌ای (زیرمسیر `/pm/api`) |
| **pm-frontend** | `pm-frontend` | فرانت‌اند پلتفرم PM (React 19 + تقویم شمسی + گانت + QC) | سرو از طریق Traefik روی `/pm` |
| **bot** | `procurement-bot` | ربات هوشمند بله با پردازش صوت و متن (DeepSeek LLM + STT) | ارتباط Long-polling با Bale |
| **db-backup** | `procurement-db-backup` | پشتیبان‌گیری خودکار زمان‌بندی‌شده از هر دو پایگاه داده | کانتینر دوره‌ای در پس‌زمینه |
| **traefik** | `procurement-traefik` | ریورس پروکسی و مدیریت ترافیک لایه لبه (Traefik v3) | `80` (HTTP), `443` (HTTPS) |
---
## 📁 Layout
## 🌐 معماری شبکه و هدایت ترافیک (Traefik Edge Routing)
```text
v1.3/
├── docker-compose.yml ← all paths are relative to this folder
├── docker-compose.prod.yml ← production overlay (real domains + HTTPS via Let's Encrypt)
├── start.ps1 ← PowerShell helper (build + start + status)
├── mc.cmd ← MinIO Client helper (inspect the bucket)
├── README.md
┌────────────────────────┐
│ Browser / Client │
└───────────┬────────────┘
│ :80 / :443
▼
┌────────────────────────┐
│ Traefik v3 (Edge) │
└───────────┬────────────┘
│
┌────────────────────────────────────┼───────────────────────────────────┐
│ │ │
▼ (Host: localhost) ▼ (PathPrefix: /pm) ▼ (Host: api.localhost)
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ frontend (React) │ │ pm-frontend / pm-api │ │ api (FastAPI Core) │
│ /api/* Proxy ───────┼────────────┼───────────────────────┼──────────►│ /api/v1/* │
└───────────────────────┘ └───────────────────────┘ └───────────┬───────────┘
│ │
├────────────────────────────────────┬──────────────────────────────────────┤
▼ ▼ ▼
┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐
│ Casdoor OIDC (:8080) │ │ MinIO S3 (:9000) │ │ PostgreSQL 18 (:5455) │
│ Host: auth.localhost │ │ Host: s3 / storage │ │ procurement + casdoor │
└───────────────────────┘ └───────────────────────┘ └───────────────────────┘
▲
│ ثبت پیش‌نویس استعلام
┌────────┴──────────────┐
│ bot (Bale AI Agent) │
└───────────────────────┘
```
---
## 📁 ساختار پوشه‌ها و اجزای مخزن
```text
ERP/
├── docker-compose.yml ← پیکربندی اصلی اجرای کامل تمام سرویس‌های استک
├── docker-compose.prod.yml ← لایه تنظیمات عملیاتی (Let's Encrypt و دامنه‌های رسمی)
├── docker-compose.coolify.yml ← پیکربندی سازگار با استقرار بر روی Coolify
├── start.ps1 ← اسکریپت هلپر پاورشل برای ساخت و اجرا
├── mc.cmd ← ابزار خط فرمان MinIO Client برای مدیریت باکت‌ها
├── README.md ← همین سند راهنما
├── backend/ ← سرویس بک‌اند اصلی تدارکات و انبار (procurement-api)
│ ├── app/
│ │ ├── db/ ← اتصال دیتابیس و توابع راه‌اندازی اسکیمای خودکار (init_db.py)
│ │ ├── models/ ← مدل‌های پایگاه‌داده (procurement, inventory, catalog)
│ │ ├── routers/ ← اندپوینت‌های API v1 (quotes, inventory, users, drafts, ...)
│ │ ├── schemas/ ← مدل‌های اعتبارسنجی Pydantic
│ │ ├── services/ ← منطق بیزینس (انبارداری، استعلام‌ها، ذخیره‌سازی، ...)
│ │ ├── utils/ ← ابزارهای امنیتی، خطایابی، ریت‌لیمیت
│ │ ├── auth.py ← تایید توکن Casdoor و تفکیک دسترسی‌ها
│ │ ├── config.py ← متغیرهای پیکربندی
│ │ └── main.py ← اسمبلی برنامه FastAPI و میدل‌ورها
│ └── tools/ ← اسکریپت‌های سیدینگ (seed_casdoor.py) و تست‌های دود (Smoke)
├── frontend/ ← وب‌اپلیکیشن اصلی ERP (procurement-frontend)
│ ├── src/
│ │ ├── components/ ← کامپوننت‌های مشترک، لایه‌بندی و سوییچر پنل‌ها
│ │ ├── pages/
│ │ │ ├── Admin/ ← پنل مدیریت سامانه و همگام‌سازی کاربران
│ │ │ ├── Auth/ ← صفحه ورود و احراز هویت
│ │ │ ├── Dashboard/ ← کارتابل استعلام‌ها و تصمیم‌گیری خرید
│ │ │ ├── Inventory/ ← انبارداری (موجودی، ورود کالا، انتقال کارگاهی، کنترل روزانه)
│ │ │ ├── Organization/ ← ماتریس دسترسی پنل‌ها و انتساب پروژه‌ها
│ │ │ ├── Projects/ ← مدیریت پروژه‌های عمرانی
│ │ │ ├── Suppliers/ ← دسته‌بندی موضوعی و فهرست تأمین‌کنندگان
│ │ │ ├── Units/ ← سیستم کاتالوگ متریال‌ها، تامین‌کنندگان و ضرایب تبدیل
│ │ │ └── Wizard/ ← ویزارد مرحله‌ای ثبت استعلام (با اتصال مستقیم به پیش‌نویس ربات)
│ │ ├── store/ ← مدیریت وضعیت با Zustand
│ │ └── App.tsx ← درخت روت‌ها، گیت‌های امنیتی پنل‌ها و نقش‌ها
├── pm/ ← پلتفرم مدیریت پروژه و کنترل کیفیت (پلتفرم آرامش)
│ ├── backend/ ← بک‌اند مستقل PM (FastAPI, CPM Engine, QC Service)
│ ├── frontend/ ← فرانت‌اند PM (گانت چارت شمسی، WBS، ثبت عیوب و بازرسی)
│ ├── sql/ ← اسکیما و اسکریپت‌های اختصاصی پایگاه داده PM
│ └── API_CONTRACT.md ← قرارداد کامل اندپوینت‌های PM
├── bot/ ← ایجنت هوشمند بله برای ثبت صوتی/متنی استعلام‌ها
│ ├── app/ ← کلاینت Bale، پردازشگر STT صوتی، استخراج NLU با DeepSeek
│ ├── docs/SETUP.md ← راهنمای راه‌اندازی و اتصال توکن بله
│ └── requirements.txt
├── monitoring/ ← استک مانیتورینگ، متریک‌ها و لاگ‌ها (پرومتئوس، گرافانا، لوکی)
├── infra/ ← تنظیمات وب‌هوک دیپلوی خودکار و کانفیگ‌های داینامیک Traefik
├── sql/
│ ├── migration.sql ← DB schema v2.6 (auto-runs on first boot)
│ └── 02-casdoor-db.sql ← creates the `casdoor` role+DB on the same instance
├── infra/
│ └── traefik-dynamic.yml ← Traefik shared middlewares (HTTP→HTTPS redirect)
├── docs/ ← ERDs + migration plan + auth guide
│ ├── erd.txt (v2.4 — old procurement schema)
│ ├── new erd.txt (v2.5 — material → supplier → category)
│ ├── NewERD.txt (v2.6 — integrated procurement + inventory)
│ ├── casdoor-auth.md (v2.6 — Casdoor login, roles, failure scenarios)
│ └── material-supplier-category-migration-plan.md
├── backend/ ← build context of the `api` service
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── .dockerignore
│ ├── app/ (source code — procurement + inventory + auth)
│ └── tools/ (smoke-test + seed scripts, copied into the image)
└── frontend/ ← build context of the `frontend` service
├── Dockerfile
├── nginx.conf
├── .dockerignore
├── .env.example (copy to .env if you need a custom API base URL)
├── index.html
├── package.json
├── package-lock.json
├── tsconfig*.json
├── vite.config.ts
├── public/
└── src/
└── pages/
├── Dashboard/ (procurement office)
├── Wizard/ (add new offer)
├── Suppliers/ (suppliers & topics)
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
│ ├── migration.sql ← اسکریپت اولیه پایه دیتابیس
│ └── 02-casdoor-db.sql ← اسکریپت راه‌اندازی دیتابیس هویت Casdoor
└── tests/ ← تست‌های یکپارچگی NLU و ویزارد استعلام
```
---
## 🔐 Login
## 🔐 سیستم احراز هویت، ماتریس پنل‌ها و کنترل دسترسی (RBAC)
The whole system is behind a login page (`/login`). Login is **in-page**:
the form (username / password) is sent to
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor
(password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor.
سامانه از سیستم احراز هویت **Casdoor OIDC** بهره می‌برد؛ ورود به صورت In-Page بوده و توکن JWT صادر شده توسط بک‌اند با JWKS اعتبارسنجی می‌شود.
* **مدیر پروژه (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.
مدل دسترسی سامانه بر پایه **پنل‌ها (`user_panels`)** بازطراحی شده است:
**Developer Mode** (top-left button on the login page) is a 3-step flow for
the admin (super user): ① superuser auth gate (admin credentials only) →
② role switcher (ادمین / مدیر تدارکات / مدیر پروژه + project) → ③ session
activation by role (admin → **پنل مدیریت ادمین** at `/admin`, مدیر تدارکات →
procurement office, مدیر پروژه → their project's inventory panel). It is
controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the
`DEV_LOGIN_ENABLED` compose build arg, default `true` — set it to `false`
for production builds).
### ۱. لایه‌های دسترسی پنل‌ها
* **پنل تدارکات (`procurement`):**
* `full`: دسترسی کامل به کارتابل استعلام‌ها، تایید یا رد پیشنهادها، تامین‌کنندگان و قیمت‌ها.
* `limited`: فقط امکان ثبت استعلام جدید در ویزارد برای پروژه‌های منتسب به کاربر (کاربر پس از ورود مستقیماً به `/wizard` یا `/requester` هدایت می‌شود).
* **پنل انبارداری (`inventory`):**
* `full`: مشاهده موجودی همه انبارها، رهگیری «کالاهای در راه» و کارتابل تایید رسید.
* `limited`: فقط مشاهده و مدیریت انبارهای پروژه‌های تحت عضویت، همراه با دسترسی به ثبت تغییر موجودی کارگاهی (`/inventory/change`).
* **پنل مدیریت پروژه (`pm`):**
* دسترسی به مشاهده یا ویرایش تسک‌ها، چارت گانت، ثبت عیوب و فرم‌های QC در `/panel/pm`.
| What | Where | Credentials |
| --- | --- | --- |
| App | http://localhost (via Traefik) | — |
| Casdoor console | http://localhost:8080 | admin credentials are set when you run the seeder (`CASDOOR_ADMIN_PW`) — never commit them |
| Seeded app users (org `inventory`) | `razaei` (admin), `mousavi` (مدیر تدارکات), `mohaghegh` (مدیر پروژه), `tester` (مدیر پروژه، پروژه «تست») | password from `CASDOOR_SEED_PASSWORD` (bcrypt-hashed at rest — see `backend/tools/seed_casdoor.py`) |
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=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).
### ۲. قوانین و نقش سرپرست پروژه (`supervisor`)
* کاربرانی با نقش `supervisor` تنها در صورتی مجاز به ورود به سامانه هستند که حداقل یک پروژه در جدول `user_project_members` یا `projects.pm_user_id` به آن‌ها منتسب شده باشد. در غیر این صورت، پیام عدم تخصیص پروژه نمایش داده شده و اجازه ورود نخواهند داشت.
---
## 🌐 Working with the deployed system (server)
## 🚀 راهنمای سریع راه‌اندازی محیط توسعه (Docker)
The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS):
### پیش‌نیازها
* Docker Desktop (ویندوز) یا Docker Engine + Docker Compose Plugin (لینوکس)
* حداقل ۴ گیگابایت فضای خالی دیسک و اتصال اینترنت برای بارگیری ایمیج‌های اولیه
| 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)).
### MinIO console on the server (SSH tunnel)
The MinIO console is deliberately **loopback-only** on the server
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
is an SSH tunnel from your machine:
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>
```
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).
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
⚠️ Those credentials are **full admin access to the file storage**. They live
only in the server-side `.env`; if one ever leaks, rotate it immediately
(checklist in `docs/SECRET-MANAGEMENT.md`).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
tunnel.
Notes from the server setup:
* The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser.
* Host port `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)
---
## Start the whole stack
### دستورات اجرا
```powershell
cd <this-folder>
# ۱. رفتن به پوشه پروژه و ساخت/اجرای کانتینرها
docker compose up -d --build
```
First run downloads base images and builds the frontend (takes a few minutes).
Subsequent runs: `docker compose up -d`.
Wait until all services are up:
```powershell
# ۲. بررسی وضعیت سلامت سرویس‌ها
docker compose ps
```
All should show `Up` (postgres also `(healthy)`).
تمام سرویس‌ها باید وضعیت `Up` و دیتابیس وضعیت `(healthy)` را نشان دهند.
### درگاه‌ها و آدرس‌های محلی
| بخش | آدرس محلی | توضیحات |
|---|---|---|
| **رابط کاربری ERP** | http://localhost | داشبورد اصلی سامانه |
| **پلتفرم PM** | http://localhost/pm | مدیریت پروژه و کنترل کیفیت (قابل دسترس در `/panel/pm`) |
| **مستندات API (Swagger)** | http://localhost:8000/docs | مستندات تعاملی اندپوینت‌های تدارکات |
| **سلامت API** | http://localhost:8000/health/ready | بررسی اتصال دیتابیس، Casdoor و MinIO |
| **کنسول MinIO** | http://localhost:9001 | نام کاربری و رمز پیش‌فرض: `minioadmin` / `minioadmin` |
| **کنسول Casdoor** | http://localhost:8080 | نام کاربری و رمز اولیه: `admin` / `123` |
| **پایگاه داده PostgreSQL** | `localhost:5455` | نام دیتابیس: `procurement`، کاربر: `postgres` |
---
## Verify
## 🤖 ربات هوشمند بله (`procurement-bot`)
| What | Where |
| --- | --- |
| Frontend UI | http://localhost |
| API docs (Swagger) | http://localhost:8000/docs |
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` |
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready |
| Metrics | http://localhost:8000/metrics |
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) |
| Database | `localhost:5433`, db `procurement`, user/pass `postgres`/`postgres` (Casdoor uses the same instance, db `casdoor`) |
Automated end-to-end checks (authenticates via `SMOKE_USER` /
`SMOKE_PASSWORD` — the seeder users):
```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).
ربات پیام‌رسان بله به عنوان یک سرویس اختصاصی درون شبکه فعال است:
1. پیام صوتی یا متنی فرستاده شده از طریق هوش مصنوعی (DeepSeek NLU) و تبدیل صوت به متن پردازش می‌شود.
2. قلم‌ها، متریال‌ها، پروژه‌ها و تامین‌کنندگان به صورت فازی جستجو و شناسایی می‌شوند.
3. پیش‌نویس در جدول `bot_drafts` ذخیره شده و شناسه پیش‌نویس تولید می‌شود.
4. کاربر می‌تواند با زدن دکمه روی بله یا مراجعه به لینک مستقیم:
`http://localhost/wizard?draft=<UUID>`
اطلاعات استخراج‌شده را در ویزارد فرانت‌اند مرور، تکمیل و نهایی کند.
5. راهنمای تکمیلی تنظیم توکن در [bot/docs/SETUP.md](file:///c:/Users/Reza/Desktop/v1.6/bot/docs/SETUP.md) قرار دارد.
---
## MinIO (object storage for invoice / proforma files)
## 🔄 انتقال بین‌کارگاهی کالاها (`project_transfers`)
| Item | Value |
| --- | --- |
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) |
| Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell
.\mc.cmd ls --recursive
```
سامانه دارای سیستم ردیابی انتقال متریال بین کارگاه‌هاست:
* ثبت انتقال از طریق صفحه «تغییر موجودی» با نوع `انتقال بین کارگاهی`.
* وضعیت‌های انتقال: `در حال انتقال (in_transit)`، `تحویل‌شده (received)` و `لغوشده (cancelled)`.
* کارگاه مقصد از طریق زبانه «انتقالات ورودی» در صفحه «کالاهای در راه» اقلام ارسالی را دریافت و به انبارهای خود تخصیص می‌دهد.
---
## Database credentials / connection
## 🛠️ عملیات نگهداری و دیپلوی در سرور (Operations)
```text
Host: localhost
Port: 5433
Database: procurement
Username: postgres
Password: postgres
```
```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement
```
---
## Recovery / seeding
The databases are **not** in git — Docker volumes hold the data, and
database dumps must never be committed (they contain user rows and signing
material). Two supported ways to bring a fresh stack up:
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"
```
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
```
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
```powershell
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`.
```
* **دیپلوی خودکار:** سرور با هر Push به برنچ `production` از طریق وب‌هوک اختصاصی به صورت خودکار بیلد و بالا می‌آید. راهنمای کامل: [docs/OPERATIONS.md](file:///c:/Users/Reza/Desktop/v1.6/docs/OPERATIONS.md).
* **پشتیبان‌گیری پایگاه داده:** سرویس `db-backup` به صورت روزانه از هر دو پایگاه داده `procurement` و `casdoor` فایل‌های `.dump` تولید و در پوشه `backups/` نگهداری می‌کند.
* **مانیتورینگ و مشاهده‌پذیری:** ابزارهای Prometheus، لاگ‌های Loki و ردیابی خطاهای GlitchTip در پوشه [monitoring/](file:///c:/Users/Reza/Desktop/v1.6/monitoring/README.md) سازمان‌دهی شده‌اند.
-120
View File
@@ -1,120 +0,0 @@
# procurement_ai_agent — Bale chatbot for the procurement ERP
A **Bale messenger bot** that turns Persian **chat messages and voice notes**
into procurement actions in the
[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) —
draft-first: it captures what it understood, tells you **what it did** and
**what is still empty**, and **commits only after you approve** with a ✅ button.
```
you (Bale, text/voice) ┌─────────────────────────────────┐
│ getUpdates (long poll) │ procurement_ai_agent │
▼ │ ffmpeg → STT (Persian) │
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│
└───────────────┬─────────────────┘
│ HTTP + X-Actor-Id
▼
┌─────────────────────────────────┐
│ ERP api (/api/v1) │
│ quotes · suppliers · materials│
└─────────────────────────────────┘
```
## What it can do
| Activity | Example (voice or text) | Committed via |
|---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` |
### The draft → approve loop
1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM
extracts a typed **intent + entities** (JSON mode, Persian digits
normalized).
2. Names are fuzzy-matched against the ERP. Several candidates → inline
**pick buttons**; no match → listed as missing.
3. The bot replies with a **review card**: everything captured + a
✅/⚠️/❌ list of what is still empty.
4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and
re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
5. **Nothing is written to the ERP until you tap ✅.** After commit you get the
created quote id and the fields left empty.
### Team access & attribution
Each member links their Bale chat once: `/start` → Casdoor username → password
(validated via the ERP's real `POST /auth/login`, used once, never stored).
Every mutating call carries `X-Actor-Id: <users.id>` so `quote_events.actor`
records the **human** who asked.
Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink`
## Quick start (Docker)
```bash
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY
docker build -t procurement-ai-agent .
docker run -d --name procurement-bot --env-file .env \
--add-host host.docker.internal:host-gateway \
procurement-ai-agent
docker logs -f procurement-bot
```
Or as part of the ERP stack — see [`integration/`](integration/README.md)
(compose service + the one tiny ERP-side patch).
Full setup, env-var reference, flows and troubleshooting:
**[docs/SETUP.md](docs/SETUP.md)**.
## Architecture
```
app/
├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop
├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix
├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files)
├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope)
├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions
├── nlu.py Persian intent+entity extraction (JSON-mode chat completions)
├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches
├── drafts.py draft store · QuoteCreate payload builder · missing-field logic
├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes)
├── texts.py Persian message templates
├── runtime.py context, link store, in-memory pending flows
├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema)
└── handlers/ dispatch · commands · callbacks · links ·
quote · supplier · material · queries · decisions
```
Design notes:
- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling —
no public port, no webhook, resumable offset persisted across restarts.
- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is
converted into the exact `POST /quotes` request body (temp_id
cross-references) only on approval; the API deliberately tolerates
incomplete quotes, so partial commits are explicit, never accidental.
- **Accept flow echoes the termsheet's existing `schedule_steps`** back to
`PATCH /quotes/{id}/accept` (the API replaces steps with what it receives).
- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding,
Arabic ya/ka folding, difflib ranking on top of the API's ilike search.
- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before
STT. The LLM provider and the STT provider are configured independently
(e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice).
## Testing
An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler
pipeline with fake updates — no token, DB or API key needed:
```bash
pip install -r requirements.txt
python tools/smoke_test.py # → ALL PASSED: 48 passed
```
+24 -15
View File
@@ -145,18 +145,6 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
user.username = username
await db.flush()
project_ids: list = []
if user.role in ("supervisor", "pm"):
projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
project_ids = [p.id for p in projects]
project_id = project_ids[0] if project_ids else None
# لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
panels: dict = {}
panel_rows = (
@@ -166,6 +154,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
@@ -177,6 +166,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
).scalars().all()
]
pm_projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
pm_project_ids_list = [p.id for p in pm_projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(
user=user,
@@ -190,15 +192,22 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM/supervisor user (oldest first)."""
rows = (
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
pm_rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
member_rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
+3
View File
@@ -420,6 +420,9 @@ async def ensure_rbac_schema(engine: AsyncEngine) -> None:
volume — the RBAC model expects these and login 500s without them."""
stmts = (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
# The live user_role enum predates 'supervisor' — CREATE TYPE no-ops on
# an existing type, so without this ALTER, assigning the role 500s.
"ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'supervisor'",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
+2 -1
View File
@@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user)
if ctx.role == "supervisor" and not projects:
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track(
"login_success",
+17 -3
View File
@@ -12,6 +12,7 @@ import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
@@ -216,6 +217,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"created_at": to_iso(tx.created_at) if hasattr(tx, "created_at") and tx.created_at else None,
"note": tx.note,
}
@@ -448,10 +450,22 @@ async def list_transactions(
if transaction_type is not None:
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
if date_from is not None:
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
# asyncpg تایپ سخت‌گیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
# دیتابیس با TZ=UTC اجرا می‌شود، پس «YYYY-MM-DD» یعنی نیمه‌شب UTC
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
stmt = stmt.where(
InventoryTransaction.transaction_date
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
)
if date_to is not None:
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
stmt = stmt.where(
InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
)
if not sort or sort == "-transaction_date":
stmt = stmt.order_by(InventoryTransaction.transaction_date.desc(), InventoryTransaction.created_at.desc())
else:
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db)
# Enrich: warehouse name + material/brand/specs/unit for the UI rows.
+6 -3
View File
@@ -169,11 +169,14 @@ async def create_quote(
):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
member = set(ctx.member_project_ids or [])
wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= member:
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
+25 -11
View File
@@ -17,7 +17,7 @@ import uuid
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import delete, select, update, text
from sqlalchemy import delete, select, update, text, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
@@ -100,7 +100,9 @@ async def user_to_dict(user: User, projects: list[Project] | None = None, db: As
}
if db is not None:
out["panels"] = await _panels_of(db, user.id)
out["member_project_ids"] = await _member_project_ids_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
@@ -112,13 +114,23 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project assigned to the user (supervisor or PM) (oldest first)."""
"""Every project assigned to the user (supervisor or PM) (oldest first).
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
@@ -246,12 +258,12 @@ async def create_user(
async def update_user_role(
user_id,
payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
assign or elevate roles. Revoking (or switching away from «pm») also
unassigns the user from their project."""
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
if payload.role == "admin" and ctx.role != "admin":
raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
@@ -386,13 +398,14 @@ async def assign_user_project(
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین."""
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
@@ -409,10 +422,11 @@ async def set_user_panels(
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
+11 -5
View File
@@ -22,6 +22,7 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty).
import hashlib
import json
import uuid
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from sqlalchemy import select
@@ -175,6 +176,11 @@ async def apply_transaction(
t = payload.transaction_type
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
tx_date = payload.transaction_date or datetime.now(timezone.utc)
# جلوگیری از ثبت تاریخ در آینده
now_utc = datetime.now(timezone.utc)
if tx_date > now_utc + timedelta(minutes=5):
raise ValidationError("امکان ثبت تاریخ یا زمان در آینده وجود ندارد", field="transaction_date")
# STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT":
@@ -197,7 +203,7 @@ async def apply_transaction(
recorded_qty=recorded,
physical_qty=payload.physical_qty,
performed_by=performed_by,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(tx)
@@ -238,7 +244,7 @@ async def apply_transaction(
quantity=-quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(out_tx)
@@ -256,7 +262,7 @@ async def apply_transaction(
quantity=quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(in_tx)
@@ -291,7 +297,7 @@ async def apply_transaction(
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(tx)
@@ -321,7 +327,7 @@ async def apply_transaction(
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
transaction_date=tx_date,
note=payload.note,
)
db.add(tx)
+150
View File
@@ -0,0 +1,150 @@
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
> این سند وضعیت نهایی یکپارچه‌سازی بات تلگرام/بله (procurement AI agent) با پروژه‌ی ERP را
> توضیح می‌دهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
> نکات امنیتی و نحوه‌ی استفاده. تاریخ آخرین به‌روزرسانی: برنچ `prod-v7`.
---
## ۱. مشکل چی بود؟
سرویس `bot` در `docker-compose.yml` این‌طور تعریف شده بود:
```yaml
bot:
build:
context: ./bot # ← پوشه‌ای که در گیت وجود نداشت!
volumes:
- ./bot/app:/srv/bot/app
```
اما:
1. **پوشه‌ی `bot/` هیچ‌وقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
2. کد ایجنت در پوشه‌ای به نام `ai procurement/` (با فاصله در نام) زندگی می‌کرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمی‌کرد.
3. روی دستگاه reza.g یک **Windows Junction** (میان‌بر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره می‌کرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب می‌داد.
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست می‌خورد —
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
## ۲. راه‌حل اعمال‌شده (راه‌حل شماره ۲)
1. Junction ویندوزی `bot` حذف شد (فقط میان‌بر؛ فایلی از دست نرفت).
2. پوشه‌ی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
**تاریخچه‌ی گیت هر ۴۱ فایل به‌صورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشه‌ی بات دیگر ignore نشود.
(`voice/` سر جایش ماند — نت‌های صوتی رuntimes هستند و نباید کامیت شوند.)
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار می‌کند:
`context: ./bot` و `./bot/app` دقیقاً به فایل‌های کامیت‌شده اشاره می‌کنند.
5. همه‌چیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایل‌ها دسترسی داشته باشد.
### قواعد ignore فعلی (عمدی)
| مسیر | وضعیت | دلیل |
|---|---|---|
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکن‌ها — هرگز کامیت نمی‌شوند |
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
| `voice/` | ignore (ریشه) | فایل‌های صوتی دریافتی از بله در زمان اجرا |
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیت‌شده** | بکاپ تاریخچه‌ی اصلی ایجنت (~۲۰۰KB) |
## ۳. وضعیت فعلی گیت (تاریخ این سند)
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` هم‌SHA).
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **به‌علاوه‌ی** `bot/`
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
(اندپوینت‌های همگام‌سازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
تا «feat(bot,wizard): smart message filtering…».
- بقیه‌ی برنچ‌های تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایل‌های بات را ندارند** —
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
## ۴. ⚠️ نکته‌ی امنیتی (مهم — اقدام لازم)
در همان `docker-compose.yml`، مقادیر **واقعی** به‌عنوان پیش‌فرض env کامیت و پوش شده‌اند:
```yaml
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
```
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچه‌ی مخزن مشترک است و **هر کسی که به ریپو
دسترسی دارد می‌تواند آن‌ها را ببیند** (`.env` کامیت نشده، ولی این پیش‌فرض‌ها عملاً همان لو رفتن راز است).
اقدامات توصیه‌شده (هنوز انجام نشده — نیاز به تصمیم مالک توکن‌ها):
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
2. مقادیر پیش‌فرض را از compose حذف کنید و به‌جای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
## ۵. نحوه‌ی استفاده
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
```powershell
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
cd ERP
git checkout prod-v7
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
# BALE_BOT_TOKEN=... توکن بات از بله
# AI_API_KEY=... کلید سرویس LLM
# BALE_ADMIN_CHAT_IDS=... چت‌آیدی مدیران
# ۲) کل استک + بات:
docker compose up -d --build
```
سرویس `bot` (کانتینر `procurement-bot`) به‌صورت long-polling به `tapi.bale.ai` وصل می‌شود،
به `api:8000/api/v1` (همان ERP) و پایگاه‌داده‌ی `procurement` وصل است و
draft های گفتگو را در جدول `bot_drafts` می‌نویسد.
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
```powershell
docker compose build bot
docker compose up -d bot
docker compose logs -f bot
```
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
1. کاربر در بله به بات پیام می‌دهد (متن یا وویس — وویس با STT به متن تبدیل می‌شود).
2. بات با NLU موجودی/تأمین‌کننده/کالا را استخراج می‌کند و یک **draft** می‌سازد
(وضعیت `active` در جدول `bot_drafts`) و کارت پیش‌نمایش با فیلدهای خالی می‌فرستد.
3. کاربر با دکمه‌ی ✅ تأیید می‌کند؛ بات draft را به ERP کامیت می‌کند
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
4. ویزارد فرانت‌اند همان draft را از `GET /api/v1/drafts` می‌خواند و پیش‌پر می‌کند —
همگام‌سازی دوطرفه با ذخیره‌ی خودکار (debounced) بین مرورگر و بات.
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی می‌شود (reply-based edit، بدون کارت تکراری).
### ۵.۴ تست سریع سلامت
```powershell
docker compose ps bot # باید Up باشد
docker compose logs bot | Select-Object -Last 20
# داخل کانتینر:
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
```
### ۵.۵ مسیرهای مهم
| مسیر | محتوا |
|---|---|
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
| `bot/docs/SETUP.md` | راه‌اندازی جزئی‌تر ایجنت |
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچه‌ی مستقل قبلی ایجنت |
## ۶. نکات باقی‌مانده برای تیم
- **ادغام با برنچ‌های جدید تیم:** فایل‌های بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
آن‌ها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
+70
View File
@@ -0,0 +1,70 @@
# procurement_ai_agent — ایجنت هوشمند بله برای سامانه ERP
بات پیام‌رسان بله (Bale Messenger Bot) که پیام‌های صوتی و متنی فارسی کاربران را دریافت کرده و با استفاده از مدل‌های هوش مصنوعی (DeepSeek NLU + Whisper STT) آن‌ها را به اقدامات و پیش‌نویس‌های استعلام در سامانه ERP تبدیل می‌کند.
---
## 🏗️ وضعیت استقرار در استک ERP
این ایجنت **به صورت سرویس بومی (`procurement-bot`) مستقیماً درون `docker-compose.yml` ریشه پروژه** تعریف شده است و نیازی به نصب یا راه‌اندازی دستی ندارد.
* **نوع اتصال:** Long-polling از سرور بله (`tapi.bale.ai`) — بدون نیاز به باز کردن پورت خارجی، وب‌هوک یا TLS.
* **ارتباط با بک‌اند:** به صورت مستقیم و درون‌شبکه‌ای با سرویس `api` در آدرس `http://api:8000/api/v1`.
* **پایگاه داده:** استفاده از جدول `bot_drafts` در پایگاه‌داده مشترک PostgreSQL.
```text
کاربر (بله: متن یا صوت)
│ getUpdates (Long-polling)
▼
┌─────────────────────────────────┐
│ procurement_ai_agent │
│ ffmpeg → STT تبدیل صوت به متن │
│ LLM → استخراج نیت و موجودیت‌ها │
│ Fuzzy Match → انطباق با ERP │
│ ایجاد پیش‌نویس در bot_drafts │
└────────────────┬────────────────┘
│
├── ثبت نهایی از بله با دکمه تایید
└── یا باز کردن مستقیم در ویزارد وب:
/wizard?draft=<UUID>
```
---
## 🌟 قابلیت‌ها و کاربردها
| فعالیت | نمونه پیام (صوت یا متن) | اقدام نهایی |
|---|---|---|
| **ثبت استعلام جدید** | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| **تعریف تأمین‌کننده** | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| **تعریف متریال جدید** | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| **استعلام وضعیت** | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes` |
| **پذیرش استعلام** | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` |
---
## 🔗 حلقه پیش‌نویس و تایید (Draft-First Loop)
1. کاربر پیام صوتی یا متنی می‌فرستد. صوت به متن تبدیل شده و مدل هوش مصنوعی موجودیت‌ها را استخراج می‌کند.
2. اسامی تامین‌کننده، متریال، پروژه و واحدها با کاتالوگ ERP تطبیق داده می‌شوند.
3. کارت مرور پیش‌نویس همراه با فیلدهای مشخص‌شده و اقلام خالی («چه چیزهایی ناقص است») برای کاربر ارسال می‌شود.
4. **دو روش برای تکمیل و ثبت نهایی وجود دارد:**
* **از طریق چت بله:** با ارسال پیام‌های اصلاحی و در نهایت لمس دکمه «✅ ثبت نهایی».
* **از طریق پنل وب ویزارد:** با کلیک روی لینک مستقیم `/wizard?draft=<UUID>` که اطلاعات به صورت خودکار در فرم ۵ مرحله‌ای ویزارد بارگذاری می‌شود و کاربر می‌تواند فایل فاکتور را نیز ضمیمه کند.
---
## ⚙️ متغیرهای محیطی و پیکربندی
تنظیمات بات از طریق فایل `.env` در ریشه پروژه خوانده می‌شود:
```env
BALE_BOT_TOKEN=توکن_دریافتی_از_بات_فادر_بله
BALE_API_BASE=https://tapi.bale.ai
AI_BASE_URL=https://api.deepseek.com
AI_API_KEY=کلید_سرویس_هوش_مصنوعی
AI_LLM_MODEL=deepseek-flash
BALE_ADMIN_CHAT_IDS=شناسه_عددی_مدیران_در_بله
```
راهنمای گام‌به‌گام اتصال و عیب‌یابی در [docs/SETUP.md](docs/SETUP.md) در دسترس است.
+73
View File
@@ -350,6 +350,78 @@ services:
restart: unless-stopped
mem_limit: 512m
# ---------------------------------------------------------------------------
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
# created additively at startup) and reads ERP users/user_panels for SSO.
# ---------------------------------------------------------------------------
pm-api:
image: v15-pm-api:latest
build:
context: ./pm/backend
dockerfile: Dockerfile
container_name: pm-api
volumes:
- pm_files:/data/files
- ./pm/backend/app:/srv/pm/app
environment:
# Same database as the ERP — PM tables are additive, created at startup.
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
PM_FILES_DIR: /data/files
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
# ERP integration — in-network service URLs.
PM_ERP_BASE_URL: http://api:8000
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
pm-frontend:
image: v15-pm-frontend:latest
build:
context: ./pm/frontend
dockerfile: Dockerfile
container_name: pm-frontend
depends_on:
- pm-api
# PM platform under /pm on the same domain and the same Traefik router.
labels:
- traefik.enable=true
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
- traefik.http.routers.pm.entrypoints=web
# Higher priority than the ERP frontend Host router — otherwise /pm/api
# would reach the ERP SPA (POST → 405).
- traefik.http.routers.pm.priority=100
- traefik.http.services.pm.loadbalancer.server.port=80
networks:
- traefik_network
- default
restart: unless-stopped
mem_limit: 128m
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
@@ -366,3 +438,4 @@ volumes:
postgres_data:
minio_data:
traefik_certs:
pm_files:
+94
View File
@@ -0,0 +1,94 @@
# گزارش گپ‌های دفتر موجودی (Inventory Ledger Gaps)
بررسی کامل منطق تراکنش‌های انبار (بک‌اند + فرانت‌اند) در جریان بازطراحی صفحه «تغییر موجودی».
**این گزارش فقط فهرست گپ‌هاست برای تصمیم‌گیری معماری — به‌جز مورد ۱، هیچ‌کدام در این نسخه اصلاح نشده‌اند.**
وضعیت‌ها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
---
## ۱. ✅ اصلاح‌شده در همین نسخه (پیش‌نیاز فیچر برگشت)
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
(`backend/app/routers/inventory.py` — list_transactions)
ستون `transaction_date` (timestamptz) با رشته‌ی خام مقایسه می‌شد. درایور asyncpg تایپ سخت‌گیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش می‌کرد. هیچ‌جای UI قدیمی این فیلترها را صدا نمی‌زد، برای همین مخفی مانده بود.
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل می‌شوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکته‌ی مرتبط: مورد ۱۲.
---
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
بک‌اند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) می‌پذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API می‌توان موجودی یک انبار را بی‌نهایت تورم داد.
UI جدید این را کنترل می‌کند (تگ `[REF:<tx-id>]` در note + محاسبه‌ی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمی‌شود.**
**پیشنهاد برای تصمیم‌گیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته می‌شود
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته می‌شود (سرویس آن را افزایش عمومی حساب می‌کند). یعنی از مسیر API می‌توان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساخته‌شده توسط خود سرویس `transfer_pair_id` دارد.
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفت‌سازی داخلی سرویس ثبت شود).
## ۴. 🟡 create انتقال بین‌کارگاهی «ردیف انبار» نمی‌گیرد
`POST /inventory/project-transfers` به‌جای `inventory_row_id` فقط material/brand/unit/specs می‌گیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر می‌کند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
## ۵. 🟡 Audit Trail ناقص است
فقط `POST /inventory/transactions` رکورد audit می‌نویسد (`inventory.py:491-500`). این‌ها audit ندارند:
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بین‌کارگاهی
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
- CRUD انبارها و تغییرات کاربران
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمی‌کند).
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال می‌شوند
- `delivered_to` (تحویل‌گیرنده برداشت) در UI اجباری است؛ API بدون آن 201 می‌دهد.
- ظرفیت‌سنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
در بخش‌های لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیف‌های ۱..k-1 **موفق ثبت‌شده** در جدول باقی می‌مانند → retry کاربر = ثبت تکراری. (در انتقال بین‌کارگاهی و برگشتِ این نسخه، ردیف‌های موفق از لیست حذف می‌شوند — الگوی درست.)
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
## ۸. 🟡 برگشت‌های قدیمی بدون REF قابل انتساب دقیق نیستند
برگشت‌های ثبت‌شده قبل از این نسخه هیچ اشاره‌ای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشت‌های هم‌ردیف (متریال+برند+مشخصات+واحد) جبران می‌کند، ولی این حدس است و در دو حالت خطا دارد:
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمی‌شود.
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحه‌های بعد را جابه‌جا کند.
**پیشنهاد (داده):** اسکریپت یک‌باره‌ی backfill — تطبیق برگشت‌های قدیمی با برداشت‌های همان ردیف (قدیمی‌ترین برداشتِ باقیمانده) و درج REF در note.
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC می‌برد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC می‌افتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich می‌کند). UI فعلی از آن استفاده نمی‌کند ولی برای دیباگ/گزارش‌ها گمراه‌کننده است.
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشی‌های لیست
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی می‌شوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمی‌شود (محدودیت قدیمی، در لیست‌های جدید این صفحه هم از همان دیتا می‌خواند). لیست برگشت و مفقودی‌ها صفحه‌بندی/واکشی کامل دارند.
## ۱۲. 🔵 لیست انتقال‌های بین‌کارگاهی بدون صفحه‌بندی
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همه‌ی ردیف‌ها را بدون pagination برمی‌گردانند؛ با رشد داده بار پاسخ زیاد می‌شود.
---
## چیزهایی که بررسی شد و درست است ✅
- **Immutable Ledger در سطح API:** برای تراکنش‌ها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بین‌کارگاهی است که تراکنش خروجی خودش را بازمی‌گرداند و موجودی را ترمیم می‌کند.
- **سقف FOUND در بک‌اند اعمال می‌شود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامت‌دار صحیح.
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمی‌شود.
- **دریافت انتقال بین‌کارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتب‌شده‌ی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head>
<body>
<div id="root"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "دفترچه تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "مدیریت پروژه و تأمین",
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl",
"lang": "fa",
"start_url": "/",
+157 -31
View File
@@ -59,6 +59,7 @@ function Loading() {
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const location = useLocation();
const pathname = location.pathname;
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search);
@@ -73,7 +74,10 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
}
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
if (!user.projects || user.projects.length === 0) {
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
@@ -96,43 +100,137 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
</div>
);
}
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
if (!location.pathname.startsWith("/inventory") && location.pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
}
}
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const panels = user.panels ?? {};
const isAdmin = user.role === "admin";
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند.
function HomeRoute() {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (panels.inventory) return <Navigate to="/inventory" replace />;
if (panels.procurement === "limited") return <RequesterHome />;
// فقط پنل PM: مستقیم به پنل مدیریت پروژه
if (panels.pm && !panels.inventory && !panels.procurement) {
return <Navigate to="/panel/pm" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
return <DashboardPage />;
}
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />;
// ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full
// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester)
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") {
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>;
}
if (panels.procurement === "full") return <>{children}</>;
if (panels.procurement === "limited") return <Navigate to="/requester" replace />;
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
@@ -146,25 +244,52 @@ function PmPanelPage() {
);
}
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های میراث pm/supervisor —
// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const allowed =
user?.role === "admin" ||
Boolean(panels.pm) ||
user?.role === "pm" ||
user?.role === "supervisor";
if (!allowed) return <Navigate to="/" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const isAdmin = user?.role === "admin";
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>;
}
@@ -217,6 +342,7 @@ export default function App() {
</RequireAuth>
}
/>
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route
path="/suppliers"
element={
+42 -30
View File
@@ -17,49 +17,61 @@ export function AppSwitcherMenu({
}) {
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const isSupervisor = user?.role === "supervisor";
const panels = user?.panels ?? {};
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود
if (isSupervisor) {
const supervisorItems: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
{
label: "مدیریت پروژه",
to: "/panel/pm",
},
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
];
return <PanelMenu title="پنل‌ها" items={supervisorItems} />;
}
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const items: MenuItem[] = [
{
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند
const canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = [];
if (hasInventory) {
items.push({
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
];
});
}
if (canSwitchToProcurement) {
if (hasProcurement && canSwitchToProcurement) {
items.push({
label: "تدارکات",
to: "/",
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement",
});
}
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
if (hasPm) {
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
}
if (canManage) {
if (canManageOrg) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
+16 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type RefObject } from "react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
@@ -17,6 +17,7 @@ export interface ComboboxProps {
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از +
placeholder?: string;
className?: string;
@@ -25,6 +26,8 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
@@ -44,6 +47,7 @@ export function Combobox({
onChange,
items,
allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید",
placeholder,
className,
@@ -52,6 +56,7 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
inputRef,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
@@ -125,6 +130,7 @@ export function Combobox({
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
ref={inputRef}
className={cn(
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
inputClassName,
@@ -182,9 +188,15 @@ export function Combobox({
onMouseEnter={() => setHighlight(filtered.length)}
>
<span className="font-bold">+</span>
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
{addPrefix ? (
<span>
{addPrefix} «<b>{text.trim()}</b>»
</span>
) : (
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
)}
</button>
)}
</div>
+166 -52
View File
@@ -21,6 +21,7 @@ export interface JalaliDatePickerProps {
className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean;
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
}
function todayJalali(): [number, number, number] {
@@ -76,6 +77,7 @@ export function JalaliDatePicker({
className,
withTime = false,
disabled = false,
maxDate,
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
@@ -83,6 +85,62 @@ export function JalaliDatePicker({
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
const maxDateObj = useMemo(() => {
if (!maxDate) return null;
const d = typeof maxDate === "string" ? new Date(maxDate) : maxDate;
return Number.isNaN(d.getTime()) ? null : d;
}, [maxDate]);
const maxDateIso = useMemo(() => {
if (!maxDateObj) return null;
return `${maxDateObj.getFullYear()}-${String(maxDateObj.getMonth() + 1).padStart(2, "0")}-${String(maxDateObj.getDate()).padStart(2, "0")}`;
}, [maxDateObj]);
const maxJalaliParts = useMemo(() => {
if (!maxDateObj) return null;
return gregorianToJalali(maxDateObj.getFullYear(), maxDateObj.getMonth() + 1, maxDateObj.getDate());
}, [maxDateObj]);
const canGoNextMonth = useMemo(() => {
if (!maxJalaliParts) return true;
const [cy, cm] = curJ;
const [my, mm] = maxJalaliParts;
return cy < my || (cy === my && cm < mm);
}, [curJ, maxJalaliParts]);
const canGoNextYear = useMemo(() => {
if (!maxJalaliParts) return true;
return curJ[0] < maxJalaliParts[0];
}, [curJ, maxJalaliParts]);
const clampIfFuture = (iso: string | null): string | null => {
if (!iso || !maxDateObj) return iso;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
if (d.getTime() > maxDateObj.getTime()) {
return maxDateObj.toISOString();
}
return iso;
};
const getClampedTime = (h: number, m: number, jy: number, jm: number, jd: number): { h: number; m: number } => {
let validH = Math.min(23, Math.max(0, h));
let validM = Math.min(59, Math.max(0, m));
if (maxDateObj && maxDateIso) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (dayIso === maxDateIso) {
const maxH = maxDateObj.getHours();
const maxM = maxDateObj.getMinutes();
if (validH > maxH || (validH === maxH && validM > maxM)) {
validH = maxH;
validM = maxM;
}
}
}
return { h: validH, m: validM };
};
const [text, setText] = useState<string>(() => {
if (!value) return "";
const j = isoToJalaliStr(value);
@@ -145,23 +203,30 @@ export function JalaliDatePicker({
const dim = daysInMonth(jy, jm);
const today = new Date();
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const cells: { d: number; iso: string; isToday: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
const cells: { d: number; iso: string; isToday: boolean; disabled: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false });
for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
cells.push({ d, iso, isToday: iso === tIso });
const isDisabled = !!(maxDateIso && iso > maxDateIso);
cells.push({ d, iso, isToday: iso === tIso, disabled: isDisabled });
}
return cells;
}, [curJ]);
}, [curJ, maxDateIso]);
const nav = (dir: string) => {
setCurJ(([jy, jm]) => {
let ny = jy;
let nm = jm;
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
if (dir === "m-1") {
if (!canGoNextMonth) return [jy, jm, 1];
nm = nm === 12 ? 1 : nm + 1;
}
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
if (dir === "y-1") ny += 1;
if (dir === "y-1") {
if (!canGoNextYear) return [jy, jm, 1];
ny += 1;
}
if (dir === "y+1") ny -= 1;
if (nm < 1) {
nm = 12;
@@ -171,15 +236,27 @@ export function JalaliDatePicker({
nm = 1;
ny += 1;
}
if (maxJalaliParts) {
if (ny > maxJalaliParts[0] || (ny === maxJalaliParts[0] && nm > maxJalaliParts[1])) {
ny = maxJalaliParts[0];
nm = maxJalaliParts[1];
}
}
return [ny, nm, 1];
});
};
const selectDay = (jy: number, jm: number, jd: number) => {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dayIso > maxDateIso) return;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
const validM = Math.min(59, Math.max(0, currentM));
const { h: validH, m: validM } = getClampedTime(currentH, currentM, jy, jm, jd);
setHourText(faNum(String(validH).padStart(2, "0")));
setMinuteText(faNum(String(validM).padStart(2, "0")));
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val);
setText(
@@ -200,30 +277,28 @@ export function JalaliDatePicker({
return;
}
const n = parseInt(digits, 10);
const clamped = Math.min(23, Math.max(0, n));
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
setHourText(faNum(textToShow));
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : digits;
setHourText(faNum(textToShow));
onChange(buildIso(jy, jm, jd, clampedH, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM));
setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(23, Math.max(0, n));
setHourText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM));
onChange(buildIso(jy, jm, jd, clamped, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
setHourText(faNum(String(clampedH).padStart(2, "0")));
onChange(buildIso(jy, jm, jd, clampedH, validM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clampedH, validM));
setText(`${j} ${faNum(String(clampedH).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
};
const handleMinuteChange = (raw: string) => {
@@ -238,37 +313,46 @@ export function JalaliDatePicker({
return;
}
const n = parseInt(digits, 10);
const clamped = Math.min(59, Math.max(0, n));
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
setMinuteText(faNum(textToShow));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : digits;
setMinuteText(faNum(textToShow));
onChange(buildIso(jy, jm, jd, validH, clampedM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`);
};
const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0;
const clamped = Math.min(59, Math.max(0, n));
setMinuteText(faNum(String(clamped).padStart(2, "0")));
const [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH));
onChange(buildIso(jy, jm, jd, validH, clamped));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
setMinuteText(faNum(String(clampedM).padStart(2, "0")));
onChange(buildIso(jy, jm, jd, validH, clampedM));
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clampedM));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clampedM).padStart(2, "0"))}`);
};
const pickToday = () => {
const now = new Date();
const [jy, jm, jd] = todayJalali();
const nh = now.getHours();
const nm = now.getMinutes();
let nh = now.getHours();
let nm = now.getMinutes();
if (maxDateObj) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const todayIso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && todayIso > maxDateIso) return;
if (todayIso === maxDateIso) {
if (nh > maxDateObj.getHours() || (nh === maxDateObj.getHours() && nm > maxDateObj.getMinutes())) {
nh = maxDateObj.getHours();
nm = maxDateObj.getMinutes();
}
}
}
setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime
@@ -300,20 +384,23 @@ export function JalaliDatePicker({
}
const iso = jalaliStrToIso(v);
if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) {
const nums = String(v)
.split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
onChange(dOnly);
} else {
onChange(iso.slice(0, 10));
onChange(validIso.slice(0, 10));
}
} else {
onChange(iso);
onChange(validIso);
}
const j = isoToJalali(iso);
const j = isoToJalali(validIso);
if (j) setCurJ([j[0], j[1], j[2]]);
}
};
@@ -325,27 +412,32 @@ export function JalaliDatePicker({
}
const iso = jalaliStrToIso(text);
if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) {
const nums = String(text)
.split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
let dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
if (maxDateIso && dOnly > maxDateIso) dOnly = maxDateIso;
onChange(dOnly);
setText(isoToJalaliStr(dOnly));
} else {
onChange(iso.slice(0, 10));
setText(isoToJalaliStr(iso));
const dOnly = validIso.slice(0, 10);
onChange(dOnly);
setText(isoToJalaliStr(dOnly));
}
} else {
onChange(iso);
const j = isoToJalaliStr(iso);
const d = new Date(iso);
onChange(validIso);
const j = isoToJalaliStr(validIso);
const d = new Date(validIso);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
setHourText(faNum(hh));
setMinuteText(faNum(mm));
}
}
} else if (value) {
@@ -397,10 +489,22 @@ export function JalaliDatePicker({
<span className="text-[13px] font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
<button
type="button"
disabled={!canGoNextMonth}
className={cn("rounded px-1.5 text-sm", canGoNextMonth ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextMonth && nav("m-1")}
title={canGoNextMonth ? "ماه بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
›
</button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
<button
type="button"
disabled={!canGoNextYear}
className={cn("rounded px-1.5 text-sm", canGoNextYear ? "cursor-pointer hover:bg-slate-bg" : "opacity-25 cursor-not-allowed text-muted")}
onClick={() => canGoNextYear && nav("y-1")}
title={canGoNextYear ? "سال بعد" : "امکان انتخاب تاریخ آینده وجود ندارد"}
>
»
</button>
</div>
@@ -415,6 +519,16 @@ export function JalaliDatePicker({
{grid.map((cell, i) =>
cell.d === 0 ? (
<div key={i} />
) : cell.disabled ? (
<button
key={i}
type="button"
disabled
className="rounded py-0.5 text-[12px] opacity-25 text-muted cursor-not-allowed max-md:py-1.5"
title="امکان انتخاب تاریخ آینده وجود ندارد"
>
{toFa(cell.d)}
</button>
) : (
<button
key={i}
+14 -3
View File
@@ -263,6 +263,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
}
return res as T;
}
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
const res = await request<any>(path, { method: "DELETE" });
if (res && typeof res === "object" && "data" in res) {
@@ -1806,9 +1810,9 @@ export const apiV26 = {
patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panels`, { panels }),
putData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
@@ -1856,11 +1860,18 @@ export const apiV26 = {
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
return data.map(rawInventory);
},
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
},
// همان transactions با meta.total برای صفحه‌بندی «نمایش بیشتر»
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
},
createTransaction: async (body: {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null;
+21
View File
@@ -115,6 +115,27 @@ export function isoToJalaliStr(iso: string | null | undefined): string {
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
}
// format an ISO string to "۱۴۰۳/۰۵/۱۲ ۱۴:۳۰"
export function isoToJalaliDateTime(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${toFa(hh)}:${toFa(mm)}`;
}
// format time only: "۱۴:۳۰"
export function isoToTimeStr(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${toFa(hh)}:${toFa(mm)}`;
}
// format with month name: "۱۸ مرداد ۱۴۰۴"
export function isoToJalaliLong(iso: string | null | undefined): string {
if (!iso) return "";
+1
View File
@@ -172,6 +172,7 @@ export interface InventoryTransaction {
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
created_at?: string;
note?: string | null;
material?: { id: string; name: string } | null;
brand?: { id: string; name: string } | null;
+36 -8
View File
@@ -79,7 +79,10 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return;
}
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (user.role === "supervisor" && !hasProjects) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
@@ -92,10 +95,24 @@ export function LoginPage() {
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیر پروژه → پنل انبار؛ بقیه → دفتر
if (user.role === "supervisor") navigate("/panel/pm");
else navigate(user.role === "pm" ? "/inventory" : "/");
} (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
// هدایت پس از ورود بر اساس پنل‌ها یا نقش:
const panels = user.panels ?? {};
if (panels.procurement === "full") {
navigate("/");
} else if (panels.procurement === "limited") {
navigate("/requester");
} else if (panels.inventory) {
navigate("/inventory");
} else if (panels.pm) {
navigate("/panel/pm");
} else if (user.role === "supervisor") {
navigate("/panel/pm");
} else if (user.role === "pm") {
navigate("/inventory");
} else {
navigate("/");
}
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally {
@@ -285,10 +302,21 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} />
</span>
<div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
<p className="mt-1 text-[12.5px] leading-6 text-muted">
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
</p>
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
مدیریت پروژه
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
تدارکات
</span>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
انبارداری
</span>
</div>
</div>
</div>
+55 -11
View File
@@ -1,28 +1,68 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { FilePlus2, Info } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { useAuthStore } from "../../store/auth";
import { useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای
// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود.
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژه‌های تخصیص‌یافته
export function RequesterHome() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const memberIds = new Set(user?.member_project_ids ?? []);
const myProjects = memberIds.size
? projects.filter((p) => memberIds.has(p.id))
: [];
// پروژه‌های کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژه‌های عمومی)
const myProjects = useMemo(() => {
const list: { id: string; name: string }[] = [];
const seen = new Set<string>();
// ۱. پروژه‌های موجود در آرایه user.projects
for (const p of user?.projects ?? []) {
if (p.id && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۲. پروژه‌ی اختصاصی فعال (project_id)
if (user?.project_id && !seen.has(user.project_id)) {
seen.add(user.project_id);
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیص‌یافته" });
}
// ۳. پروژه‌های عضویت member_project_ids تطبیق‌یافته با لیست پروژه‌ها
const memberIdSet = new Set(user?.member_project_ids ?? []);
for (const p of projects) {
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
if (list.length === 0 && user?.project_name) {
const match = projects.find((p) => p.name === user.project_name);
if (match && !seen.has(match.id)) {
list.push({ id: match.id, name: match.name });
}
}
return list;
}, [user, projects]);
return (
<div className="min-h-screen">
<Header title="افزودن استعلام" brand />
<Header
title="افزودن استعلام"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
شما مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
@@ -36,8 +76,8 @@ export function RequesterHome() {
{myProjects.map((p) => (
<Link
key={p.id}
to="/wizard"
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
to={`/wizard?project=${encodeURIComponent(p.name)}`}
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
@@ -50,7 +90,10 @@ export function RequesterHome() {
))}
</div>
<Link to="/wizard" className="btn-line-file no-underline">
<Link
to="/wizard"
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
>
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
@@ -60,3 +103,4 @@ export function RequesterHome() {
</div>
);
}
@@ -268,8 +268,6 @@ export function ArrivalsPage() {
}
};
if (!isAdmin && !isPM) {
if (!canAccess) {
return (
File diff suppressed because it is too large Load Diff
@@ -140,7 +140,7 @@ export function DailyControlPage() {
if (t.transaction_date > g.last) g.last = t.transaction_date;
}
});
return [...gmap.values()];
return [...gmap.values()].sort((a, b) => new Date(b.last).getTime() - new Date(a.last).getTime());
}, [withdrawals]);
const isToday = sel === todayKey();
+199 -25
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown, ChevronUp, Clock } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { useAuthStore } from "../../store/auth";
@@ -10,8 +10,24 @@ import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
const TX_TYPE_OPTIONS: { type: string; label: string }[] = [
{ type: "", label: "همه عملیات‌ها" },
{ type: "PURCHASE", label: "موجودی اولیه / خرید" },
{ type: "WITHDRAWAL", label: "خروج / مصرف" },
{ type: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی" },
{ type: "RETURN", label: "برگشت به انبار" },
{ type: "TRANSFER_OUT", label: "انتقال بین انبارها (خروجی)" },
{ type: "TRANSFER_IN", label: "انتقال بین انبارها (ورودی)" },
{ type: "DAMAGE", label: "خرابی" },
{ type: "WASTE", label: "ضایعات" },
{ type: "LOSS", label: "مفقودی" },
{ type: "FOUND", label: "پیدا شده" },
{ type: "STOCK_COUNT", label: "انبارگردانی / شمارش" },
];
// رنگ‌بندی معنایی تراکنش‌ها
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
@@ -58,10 +74,20 @@ export function InventoryPage() {
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
const [filters, setFilters] = useState<{
warehouse: string;
material: string;
project: string;
transactionType: string;
dateFrom: string | null;
dateTo: string | null;
}>({
warehouse: params.get("warehouse") || "",
material: "",
project: "",
transactionType: "",
dateFrom: null,
dateTo: null,
});
const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -77,14 +103,40 @@ export function InventoryPage() {
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
// فیلتر تراکنش‌ها بر اساس نوع و بازه زمانی روزانه (۰۰:۰۰:۰۰ تا ۲۳:۵۹:۵۹)
const filteredTransactions = useMemo(() => {
let list = transactions;
if (filters.transactionType) {
list = list.filter((t) => t.transaction_type === filters.transactionType);
}
if (filters.dateFrom) {
const dFrom = new Date(filters.dateFrom);
const startOfDay = new Date(dFrom.getFullYear(), dFrom.getMonth(), dFrom.getDate(), 0, 0, 0, 0);
list = list.filter((t) => new Date(t.transaction_date).getTime() >= startOfDay.getTime());
}
if (filters.dateTo) {
const dTo = new Date(filters.dateTo);
const endOfDay = new Date(dTo.getFullYear(), dTo.getMonth(), dTo.getDate(), 23, 59, 59, 999);
list = list.filter((t) => new Date(t.transaction_date).getTime() <= endOfDay.getTime());
}
return list;
}, [transactions, filters.transactionType, filters.dateFrom, filters.dateTo]);
const hasTxFilter = !!(filters.transactionType || filters.dateFrom || filters.dateTo);
const groups = useMemo<Group[]>(() => {
const gmap = new Map<string, Group>();
const order: string[] = [];
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
inventory.forEach((r) => {
const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return;
if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
if (activeInvIds && !activeInvIds.has(r.id)) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk);
if (!g) {
@@ -104,7 +156,6 @@ export function InventoryPage() {
order.push(gk);
}
g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r);
if (r.brand) {
if (!(r.brand.id in g.brands)) {
@@ -114,26 +165,61 @@ export function InventoryPage() {
g.brands[r.brand.id] += r.quantity_number || 0;
}
});
// استخراج آخرین تاریخ تراکنش واقعی کاربر برای هر گروه و انتساب به updatedAt
order.forEach((k) => {
const g = gmap.get(k);
if (!g) return;
const groupAllTxs = transactions.filter((t) => g.recs.some((r) => r.id === t.inventory_id));
if (groupAllTxs.length > 0) {
const latestTx = groupAllTxs.reduce((latest, t) => {
return new Date(t.transaction_date).getTime() > new Date(latest.transaction_date).getTime() ? t : latest;
}, groupAllTxs[0]);
g.updatedAt = latestTx.transaction_date;
}
});
const out = order.map((k) => gmap.get(k)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return out;
}, [inventory, visibleIds, filters, sort]);
}, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
const groupTxs = (g: Group) =>
transactions
filteredTransactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
.sort((a, b) => {
const da = new Date(a.transaction_date).getTime();
const db = new Date(b.transaction_date).getTime();
if (da !== db) return da - db;
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
return ca - cb;
});
const brandTxs = (g: Group, bid: string) =>
transactions
filteredTransactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
.sort((a, b) => {
const da = new Date(a.transaction_date).getTime();
const db = new Date(b.transaction_date).getTime();
if (da !== db) return da - db;
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
return ca - cb;
});
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
const hasFilter = !!(
filters.warehouse ||
(isAdmin && filters.project) ||
filters.material ||
filters.transactionType ||
filters.dateFrom ||
filters.dateTo
);
if (loading) return <div className="min-h-screen" />;
if (!user) {
@@ -285,13 +371,72 @@ export function InventoryPage() {
</div>
)}
{/* نوع عملیات / بخش */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">نوع عملیات / بخش:</div>
<div className="flex flex-wrap gap-1.5">
{TX_TYPE_OPTIONS.map((opt) => (
<button
key={opt.type}
type="button"
onClick={() => setFilters((f) => ({ ...f, transactionType: f.transactionType === opt.type ? "" : opt.type }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.transactionType === opt.type
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* فیلتر بازه تاریخ یا روز مشخص */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-bold text-muted">فیلتر بازه روزانه (بازه کامل ۰۰:۰۰ تا ۲۳:۵۹):</span>
<button
type="button"
onClick={() => {
const todayIso = new Date().toISOString().slice(0, 10);
setFilters((f) => ({ ...f, dateFrom: todayIso, dateTo: todayIso }));
}}
className="text-[11px] text-accent font-semibold hover:underline cursor-pointer"
>
امروز
</button>
</div>
<div className="grid gap-2 sm:grid-cols-2 max-w-md">
<div>
<label className="mb-0.5 block text-[10.5px] text-muted">از تاریخ (یا روز مشخص):</label>
<JalaliDatePicker
value={filters.dateFrom}
onChange={(val) => setFilters((f) => ({ ...f, dateFrom: val, dateTo: f.dateTo || val }))}
placeholder="از تاریخ…"
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-0.5 block text-[10.5px] text-muted">تا تاریخ:</label>
<JalaliDatePicker
value={filters.dateTo}
onChange={(val) => setFilters((f) => ({ ...f, dateTo: val }))}
placeholder="تا تاریخ…"
maxDate={new Date()}
/>
</div>
</div>
</div>
{/* دکمه پاک کردن فیلترها */}
{hasFilter && (
<div className="pt-1 flex items-center justify-between">
<button
type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
>
<X className="h-3 w-3" />
پاک کردن فیلترها
@@ -363,7 +508,6 @@ export function InventoryPage() {
<tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" />
<th className="py-2.5 pe-3 font-semibold">متریال</th>
<th className="py-2.5 pe-3 font-semibold">دسته‌بندی</th>
<th className="py-2.5 pe-3 font-semibold">انبار</th>
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
<th className="py-2.5 pe-3 font-semibold">تعداد</th>
@@ -515,8 +659,9 @@ function InventoryMobileCard({
<span className="text-muted/60">—</span>
)}
</div>
<span className="shrink-0 tabular-nums">
{isoToJalaliStr(g.updatedAt)}
<span className="shrink-0 tabular-nums flex items-center gap-1">
<Clock className="w-3 h-3 text-muted/70" />
<span>{isoToJalaliDateTime(g.updatedAt)}</span>
</span>
</div>
</div>
@@ -554,13 +699,23 @@ function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, ti
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td>
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2.5 text-muted">🕘</td>
<td className="py-2.5 pe-3 font-medium text-ink/80">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">
<div className="font-semibold text-ink">{isoToJalaliStr(g.updatedAt)}</div>
<div className="text-[10.5px] text-muted flex items-center gap-1 mt-0.5">
<Clock className="w-3 h-3 text-muted/70 shrink-0" />
<span>{isoToTimeStr(g.updatedAt)}</span>
</div>
</td>
<td className="py-2.5 text-center text-muted">
<span className="p-1 inline-flex items-center justify-center rounded hover:text-accent">
{isExp ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</span>
</td>
</tr>
{isExp && (
<tr className="border-b border-line/40 bg-accent-bg/10">
<td colSpan={9} className="p-3.5">{timeline}</td>
<td colSpan={8} className="p-3.5">{timeline}</td>
</tr>
)}
</>
@@ -592,14 +747,29 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id}
type="button"
onClick={() => setSelTx(sel ? null : t.id)}
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
className={`flex min-w-[135px] flex-col gap-1 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
style={{ borderTop: `3px solid ${meta.color}` }}
>
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit}
<div className="flex items-center justify-between gap-1">
<span className="text-[11px] font-bold" style={{ color: meta.color }}>
{meta.icon} {meta.label}
</span>
</div>
<span className="text-[12.5px] font-extrabold tabular-nums" style={{ color: meta.color }}>
{t.quantity_number > 0 ? "+" : ""}{faNum(t.quantity_number)} {unit}
</span>
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span>
<div className="flex items-center justify-between gap-1 text-[10px] text-muted border-t border-line/40 pt-1">
<span>مانده: <b className="tabular-nums font-semibold">{faNum(bal)}</b></span>
</div>
<div className="flex items-center gap-1 text-[9.5px] text-muted/90 tabular-nums">
<Clock className="w-2.5 h-2.5 text-muted shrink-0" />
<span>{isoToJalaliDateTime(t.transaction_date)}</span>
</div>
{t.delivered_to && (
<div className="text-[9.5px] text-ink/80 font-medium truncate max-w-[125px]" title={t.delivered_to}>
تحویل: {t.delivered_to}
</div>
)}
</button>
);
});
@@ -623,7 +793,11 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span>
<span>زمان وقوع: <b className="text-ink font-bold">{isoToJalaliDateTime(t.transaction_date)}</b></span>
{t.delivered_to && <span>تحویل به: <b className="text-ink font-bold">{t.delivered_to}</b></span>}
{t.created_at && (
<span>زمان ثبت در سیستم: <b className="text-ink/80">{isoToJalaliDateTime(t.created_at)}</b></span>
)}
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<>
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
@@ -0,0 +1,145 @@
// ============================================================
// جدول «پیدا شده» — مفقودی‌های باقی‌مانده‌ی انبار (LOSS − FOUND)
// با انتخاب انبار همه‌ی مفقودی‌ها می‌آیند و نام متریال فقط فیلتر است.
// ============================================================
import type { InventoryTransaction } from "../../../lib/types";
import { faNum } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine, type RowDraft } from "./StockTable";
export interface LossItem { tx: InventoryTransaction; remaining: number; }
interface FoundTableProps {
items: LossItem[];
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
loading?: boolean;
}
function parseLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
if (loading) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودی‌ها…</div>;
}
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[620px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<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">تعداد پیداشده</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{items.map(({ tx, remaining }, i) => {
const over = parseLocal(rows[i]?.qty || "") > remaining;
return (
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-2 px-3">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
</td>
<td className="py-2 px-3">
<input
type="text"
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>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{items.map(({ tx, remaining }, i) => {
const over = parseLocal(rows[i]?.qty || "") > remaining;
return (
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
</div>
{tx.specs && Object.keys(tx.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">مانده مفقودی</div>
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="تعداد پیداشده"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,132 @@
// ============================================================
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
// انتخاب انبار مبدأ → لیست همه‌ی اقلام موجود → ورودی تعداد هر ردیف.
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب می‌شوند.
// ردیف‌های ناموفق هنگام ثبت متوالی، با پیام خطا در لیست می‌مانند و
// ردیف‌های موفق حذف می‌شوند (ورودی‌ها کلید-محورند تا جابه‌جا نشوند).
// ============================================================
import { faNum } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine, type StockRecord } from "./StockTable";
interface ProjectTransferListProps {
records: StockRecord[];
qtyOf: (id: string) => string;
onQty: (id: string, v: string) => void;
unitNameFn: (id: number | null | undefined) => string;
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
errors: Record<string, string>;
emptyMsg: string;
}
function parseNum(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[600px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<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">تعداد انتقال</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r) => {
const err = errors[r.id];
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
return (
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="px-3 py-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={qtyOf(r.id)}
onChange={(e) => onQty(r.id, e.target.value)}
/>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
</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>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{records.map((r) => {
const err = errors[r.id];
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
return (
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
</div>
{r.specs && Object.keys(r.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">موجودی</div>
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
</div>
</div>
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="تعداد انتقال"
value={qtyOf(r.id)}
onChange={(e) => onQty(r.id, e.target.value)}
/>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,88 @@
// ============================================================
// جدول «اقلام ثبت‌شده در این نشست» برای موجودی اولیه
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظه‌ی همان صفحه.
// ============================================================
import { faNum } from "../../../lib/formatters";
import { specsLine } from "./StockTable";
export interface PurchaseSessionEntry {
material: string;
brand: string;
specs: Record<string, string>;
qty: number;
unitLabel: string;
time: string; // HH:MM آماده‌ی نمایش
whName: string;
}
interface PurchaseSessionLogProps {
entries: PurchaseSessionEntry[];
onClear: () => void;
}
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
if (!entries.length) return null;
return (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
<h3 className="text-[13px] font-bold">
🧾 اقلام ثبت‌شده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
</h3>
<button
type="button"
onClick={onClear}
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
>
پاک کردن لیست
</button>
</div>
{/* دسکتاپ: جدول فشرده */}
<div className="hidden sm:block">
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
<table className="w-full text-[12px]">
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2 px-3">متریال</th>
<th className="py-2 px-3">برند</th>
<th className="py-2 px-3">مشخصه‌ها</th>
<th className="py-2 px-3">تعداد</th>
<th className="py-2 px-3">ساعت</th>
<th className="py-2 px-3">انبار</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{[...entries].reverse().map((e, i) => (
<tr key={i}>
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
<td className="py-1.5 px-3 text-muted">{e.time}</td>
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* موبایل: ردیف‌های فشرده */}
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
{[...entries].reverse().map((e, i) => (
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
</div>
<div className="mt-0.5 text-[11px] text-muted">
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
// ============================================================
// نوار فیلتر بازه‌ی روز بخش «برگشت»
// پیش‌فرض‌های لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازه‌ی دلخواه با تقویم جلالی.
// چیپ‌ها در موبایل افقی اسکرول‌پذیرند (no-scrollbar) با ارتفاع تاچ.
// ============================================================
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
import { cn } from "../../../lib/utils";
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
interface ReturnFilterBarProps {
preset: DatePreset;
onPreset: (p: DatePreset) => void;
customFrom: string | null;
customTo: string | null;
onCustomFrom: (iso: string | null) => void;
onCustomTo: (iso: string | null) => void;
}
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
return (
<div className="space-y-2">
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
{DATE_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onPreset(p.id)}
className={cn(
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
preset === p.id
? "bg-accent font-bold text-white shadow-xs"
: "border border-line bg-panel text-ink hover:bg-bg",
)}
>
{p.label}
</button>
))}
</div>
{preset === "custom" && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
</div>
<div>
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,164 @@
// ============================================================
// جدول برداشت‌های بازه برای بخش «برگشت»
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
// برگشت محاسبه‌شده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
// ورودی‌ها کلید-محورند (کلید = tx-id) تا حذف ردیف‌های موفق، بقیه را
// به‌هم نریزد.
// ============================================================
import type { InventoryTransaction } from "../../../lib/types";
import { faNum, fmtDateTime } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine } from "./StockTable";
interface ReturnTableProps {
withdrawals: InventoryTransaction[];
qtyOf: (id: string) => string;
noteOf: (id: string) => string;
onQty: (id: string, v: string) => void;
onNote: (id: string, v: string) => void;
remainders: Map<string, number>;
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
loading?: boolean;
}
function parseNum(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
if (loading) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشت‌ها…</div>;
}
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[760px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<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">برداشت</th>
<th className="py-2.5 px-3">مانده قابل برگشت</th>
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{withdrawals.map((w) => {
const cap = remainders.get(w.id) ?? 0;
const q = parseNum(qtyOf(w.id));
const over = q > cap + 1e-9;
return (
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={qtyOf(w.id)}
onChange={(e) => onQty(w.id, e.target.value)}
/>
<input
type="text"
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={noteOf(w.id)}
onChange={(e) => onNote(w.id, e.target.value)}
/>
</div>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-2 text-[11px] text-muted">
«مانده قابل برگشت» = مقدار برداشت − برگشت‌های ثبت‌شده همان ردیف. سقف ورودی همین مانده است.
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{withdrawals.map((w) => {
const cap = remainders.get(w.id) ?? 0;
const q = parseNum(qtyOf(w.id));
const over = q > cap + 1e-9;
return (
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
</div>
{w.specs && Object.keys(w.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
</div>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{fmtDateTime(w.transaction_date)}</span>
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="مقدار برگشت"
value={qtyOf(w.id)}
onChange={(e) => onQty(w.id, e.target.value)}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح (اختیاری)"
value={noteOf(w.id)}
onChange={(e) => onNote(w.id, e.target.value)}
/>
</div>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,72 @@
// ============================================================
// ویرایشگر مشخصه‌های فنی Key-Value (موجودی اولیه)
// خروجی: jsonb استاندارد {عنوان: مقدار} هم‌خوان با specs_hash بک‌اند.
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته می‌شود.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface SpecKvRow { key: string; value: string; }
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
const k = r.key.trim();
if (k) out[k] = r.value.trim();
}
return out;
}
interface SpecsKvEditorProps {
rows: SpecKvRow[];
onChange: (rows: SpecKvRow[]) => void;
disabled?: boolean;
}
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
const update = (i: number, patch: Partial<SpecKvRow>) =>
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
return (
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="عنوان مشخصه (مثلاً ضخامت)"
value={r.key}
onChange={(e) => update(i, { key: e.target.value })}
/>
<span className="shrink-0 text-[12px] text-muted">:</span>
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="مقدار (مثلاً ۲ میلی‌متر)"
value={r.value}
onChange={(e) => update(i, { value: e.target.value })}
/>
<button
type="button"
disabled={disabled}
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
title="حذف این مشخصه"
>
✕
</button>
</div>
))}
<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, { key: "", value: "" }])}
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
>
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
</button>
</div>
);
}
@@ -0,0 +1,154 @@
// ============================================================
// هسته‌ی نمایش مشترک لیست متریال‌های انبار برای بخش‌های لیست‌محور
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
//
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
// موبایل (<640px): کارت‌های عمودی لمسی با فیلد بزرگ ورود مقدار.
// هر بخش ستون‌های نمایش یکسان را از اینجا می‌گیرد و فقط رفتار ورودی
// (تعداد ساده یا شمارش‌شده) را با isCount انتخاب می‌کند.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface StockRecord {
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>;
}
export interface RowDraft { key: string; qty: string; note: string; }
export function specsLine(specs: Record<string, string> | undefined): string {
return (
Object.entries(specs || {})
.map(([k, v]) => `${k}: ${v}`)
.join(" · ") || "—"
);
}
interface StockTableProps {
records: StockRecord[];
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
/** شمارش موجودی: placeholder «شمارش‌شده» و پیش‌نمایش مقدار فعلی هنگام خالی بودن */
isCount?: boolean;
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است می‌توان پنهان کرد */
showMaterial?: boolean;
emptyMsg: string;
unitNameFn: (id: number | null | undefined) => string;
}
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
const qtyPlaceholder = isCount ? "شمارش‌شده" : "۰";
return (
<>
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[560px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
<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>
</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">{specsLine(r.specs)}</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
type="text"
inputMode="decimal"
pattern="[0-9]*"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={qtyPlaceholder}
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
type="text"
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>
{/* موبایل: کارت‌های عمودی لمسی */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{records.map((r, i) => (
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
</div>
{r.specs && Object.keys(r.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">موجودی</div>
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
placeholder={qtyPlaceholder}
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)))}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
</div>
))}
</div>
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
</div>
</>
);
}
@@ -0,0 +1,244 @@
// ============================================================
// منطق بخش «برگشت» — واکشی برداشت‌ها/برگشت‌ها و محاسبه مانده قابل برگشت
//
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمی‌شود؛
// مانده هر برداشت = مقدار برداشت − مجموع برگشت‌های متصل به آن.
// اتصال دو راه دارد:
// ۱) برگشت‌های جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
// ۲) برگشت‌های قدیمی بدون REF (ثبت‌شده در همان انبار مبدأ) → تخصیص FIFO
// بین برداشت‌های همان ردیف انبار (متریال+برند+مشخصات+واحد).
// همه‌ی جمع/تفریق‌ها با round4 گرد می‌شوند تا خطای اعشار جاوااسکریپت
// (مثل 0.30000000000000004) در مانده‌ها و سقف‌ها نشت نکند.
// ============================================================
import { apiV26 } from "../../../lib/api";
import type { InventoryTransaction } from "../../../lib/types";
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همه‌ی محاسبات مانده */
export function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}
// ------------------------------------------------------------
// تاریخ — کلید روز میلادی محلی و بازه‌های پیش‌فرض
// ------------------------------------------------------------
export function gregKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
{ id: "today", label: "امروز" },
{ id: "yesterday", label: "دیروز" },
{ id: "7d", label: "۷ روز اخیر" },
{ id: "30d", label: "۳۰ روز اخیر" },
{ id: "custom", label: "بازه دلخواه" },
];
/** بازه‌ی سرور-ساید برای هر پیش‌فرض؛ date_to روزِ (inclusive بک‌اند: < date_to + 24h) */
export function presetRange(
preset: DatePreset,
customFrom?: string,
customTo?: string,
): { date_from: string; date_to: string } {
const today = new Date();
switch (preset) {
case "today":
return { date_from: gregKey(today), date_to: gregKey(today) };
case "yesterday": {
const y = gregKey(addDays(today, -1));
return { date_from: y, date_to: y };
}
case "7d":
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
case "30d":
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
case "custom":
return {
date_from: customFrom || gregKey(today),
date_to: customTo || customFrom || gregKey(today),
};
}
}
// ------------------------------------------------------------
// تگ رفرنس [REF:<uuid>]
// ------------------------------------------------------------
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
export function parseRef(note?: string | null): string | null {
if (!note) return null;
const m = note.match(REF_RE);
return m ? m[1] : null;
}
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
const tag = `[REF:${withdrawalTxId}]`;
const note = (userNote || "").trim();
return note ? `${note} ${tag}` : tag;
}
// ------------------------------------------------------------
// واکشی صفحه‌بندی‌شده (هر صفحه ۱۰۰ — سقف بک‌اند)
// ------------------------------------------------------------
const MAX_PAGES = 10; // سقف محافظه‌کارانه: ۱۰۰۰ رکورد
export async function fetchWithdrawalsPage(
sourceWhId: string,
dateFrom: string,
dateTo: string,
page: number,
): Promise<{ rows: InventoryTransaction[]; total: number }> {
return apiV26.inventory.transactionsPaged({
warehouse_id: sourceWhId,
transaction_type: "WITHDRAWAL",
date_from: dateFrom,
date_to: dateTo,
sort: "transaction_date",
page,
});
}
/** همه‌ی برگشت‌های پروژه از ابتدای بازه (برای تطبیق REF) — صفحه‌به‌صفحه تا سقف */
export async function fetchProjectReturns(
dateFrom: string,
): Promise<InventoryTransaction[]> {
const out: InventoryTransaction[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const { rows, total } = await apiV26.inventory.transactionsPaged({
transaction_type: "RETURN",
date_from: dateFrom,
sort: "transaction_date",
page,
});
out.push(...rows);
if (out.length >= total || rows.length === 0) break;
}
return out;
}
/** همه‌ی تراکنش‌های یک نوع در یک انبار (برای محاسبه مانده مفقودی‌ها) */
export async function fetchAllTypeInWarehouse(
warehouseId: string,
transactionType: string,
): Promise<InventoryTransaction[]> {
const out: InventoryTransaction[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const { rows, total } = await apiV26.inventory.transactionsPaged({
warehouse_id: warehouseId,
transaction_type: transactionType,
sort: "transaction_date",
page,
});
out.push(...rows);
if (out.length >= total || rows.length === 0) break;
}
return out;
}
// ------------------------------------------------------------
// مانده قابل برگشت
// ------------------------------------------------------------
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتب‌شده) + واحد — هم‌ارز specs_hash بک‌اند */
function rowKey(t: InventoryTransaction): string {
const specs = Object.keys(t.specs || {})
.sort()
.map((k) => `${k}=${String((t.specs || {})[k])}`)
.join("&");
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
}
/**
* مانده قابل برگشت هر برداشت را حساب می‌کند.
* - برگشت‌های REF-دار: مستقیماً از برداشت مقصد کم می‌شوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
* - برگشت‌های REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته می‌شوند (به برداشت خودشان تعلق دارند).
* - برگشت‌های قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشت‌های هم‌ردیف تخصیص می‌یابند.
*/
export function computeRemainders(
withdrawals: InventoryTransaction[],
returns: InventoryTransaction[],
sourceWhId: string,
): Map<string, number> {
const rem = new Map<string, number>();
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
const inList = new Set(withdrawals.map((w) => w.id));
const legacy: InventoryTransaction[] = [];
for (const r of returns) {
const qty = round4(Math.abs(r.quantity_number || 0));
if (qty <= 0) continue;
const ref = parseRef(r.note);
if (ref) {
if (inList.has(ref)) {
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
}
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
} else {
legacy.push(r);
}
}
// تخصیص FIFO برگشت‌های قدیمیِ هم‌انبار مبدأ بین برداشت‌های هم‌ردیف (قدیمی→جدید)
const buckets = new Map<string, InventoryTransaction[]>();
for (const w of withdrawals) {
const k = rowKey(w);
const list = buckets.get(k);
if (list) list.push(w);
else buckets.set(k, [w]);
}
for (const list of buckets.values()) {
list.sort(
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
);
}
for (const r of legacy) {
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
const list = buckets.get(rowKey(r));
if (!list) continue;
let q = round4(Math.abs(r.quantity_number || 0));
for (const w of list) {
if (q <= 0) break;
const cur = rem.get(w.id) ?? 0;
if (cur <= 0) continue;
const take = Math.min(cur, q);
rem.set(w.id, round4(cur - take));
q = round4(q - take);
}
}
// کف صفر (محافظت در برابر داده‌های ناسازگار قدیمی)
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
return rem;
}
/** مانده مفقودیِ باقی‌مانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
export function computeLossRemainders(
losses: InventoryTransaction[],
founds: InventoryTransaction[],
): { tx: InventoryTransaction; remaining: number }[] {
const foundByKey = new Map<string, number>();
for (const f of founds) {
const k = rowKey(f);
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
}
return losses
.map((tx) => {
const loss = Math.abs(tx.quantity_number || 0);
const found = foundByKey.get(rowKey(tx)) || 0;
return { tx, remaining: Math.max(0, round4(loss - found)) };
})
.filter((x) => x.remaining > 0);
}
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { apiV26, type UserRow } from "../../lib/api";
import { api, apiV26, type UserRow } from "../../lib/api";
import type { Project } from "../../lib/types";
import { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
@@ -17,10 +18,12 @@ const ROLE_LABELS: Record<string, string> = {
supervisor: "سرپرست پروژه",
};
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها و پنل‌ها
export function OrganizationPage() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const storeProjects = useProjects();
const [directProjects, setDirectProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
@@ -28,11 +31,25 @@ export function OrganizationPage() {
const [busyId, setBusyId] = useState<string | null>(null);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد؛ مدیران می‌توانند پروژه‌ها را به سرپرستان تخصیص دهند
const canChangeRoles = user?.role === "admin";
const canChangeRoles = canManage;
const fetchProjects = useCallback(async () => {
try {
setLoadingProjects(true);
const list = await api.projects.list();
if (list && Array.isArray(list)) {
setDirectProjects(list);
}
} catch {
// fallback silently
} finally {
setLoadingProjects(false);
}
}, []);
const loadUsers = useCallback(async () => {
try {
setLoadingUsers(true);
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
@@ -43,8 +60,13 @@ export function OrganizationPage() {
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
if (canManage) {
void loadUsers();
void fetchProjects();
}
}, [canManage, loadUsers, fetchProjects]);
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
const syncUsers = async () => {
setSyncing(true);
@@ -57,6 +79,7 @@ export function OrganizationPage() {
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
void fetchProjects();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
@@ -91,39 +114,8 @@ export function OrganizationPage() {
}
};
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id);
try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
const names = nextIds
.map((id) => projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers();
} finally {
setBusyId(null);
}
};
// ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ----------
const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => {
const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
setBusyId(u.id);
const next = { ...(u.panels ?? {}) };
if (level) next[panel] = level;
@@ -157,12 +149,14 @@ export function OrganizationPage() {
const panelLabel = (level: string | undefined) =>
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
// سرپرستان تخصیص‌یافته به یک پروژه
// سرپرستان و مسئولان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) =>
users.filter(
(u) =>
(u.role === "supervisor" || u.role === "pm") &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
(u.projects?.some((p) => p.id === projectId) ||
u.project_id === projectId ||
u.member_project_ids?.includes(projectId)),
);
return (
@@ -172,34 +166,33 @@ export function OrganizationPage() {
brand
menu={<AppSwitcherMenu activeModule="organization" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند.
مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند.
<PageLayout className="flex flex-col gap-5">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌های سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین می‌گردد.
</div>
{/* ---------- بخش ۱: جدول کاربران و نقش‌ها ---------- */}
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقش‌ها و سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[14px] font-bold">👥 کاربران و نقش‌ها</h3>
<h3 className="text-[14px] font-bold">👥 کاربران، نقش‌ها و سطوح دسترسی پنل‌ها</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه
تعیین نقش سازمانی، تخصیص پنل‌ها (تدارکات، انبار، PM) و مشخص نمودن پروژه‌های عضویت کاربر
</p>
</div>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
@@ -207,210 +200,397 @@ export function OrganizationPage() {
<table className="w-full text-[12.5px]">
<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">وضعیت</th>
<th className="py-2.5 pe-3">نام و مشخصات</th>
<th className="py-2.5 pe-3">نقش سازمانی</th>
<th className="py-2.5 pe-3">پنل تدارکات</th>
<th className="py-2.5 pe-3">پنل انبار</th>
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
<th className="py-2.5">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
</td>
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
{u.username || "—"}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
<td className="py-2.5 pe-3">
{u.role === "supervisor" || u.role === "pm" ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
disabled={busyId === u.id}
/>
{users.map((u) => {
const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
{/* ۱. نام و نام کاربری */}
<td className="py-2.5 pe-3">
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">
{u.first_name} {u.last_name}
</span>
<span className="font-mono text-[10px] text-muted">
({u.username || "—"})
</span>
</div>
{busyId === u.id && (
<span className="text-[10px] text-accent animate-pulse font-medium">
در حال ذخیره تغییرات…
</span>
)}
</div>
) : (
<span className="text-[11px] text-muted">
{u.role === "admin"
? "دسترسی کامل به همه پروژه‌ها"
: u.role === "procurement_manager"
? "دسترسی کامل مدیریتی"
: "—"}
</span>
)}
</td>
<td className="py-2.5 text-[11px] text-muted">
{busyId === u.id ? (
<span className="text-accent">در حال ذخیره…</span>
) : (
<span className="text-emerald-600">✓ ثبت‌شده</span>
)}
</td>
</tr>
))}
</td>
{/* ۲. نقش سازمانی */}
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
{/* ۳. پنل تدارکات */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
{/* ۴. پنل انبار */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
{/* ۵. پنل مدیریت پروژه (PM) */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.pm ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
)}
</td>
{/* ۶. پروژه‌های عضویت */}
<td className="py-2.5">
{isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : canAssignProjects ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted">
سرپرستان پروژه‌ای که پروژه‌ای به آن‌ها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
</div>
</div>
)}
</div>
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
<div>
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => {
const sups = supervisorsOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
{p.status || "پروژه فعال"}
</span>
</div>
{p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)}
<div>
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
{!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
</div>
) : (
<div className="flex flex-col gap-1">
{sups.map((s) => (
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
<span className="font-semibold">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username})</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* ---------- بخش ۳: پنل‌ها و سطوح دسترسی (جایگزین نقش‌ها) ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3">
<h3 className="text-[14px] font-bold">🔑 پنل‌ها و سطوح دسترسی</h3>
<p className="mt-0.5 text-[11.5px] leading-5 text-muted">
به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن
استعلام برای پروژه‌های عضویت. انبار — کامل: همه پروژه‌ها بدون تغییر موجودی؛ محدود: فقط پروژه‌های عضویت با
امکان تغییر موجودی. ادمین دست‌نخورده است.
</p>
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<p className="text-[11.5px] text-muted">
مشاهده وضعیت پروژه‌ها و سرپرستان یا اعضای تخصیص‌یافته به هر پروژه
</p>
</div>
{loadingProjects && (
<span className="text-[11px] text-muted animate-pulse">در حال به‌روزرسانی پروژه‌ها…</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<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">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
if (u.role === "admin") {
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3"><b>{u.first_name} {u.last_name}</b></td>
<td colSpan={3} className="py-2.5 text-[11px] text-muted">
ادمین — دسترسی کامل به همه پنل‌ها (دست‌نخورده)
</td>
</tr>
);
}
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory);
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
<td className="py-2.5">
{hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{loadingProjects && !projects.length ? (
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
در حال دریافت اطلاعات پروژه‌ها…
</div>
) : !projects.length ? (
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
<span>پروژه‌ای برای نمایش یافت نشد یا در دسترس نیست.</span>
<button
type="button"
onClick={() => void fetchProjects()}
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
>
🔄 تلاش مجدد برای دریافت پروژه‌ها
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => {
const sups = supervisorsOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
{p.status || "پروژه فعال"}
</span>
</div>
{p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)}
</div>
<div className="pt-2 border-t border-line/50">
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
{!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
</div>
) : (
<div className="flex flex-col gap-1">
{sups.map((s) => (
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-5">
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<div>
<h3 className="flex items-center gap-2 text-[15px] font-bold">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
📖
</span>
توضیحات پنل‌ها و راهنمای سطوح دسترسی
</h3>
<p className="mt-1 text-[12px] text-muted">
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنل‌های سامانه
</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
مدل دسترسی ماژولار مبتنی بر پنل
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* کارت پنل تدارکات */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
🛒
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
</div>
</div>
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
خرید و بازرگانی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی کامل مدیریتی به تمامی بخش‌های تدارکات</li>
<li>ثبت و ویرایش پیش‌فاکتورها، شرایط پرداخت و فاکتورها</li>
<li>مدیریت تأمین‌کنندگان، اقلام و تصمیم‌گیری خرید در همه پروژه‌ها</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>فقط امکان ثبت درخواست استعلام قیمت و پیش‌فاکتور اولیه</li>
<li>محدود منحصراً به پروژه‌هایی که کاربر در آن‌ها عضویت دارد</li>
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمت‌ها یا پروژه‌های غیرعضو</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل انبار */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
📦
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
</div>
</div>
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
انبارداری و موجودی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژه‌ها</li>
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارش‌گیری کلان</li>
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی فقط به انبارهای پروژه‌های عضویت کاربر</li>
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
<li>تفکیک دقیق موجودی و تراکنش‌های انبار بر اساس پروژه</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل مدیریت پروژه (PM) */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
📊
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
</div>
</div>
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
کنترل پروژه و اجرا
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مدیریت جامع ساختار شکست کار (WBS)، زمان‌بندی و گانت چارت تعاملی</li>
<li>تعریف و ویرایش فعالیت‌ها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
<li>پایش شاخص‌های پیشرفت زمانی و مالی (EVM) و گزارش‌های تحلیلی</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده فعالیت‌ها و تسک‌های مربوط به پروژه‌های عضویت کاربر</li>
<li>به‌روزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسک‌ها</li>
<li>تکمیل چک‌لیست‌های کنترل کیفیت (QC) و ثبت گزارش‌های اجرایی روزانه</li>
</ul>
</div>
</div>
</div>
</div>
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">👑</span>
<div>
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
<p className="mt-0.5 text-muted leading-relaxed">
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنل‌ها و پروژه‌ها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنل‌ها ندارد.
</p>
</div>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">🎯</span>
<div>
<span className="font-bold text-foreground">کارکرد «پروژه‌های عضویت»:</span>
<p className="mt-0.5 text-muted leading-relaxed">
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه داده‌ها و اختیارات وی دقیقاً به پروژه‌های انتخابی در ستون «پروژه‌های عضویت» محدود خواهد شد.
</p>
</div>
</div>
</div>
</div>
</PageLayout>
+5 -1
View File
@@ -213,7 +213,11 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
/>
))}
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
<div className="dd-empty">
{canAddProject
? "هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید."
: "هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
+66 -26
View File
@@ -22,6 +22,7 @@ import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { matchMaterial } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier";
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
@@ -154,13 +155,19 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
const user = useAuthStore((s) => s.user);
const isLimitedProcurement =
user?.panels?.procurement === "limited" && user?.role !== "admin";
const memberProjectNames = useMemo(
() =>
projects
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
.map((p) => p.name),
[projects, user],
);
const memberProjectNames = useMemo(() => {
const allowedIds = new Set([
...(user?.member_project_ids ?? []),
...(user?.projects ?? []).map((p) => p.id),
...(user?.project_id ? [user.project_id] : []),
]);
const matched = projects
.filter((p) => allowedIds.has(p.id))
.map((p) => p.name);
const directNames = (user?.projects ?? []).map((p) => p.name);
if (user?.project_name) directNames.push(user.project_name);
return [...new Set([...matched, ...directNames])];
}, [projects, user]);
const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote();
const addSupplier = useAddSupplier();
@@ -309,22 +316,37 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
[suppliers, materials, topics, items],
);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ----------
// ---------- prefill از لینک‌ها (?topic=&supplier=&project=) ----------
useEffect(() => {
const t = params.get("topic");
if (!t) return;
const topic = topicByName(t, topics);
if (!topic) return;
setSelectedTopic(topic.name);
const sid = params.get("supplier");
if (sid) {
const sup = suppliers.find((s) => s.id === sid);
if (sup && normalize(sup.topic) === normalize(topic.name)) {
setSupplierName(sup.name);
setSupplierInvalid(false);
if (t) {
const topic = topicByName(t, topics);
if (topic) {
setSelectedTopic(topic.name);
const sid = params.get("supplier");
if (sid) {
const sup = suppliers.find((s) => s.id === sid);
if (sup && normalize(sup.topic) === normalize(topic.name)) {
setSupplierName(sup.name);
setSupplierInvalid(false);
}
}
}
}
}, [params, suppliers, topics]);
const proj = params.get("project");
if (proj) {
setAssignment((cur) => {
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [proj];
}
}
return next;
});
}
}, [params, suppliers, topics, items]);
// ---------- بارگذاری اولیه پیش‌نویس ربات بله (?token= یا ?draft=) ----------
useEffect(() => {
@@ -516,11 +538,10 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
// ---------- پروژه‌ها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(
isLimitedProcurement
? memberProjectNames
: projects.map((p) => p.name),
);
if (isLimitedProcurement) {
return memberProjectNames;
}
const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => {
if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n));
@@ -528,6 +549,24 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
return [...names];
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص می‌یابند
useEffect(() => {
if (isLimitedProcurement && memberProjectNames.length === 1) {
const sole = memberProjectNames[0];
setAssignment((cur) => {
let changed = false;
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [sole];
changed = true;
}
}
return changed ? next : cur;
});
}
}, [isLimitedProcurement, memberProjectNames, items]);
const assign = (tempId: string, proj: string) => {
setAssignment((a) => {
const cur = a[tempId] || [];
@@ -898,8 +937,9 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/"
backLabel="بازگشت به دفتر"
backTo={isLimitedProcurement ? "/requester" : "/"}
backLabel={isLimitedProcurement ? "بازگشت به استعلام‌ها" : "بازگشت به دفتر"}
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
)}
<PageLayout>
+9
View File
@@ -786,3 +786,12 @@ button:disabled {
padding: 0.45rem;
}
}
/* اسکرول افقی بدون نوار اسکرول (چیپ‌های لمسی موبایل) */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
+19
View File
@@ -0,0 +1,19 @@
# PM — Construction Project Management Platform
# Copy to .env and customize as needed (never commit .env)
# Database credentials
PM_DB_PASSWORD=pm_secret_2026
# Session secret (random 32+ bytes hex string)
PM_SESSION_SECRET=change-me-32-bytes-hex
# Initial admin account
PM_ADMIN_USER=admin
PM_ADMIN_PASSWORD=pm123456
# ERP service account (Casdoor org `inventory` - for materials/warehouse integration)
PM_ERP_CASDOOR_CLIENT_ID=inventory-app-client
PM_ERP_CASDOOR_CLIENT_SECRET=inventory-app-secret-2026
PM_ERP_SERVICE_USERNAME=pm-service
PM_ERP_SERVICE_PASSWORD=pm-service-2026
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings to LF (mirrors the ERP repo convention)
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
*.ico binary
*.dump binary
+23
View File
@@ -0,0 +1,23 @@
# secrets & local state — never commit
.env
.env.local
# python
__pycache__/
*.pyc
.venv/
venv/
# node
node_modules/
dist/
*.local
# editor / OS
.vscode/
.idea/
Thumbs.db
.DS_Store
# uploaded defect photos (runtime volume)
files/
+102
View File
@@ -0,0 +1,102 @@
# PM API Contract (v3) — Standalone Native Platform — پایه: /api/v1
پلتفرم مدیریت پروژه ساختمانی بومی (Standalone)، مستقل و بدون وابستگی به هیچ سیستم خارجی.
## Auth
- `POST /auth/login` `{username, password}` → `{data:{token, user:{id, username, display_name, role}}}`
- `GET /auth/dev-roles` (در حالت توسعه) → لیست نقش‌های آماده جهت ورود سریع
- `POST /auth/dev-login` `{role}` (در حالت توسعه) → دریافت سریع توکن نقش انتخابی
- همه endpointها (جز لاگین و فایل‌های عمومی) هدر `Authorization: Bearer <token>` می‌خواهند.
- خطاها: `{"error": {"code", "message", "field"?}}` — کدها: `VALIDATION_ERROR` (400)،
`UNAUTHORIZED` (401)، `FORBIDDEN` (403)، `NOT_FOUND` (404)، `CONFLICT` (409)،
`UPSTREAM_UNAVAILABLE` (503).
## تاریخ جلالی
- **همه تاریخ‌های قابل مشاهده در UI شمسی‌اند.** API هم ISO می‌پذیرد/می‌دهد و هم شمسی.
- هر تسک علاوه بر `start_date`/`due_date` (ISO) فیلدهای `start_date_jalali`/
`due_date_jalali` را هم برمی‌گرداند («1405/06/23»).
- `PATCH /tasks/{id}/dates` هر دو قالب را می‌پذیرد: `1405/06/23` یا `2026-09-14`
(سال ≥ 1700 میلادی، کمتر از آن شمسی تفسیر می‌شود). روز نامعتبر → 400.
- تبدیل‌ها در `backend/app/utils/jalali.py` و `frontend/src/lib/jalali.ts` انجام می‌شوند.
## Dashboard
- `GET /dashboard/today` → `{data:{summary:{problems,warnings,ok}, sections:{overdue,due_today,awaiting_qc,failed_qc,blocked,upcoming}}}`
- `awaiting_qc` = وضعیت‌های `pre_qc_pending|exec_qc_pending|ready`
- `failed_qc` = وضعیت‌های `pre_qc_failed|qc_failed_rework`
## Projects
- `GET /projects` → `[...{id, name, identifier, description, active, erp_project_id, created_at, stats:{total, awaiting_qc, failed_qc, overdue}}]`
- `POST /projects` → ساخت پروژه جدید
- `GET /projects/{id}` → جزئیات پروژه به همراه آمارهای تسک‌ها
- `PUT /projects/{id}` | `DELETE /projects/{id}`
- `GET /projects/{id}/gantt` → داده‌های گانت چارت پروژه
- `PATCH /projects/{id}/erp-mapping {erp_project_id}`
## Tasks
- `GET /tasks?project_id=&search=&qc_status=&status=&page=&page_size=`
- `POST /tasks` → ثبت فعالیت/تسک جدید
- `GET /tasks/{id}` → تسک + `relations` + `children` + **`qc`** + **`qc_definitions`** + `defects` + `open_defects`
- `PUT /tasks/{id}` → ویرایش اطلاعات تسک
- `DELETE /tasks/{id}` → حذف تسک
- `PATCH /tasks/{id}/progress {percentage: 0..100}`
- **قاعده ۱:** اگر `percentage > 0` و بازرسی پیش از اجرا تأیید نشده باشد → 400
«بازرسی پیش از اجرا تأیید نشده است (N سؤال باقی‌مانده)»
- **قاعده ۲:** اگر `percentage >= 100` و هر سؤال الزامی تأییدنشده/مردود باشد → 400
«تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (…)»
- `PATCH /tasks/{id}/dates {start_date?, due_date?}` (ISO یا شمسی)
- `POST /tasks/relations` | `DELETE /tasks/relations/{id}` → مدیریت روابط وابستگی تسک‌ها (FS, SS, FF, SF)
### شکل بلوک `qc` در جزئیات تسک
```json
{"pre": {"total":3,"passed":3,"failed":0,"pending":0,"na":0,"required_unresolved":0,"status":"passed"},
"exec": {"total":6,"passed":2,"failed":1,"pending":3,"na":0,"required_unresolved":4,"status":"pending"},
"all": { /* مجموع */ },
"status": "exec_qc_pending",
"can_start": true, "can_complete": false,
"block_reasons": ["تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (۴ سؤال باقی‌مانده، ۱ مورد مردود)"]}
```
### وضعیت‌های `qc_status` روی تسک
`none` · `pre_qc_pending` · `pre_qc_failed` · `ready` · `exec_qc_pending` ·
`in_progress` · `qc_failed_rework` · `final_passed`
## QC — تعریف (Definition)
### قالب‌های سراسری (قابل استفاده مجدد)
- `GET /qc/templates` → `[{id,name,activity_type,description,questions:[{id,phase,text,sort_order,required}]}]`
- `POST /qc/templates {name, activity_type?, description?, questions?:[…]}` → `{data:{id}}`
- `PATCH /qc/templates/{id} {name?,activity_type?,description?}`
- `DELETE /qc/templates/{id}`
- `POST /qc/templates/{id}/questions {phase,text,required?,sort_order?}`
- `PATCH /qc/questions/{qid} {text?,phase?,required?,sort_order?}`
- `DELETE /qc/questions/{qid}`
- `PUT /qc/templates/{id}/questions {questions:[…]}` — جایگزینی گروهی (برای ادیتور)
`phase` ∈ `"pre"` (پیش از اجرا) · `"during"` (هنگام اجرا) · `"after"` (پس از اجرا)
### تعریف مخصوص تسک (کپی مستقل — ویرایش آن قالب سراسری را تغییر نمی‌دهد)
- `GET /qc/tasks/{task_id}` → `{data:{task, definitions:[{id,name,source_template_id,created_at,questions:[{…,result,history_count}],summary}], readiness}}`
- `POST /qc/tasks/{task_id}/definition {name?, template_id?}` → **کپی عمیق** سؤالات قالب
- `PATCH /qc/definitions/{id} {name}` | `DELETE /qc/definitions/{id}`
- `POST /qc/definitions/{id}/questions {text,phase,required?,sort_order?}`
- `PATCH /qc/task-questions/{qid} {text?,phase?,required?,sort_order?}`
- `DELETE /qc/task-questions/{qid}`
## QC — اجرا (Execution)
- `PUT /qc/task-questions/{qid}/result {answer:"pass"|"fail"|"na", comment?}`
→ `{data:{question_id, answer, task_qc_status, readiness}}`
- پاسخ‌ها **append-only** هستند (تاریخچه می‌ماند؛ بازرسی مجدد پس از رفع عیب ممکن است)
- تعریف و اجرا کاملاً جدا هستند: هنگام ساخت/کپی سؤال هیچ پاس/ردی ثبت نمی‌شود.
- عیب‌ها:
- `POST /qc/defects {task_id, question_id?, title, description?, severity?, corrective_action?, photo_id?}`
- `POST /qc/defects/{id}/resolve {resolution_notes?}`
- عکس عیب: `POST /files` (multipart) → `{data:{id, url}}` سپس `photo_id`
## فایل‌ها
- `POST /files` multipart (jpg/png/webp تا ۱۰MB) → `{data:{id,url,filename}}`
- `GET /files/{id}`
## Materials / Resources (یکپارچگی ERP)
- `GET/POST /tasks/{task_id}/materials` · `PATCH/DELETE /materials/{req_id}`
- `GET /erp/materials?search=` · `GET /erp/projects`
- `POST /materials/{req_id}/reserve|purchase-request` · `GET /reservations` · `POST /reservations/{id}/release`
- `GET/PATCH /purchase-requests` · `POST /projects/{project_id}/erp-mapping`
+174
View File
@@ -0,0 +1,174 @@
# راهنمای جامع معماری و مدل داده پایگاه‌داده سیستم مدیریت پروژه عمرانی (Construction PM)
> **دستورالعمل برای هوش مصنوعی (AI Prompt / Context):**
> این سند، مشخصات دقیق معماری، موجودیت‌ها (Entities)، فیلدها، روابط و قواعد تجاری یک سیستم بومی مدیریت پروژه عمرانی (Construction Project Management) است.
> لطفاً این راهنما را مطالعه کرده و بر اساس آن، ساختار سیستم، نحوه ذخیره‌سازی داده‌ها، جریان کار (Workflow) و روابط میان جداول را برای کاربر تحلیل و تشریح نما.
---
## ۱. نمای کلی سیستم و فناوری‌ها (Architecture Overview)
- **ماهیت سامانه:** پلتفرم یکپارچه و بومی مدیریت پروژه‌های عمرانی و ساختمانی (Native Construction PM Platform).
- **لایه‌های نرم‌افزاری:**
- **فرانت‌اند:** React + TypeScript + TailwindCSS + TanStack Query (رابط کاربری مدرن با پشتیبانی تقویم شمسی و نمودار گانت تعاملی).
- **بک‌اند:** Python 3.10+ با فریم‌ورک FastAPI و SQLAlchemy 2.0 (Async ORM با درایور aiosqlite / asyncpg).
- **پایگاه‌داده:** SQLite / PostgreSQL با ساختار کاملاً رابطه‌ای (Relational) و فیلدهای ساخت‌یافته JSON.
- **شناسه‌های یکتا (Primary Keys):** تمامی جداول از کلیدهای اصلی ۱۰۰٪ بومی از نوع `UUID v4` استفاده می‌کنند. هیچ‌گونه شناسه یا وابستگی به سیستم‌های قدیمی (Legacy) وجود ندارد.
- **منطق تاریخ‌ها:** تاریخ‌ها در پایگاه‌داده به صورت استاندارد میلادی (`Date` و `DateTime`) ذخیره می‌شوند و تبدیل‌های شمسی به‌صورت لایه‌ای و لحظه‌ای برای نمایش به کاربر انجام می‌گیرد.
---
## ۲. نمودار رابطه موجودیت‌ها (ERD)
```mermaid
erDiagram
PROJECTS ||--o{ TASKS : "شامل می‌شود (1:N)"
PROJECTS ||--o{ PROJECT_MEMBERS : "تخصیص اعضا (1:N)"
PM_USERS ||--o{ PROJECT_MEMBERS : "عضویت کاربر (1:N)"
PM_USERS ||--o{ PROJECT_ASSIGNMENTS : "دسترسی پروژه (1:N)"
PROJECTS ||--o{ PROJECT_ASSIGNMENTS : "محدوده دسترسی (1:N)"
PM_USERS ||--o{ TASKS : "مسئول وظیفه (Assignee)"
TASKS ||--o{ TASKS : "ساختار درختی WBS (والد/فرزند)"
TASKS ||--o{ TASK_RELATIONS : "وظیفه پیش‌نیاز (from_task)"
TASKS ||--o{ TASK_RELATIONS : "وظیفه پس‌نیاز (to_task)"
PROJECTS ||--o{ TASK_RELATIONS : "روابط گانت پروژه"
TASKS ||--o{ TASK_QC_DEFINITIONS : "چک‌لیست‌های کنترلی"
TASK_QC_DEFINITIONS ||--o{ TASK_QC_QUESTIONS : "آیتم‌های کنترلی"
TASK_QC_QUESTIONS ||--o{ TASK_QC_RESULTS : "تاریخچه بازرسی‌ها"
TASKS ||--o{ DEFECTS : "عیوب و عدم انطباق‌ها"
TASK_QC_QUESTIONS ||--o{ DEFECTS : "علت ریشه‌ای عیب (اختیاری)"
QC_TEMPLATES ||--o{ QC_QUESTIONS : "قالب‌های عمومی بازرسی"
```
---
## ۳. شرح تفصیلی جداول و موجودیت‌ها (Data Dictionary)
### ۱. جدول پروژه‌ها (`projects`)
موجودیت ریشه سامانه که اطلاعات اصلی کارگاه‌های عمرانی را نگهداری می‌کند.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه یکتای پروژه |
| `name` | `VARCHAR(255)` | Index | نام پروژه (مانند «پروژه برج بهارستان») |
| `description` | `TEXT` | - | شرح پروژه، الزامات و مشخصات فنی |
| `location` | `VARCHAR(255)` | - | موقعیت جغرافیایی / نشانی کارگاه |
| `project_manager` | `VARCHAR(255)` | - | نام مدیر یا سرپرست کارگاه |
| `status` | `VARCHAR(32)` | Index | وضعیت: `PLANNED`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELLED` |
| `planned_start` | `DATE` | - | تاریخ شروع مصوب (محاسبه و تجمیع خودکار از کمترین تاریخ شروع تسک‌ها) |
| `planned_end` | `DATE` | - | تاریخ پایان مصوب (محاسبه و تجمیع خودکار از بیشترین تاریخ پایان تسک‌ها) |
| `actual_start` | `DATE` | - | تاریخ شروع واقعی (محاسبه خودکار از زمان آغاز اولین تسک) |
| `actual_end` | `DATE` | - | تاریخ پایان واقعی (محاسبه خودکار با اتمام ۱۰۰٪ تمام تسک‌ها) |
| `progress` | `FLOAT` | - | درصد پیشرفت فیزیکی پروژه (میانگین وزنی درصد پیشرفت تسک‌ها از ۰ تا ۱۰۰) |
| `created_at` | `TIMESTAMP` | - | زمان ثبت رکورد |
| `updated_at` | `TIMESTAMP` | - | زمان آخرین ویرایش رکورد |
---
### ۲. جدول وظایف و فعالیت‌ها (`tasks`)
اصلی‌ترین موجودیت سامانه برای ساختار شکست کار (WBS)، گانت، تخصیص منابع و کنترل کیفی.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه یکتای وظیفه |
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه‌ای که وظیفه به آن تعلق دارد (Cascade Delete) |
| `parent_id` | `UUID` (Nullable) | **FK** -> `tasks.id` | والد در ساختار درختی WBS با عمق نامحدود (فاز ➔ فعالیت ➔ زیرتسک) |
| `title` | `VARCHAR(512)` | - | عنوان وظیفه (مانند «بتن‌ریزی سقف طبقه ۲») |
| `description` | `TEXT` | - | مشخصات و الزامات کار |
| `status` | `VARCHAR(32)` | Index | وضعیت: `TODO`, `IN_PROGRESS`, `BLOCKED`, `COMPLETED`, `CANCELLED` |
| `priority` | `VARCHAR(32)` | - | اولویت: `low`, `medium`, `high`, `urgent` |
| `assignee_id` | `UUID` (Nullable) | **FK** -> `pm_users.id` | کاربر سیستم مسئول اجرای وظیفه |
| `assignee_name` | `VARCHAR(255)` | - | نام متنی کاربر جهت نمایش سریع |
| `planned_start` | `DATE` | - | تاریخ شروع برنامه‌ریزی‌شده |
| `planned_end` | `DATE` | - | تاریخ پایان برنامه‌ریزی‌شده |
| `actual_start` | `DATE` (Nullable) | - | شروع واقعی (ثبت خودکار هنگام تغییر پیشرفت به بیشتر از صفر) |
| `actual_end` | `DATE` (Nullable) | - | پایان واقعی (ثبت خودکار در زمان تکمیل ۱۰۰٪) |
| `duration` | `INTEGER` | - | مدت زمان برآورد شده بر حسب روز کاری |
| `progress` | `INTEGER` | - | درصد پیشرفت واقعی از ۰ تا ۱۰۰ |
| `qc_status` | `VARCHAR(32)` | - | وضعیت کنترل کیفی: `none`, `pre_qc_pending`, `pre_qc_failed`, `ready`, `exec_qc_pending`, `in_progress`, `qc_failed_rework`, `final_passed` |
| `human_resources` | `JSON` | - | لیست نقش‌ها و نفرات: `[{"role": "آرماتوربند", "count": 4}]` |
| `materials` | `JSON` | - | لیست مصالح و تجهیزات/ماشین‌آلات: `[{"name": "سیمان تیپ ۲", "unit": "تن", "specifications": ["عیار ۳۵۰"], "count": 10}, {"name": "تاور کرین", "unit": "دستگاه", "specifications": ["ظرفیت ۸ تن"], "count": 1}]` |
| `created_at` | `TIMESTAMP` | - | تاریخ ایجاد رکورد |
| `updated_at` | `TIMESTAMP` | - | تاریخ آخرین به‌روزرسانی |
---
### ۳. جدول روابط وابستگی و گانت (`task_relations`)
پایه‌گذار روش مسیر بحرانی (CPM Scheduling) و شبکه فعالیت‌های گانت.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه رابطه |
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه مربوطه |
| `from_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت پیش‌نیاز (Predecessor) |
| `to_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت وابسته / پس‌نیاز (Successor) |
| `relation_type` | `VARCHAR(8)` | - | نوع وابستگی: `FS` (پایان به شروع)، `SS` (شروع به شروع)، `FF` (پایان به پایان)، `SF` (شروع به پایان) |
| `lag_days` | `INTEGER` | - | فاصله زمانی مجاز (تاخیر یا تقدم) بر حسب روز |
| `created_at` | `TIMESTAMP` | - | زمان ثبت رابطه |
*محدودیت (Constraint):* زوج `(from_task_id, to_task_id)` یکتا بوده و از ایجاد حلقه (Cycle) در الگوریتم CPM جلوگیری می‌شود.
---
### ۴. حوزه کنترل کیفیت و بازرسی فنی (QC Domain)
این بخش تضمین می‌کند کلیه اقدامات اجرایی کارگاه دارای تاییدیه‌های استاندارد باشند:
#### الف) قالب‌های سراسری بازرسی (`qc_templates` و `qc_questions`)
- **`qc_templates`:** کتابخانه فرم‌های استاندارد کیفی (مثلاً قالب بازرسی بتن‌ریزی، قالب سفت‌کاری).
- **`qc_questions`:** سوالات و ضوابط کنترلی در سه فاز اجرایی:
- `pre`: پیش از اجرا (آمادگی کارگاه، تایید نقشه‌ها، مصالح ورودی).
- `during`: حین اجرا (دمای بتن، ویبره مناسب، ایمنی).
- `after`: پس از اجرا (عمل‌آوری، مقاومت فشاری نمونه‌ها).
#### ب) فرم‌های کیفی اختصاص‌یافته به وظایف (`task_qc_definitions` و `task_qc_questions`)
- با تخصیص چک‌لیست به یک تسک، یک نسخه مستقل از قالب بر روی وظیفه ایجاد می‌شود تا هرگونه تغییر روی قالب سراسری به تاریخچه بازرسی‌های ثبت‌شده آسیب نزند.
#### ج) نتایج و تاریخچه بازرسی (`task_qc_results`)
- سیستم ثبت پیوسته (Append-Only Log) بدون امکان حذف؛ هر ارزیابی بازرس شامل:
- `question_id`: شناسه آیتم بازرسی.
- `answer`: پاسخ بازرس (`pass` تایید، `fail` عدم انطباق، `na` عدم کاربرد).
- `comment`: توضیحات فنی و نواقص مشاهده‌شده.
- `evaluated_by`: نام بازرس ناظر.
- `evaluated_at`: زمان دقیق بازرسی.
#### د) جدول عیوب و عدم انطباق‌ها (`defects`)
- ثبت رخدادهای نامنطبق در جریان اجرای کار:
- `id` (UUID - PK): شناسه عیب.
- `task_id` (UUID - FK -> `tasks.id`): وظیفه مربوطه.
- `question_id` (UUID - FK اختیاری): سوال بازرسی که منجر به رد شدن شده است.
- `title`: عنوان عدم انطباق (مثلاً: «کرموشدگی بتن ستون جنوبی»).
- `description`: شرح دقیق ایراد.
- `severity`: درجه شدت عیب (`minor`, `medium`, `major`, `critical`).
- `status`: وضعیت رفع (`open`, `in_progress`, `resolved`).
- `corrective_action`: دستور اقدام اصلاحی صادرشده توسط مهندس ناظر.
- `resolution_notes`: شرح نحوه رفع عیب.
- `photo_path`: مسیر ذخیره تصویر مستند عیب در سرور.
- `resolved_at`: زمان تایید رفع عیب.
---
### ۵. احراز هویت و دسترسی‌ها (`pm_users`, `project_members`, `project_assignments`)
- **`pm_users`:** کاربران سازمانی سیستم (`username`, `password_hash`, `display_name`, `role`).
- نقش‌های سیستمی: `project_manager` (دسترسی نامحدود به تمام پروژه‌ها)، `project_supervisor` (دسترسی محدود به پروژه‌های مشخص).
- **`project_members`:** اعضای منتسب به هر پروژه با نقش کارگاهی:
- `role`: `project_manager`, `site_engineer`, `qc_inspector`, `member`.
- **`project_assignments`:** جدول دامنه دسترسی برای ناظران و سرپرستان دارای دسترسی محدود (Scoped Roles).
---
## ۴. قواعد و منطق‌های تجاری نهفته در سیستم (Business Invariants)
1. **گیتینگ کنترل کیفی (QC Gating Rule):**
اگر وظیفه‌ای دارای چک‌لیست بازرسی فاز `pre` (پیش از اجرا) باشد، تا زمانی که تمامی این سوالات وضعیت `pass` یا `na` دریافت نکنند، سیستم اجازه شروع کار (`IN_PROGRESS`) و افزایش پیشرفت را نمی‌دهد.
2. **محاسبات پیش‌بینی مسیر بحرانی (CPM Logic):**
محاسبات زمان‌بندی با عبور رفت و برگشت (Forward & Backward Pass) تاریخ‌های زودهنگام و دیرهنگام را استخراج کرده و فعالیت‌های با شناوری صفر (`Total Float = 0`) را به عنوان مسیر بحرانی مشخص می‌کند.
3. **چرخه حیات خودکار تاریخ‌های واقعی:**
با اولین تغییر در پیشرفت تسک (`progress > 0`)، فیلد `actual_start` به تاریخ جاری تنظیم می‌گردد. با رسیدن پیشرفت به `100%`، فیلد `actual_end` تنظیم و وضعیت وظیفه خودکار به `COMPLETED` تغییر می‌یابد.
4. **تجمیع پروژه (Rollup Logic):**
تاریخ شروع، پایان و درصد پیشرفت فیزیکی کل پروژه همواره حاصل تجمیع و میانگین‌گیری پویای وظایف موجود در پروژه است.
+59
View File
@@ -0,0 +1,59 @@
# PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت (Aramesh PM)
پلتفرم تخصصی مدیریت پروژه‌های عمرانی، کنترل کیفیت و زمان‌بندی کارگاهی که به صورت وندور شده و یکپارچه در کنار سامانه ERP قرار دارد.
---
## 🏗️ نحوه استقرار و دسترسی در سامانه ERP
پلتفرم PM در استک یکپارچه پروژه از دو سرویس داکر تشکیل شده است:
* **`pm-backend` (`pm-api`):** وب‌سرویس پایتون مبتنی بر FastAPI روی شبکه داخلی استک.
* **`pm-frontend` (`pm-frontend`):** اپلیکیشن کلاینت React با سرور داخلی Nginx که از طریق ریورس‌پروکسی Traefik زیر مسیر `/pm` قرار گرفته است.
### راه‌های دسترسی:
1. **از داخل رابط کاربری ERP:** با ورود به حساب کاربری (نقش‌های مجاز یا پنل PM) و انتخاب منوی **«مدیریت پروژه (PM)»** در آدرس `/panel/pm` (به صورت آی‌فریم روان با سشن یکپارچه).
2. **دسترسی مستقیم مرورگر:** در آدرس `http://localhost/pm` (یا دامنه عملیاتی با پسوند `/pm/`).
---
## 🗄️ پایگاه داده و احراز هویت مشترک
* **پایگاه‌داده مشترک:** سرویس PM مستقیماً به همان دیتابیس `procurement` در کانتینر Postgres متصل می‌شود. جداول اختصاصی PM (`pm_projects`, `pm_tasks`, `pm_qc_*`, `pm_defects`, ...) در زمان بوت اولیه به صورت خودکار و بدون تداخل با جداول تدارکات ایجاد می‌شوند (`schema_sync.py`).
* **احراز هویت یکپارچه (SSO):** ورود با همان حساب کاربری Casdoor انجام شده و سطح دسترسی پنل PM (`user_panels.pm`) کنترل می‌شود.
---
## ⚙️ قابلیت‌های اصلی پلتفرم PM
### ۱. مدیریت زمان‌بندی و شکست کار (WBS & CPM Engine)
* ساختار شکست کار با عمق نامحدود (فاز، بخش، فعالیت، زیرفعالیت).
* روابط وابستگی چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) به همراه زمان وقفه (Lag).
* محاسبه خودکار مسیر بحرانی (Critical Path Method) و شناوری کل فعالیت‌ها.
* گانت چارت شمسی و تعریف مایلستون‌های کلیدی پروژه.
### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC Module)
* **قالب‌های عمومی بازرسی (QC Templates):** تعریف چک‌لیست‌های استاندارد کیفیت برای انواع فعالیت‌های ساختمانی.
* **مراحل ارزیابی:**
* **پیش از اجرا (`pre`):** دروازه ورود به تسک؛ تا زمان تایید این مرحله، پیشرفت تسک نمی‌تواند بیشتر از ۰٪ شود.
* **حین اجرا (`during`):** ثبت بازرسی‌های فنی حین عملیات اجرایی.
* **پس از اجرا (`after`):** کنترل نهایی؛ تا زمان تایید تمامی سوالات الزامی، پیشرفت تسک اجازه ۱۰۰٪ شدن ندارد.
* **ثبت و پیگیری عیوب (Defects):** ثبت عیوب همراه با تصاویر، سطح بحرانی بودن و لزوم بازرسی مجدد پس از اقدامات اصلاحی.
### ۳. مدیریت منابع و اتصال به انبار کارگاه
* امکان تخصیص متریال و ثبت رزرو نرم با استعلام موجودی واقعی انبارهای متناظر در ERP.
---
## 🧪 اجرای آزمایشی مستقل (Standalone Dev)
در صورت تمایل به اجرای مستقل پلتفرم PM برای تست‌های ایزوله:
```powershell
# از درون پوشه pm:
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
این حالت روی پورت محلی `8083` به صورت ایزوله اجرا خواهد شد.
---
## 📑 قرارداد و مستندات API
قرارداد جامع اندپوینت‌ها و نمونه پیام‌ها در فایل [API_CONTRACT.md](API_CONTRACT.md) و راهنمای تفصیلی ساختار جداول در [DATABASE_GUIDE.md](DATABASE_GUIDE.md) مستند شده است.
+3
View File
@@ -0,0 +1,3 @@
__pycache__
*.pyc
.pytest_cache
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.13-slim
WORKDIR /srv/pm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
# PIP_INDEX_URL overridable like the ERP api/bot images (mirror for restricted networks).
ARG PIP_INDEX_URL=https://pypi.org/simple
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
COPY app ./app
COPY tools ./tools
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""PM application package."""
+296
View File
@@ -0,0 +1,296 @@
"""Central authorization (RBAC) — roles, permissions and per-project scoping.
Everything authorization-related lives in this module so that adding a role is
one entry in `ROLE_PERMISSIONS` (plus a nav entry on the frontend), not a hunt
through the routers.
Two independent questions, answered separately:
* **Role → permissions** (`Perm`, `ROLE_PERMISSIONS`) decides *which operations*
a user may call at all.
* **Project scope** decides *which projects* those operations may touch. A role
listed in `SCOPED_ROLES` is limited to the rows in `project_assignments`
(an empty set means "no project at all"); every other role is unrestricted.
Routers must go through the dependencies and helpers below — hiding a button in
the UI is a convenience, never the enforcement.
"""
from __future__ import annotations
import enum
import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from fastapi import Depends
from sqlalchemy import Select, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.errors import ForbiddenError, NotFoundError
from app.models import PMUser, ProjectAssignment, Task
from app.security import get_current_user
logger = logging.getLogger("pm.authz")
# --- roles --------------------------------------------------------------------
class Role(str, enum.Enum):
project_manager = "project_manager"
project_supervisor = "project_supervisor"
ROLE_LABELS: dict[str, str] = {
Role.project_manager.value: "مدیر پروژه",
Role.project_supervisor.value: "سرپرست پروژه",
}
# Users created before RBAC existed keep their previous (full) access.
DEFAULT_ROLE = Role.project_manager
# Roles whose access is limited to their assigned projects.
SCOPED_ROLES: frozenset[Role] = frozenset({Role.project_supervisor})
# --- permissions --------------------------------------------------------------
class Perm(str, enum.Enum):
"""One value per authorizable operation — keep it coarse and stable."""
dashboard_view = "dashboard.view"
projects_view = "projects.view"
projects_create = "projects.create"
projects_update = "projects.update"
tasks_view = "tasks.view"
tasks_create = "tasks.create"
tasks_update = "tasks.update"
tasks_delete = "tasks.delete"
tasks_progress = "tasks.progress"
tasks_dates = "tasks.dates"
qc_view = "qc.view"
# executing QC: recording answers and raising/resolving defects
qc_execute = "qc.execute"
# QC structure: global templates + per-task definitions
qc_define = "qc.define"
materials_view = "materials.view"
materials_manage = "materials.manage"
users_manage = "users.manage"
files_upload = "files.upload"
# Project manager: full access to everything, across every project.
_MANAGER_PERMISSIONS: frozenset[str] = frozenset(p.value for p in Perm)
# Project supervisor: executes the assigned projects — reads the plan, records
# progress and QC results, needs the material list; never edits the QC
# structure, templates, procurement or integration settings.
_SUPERVISOR_PERMISSIONS: frozenset[str] = frozenset(
{
Perm.dashboard_view.value,
Perm.projects_view.value,
Perm.tasks_view.value,
Perm.tasks_progress.value,
Perm.qc_view.value,
Perm.qc_execute.value,
Perm.materials_view.value,
Perm.files_upload.value,
}
)
ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
Role.project_manager: _MANAGER_PERMISSIONS,
Role.project_supervisor: _SUPERVISOR_PERMISSIONS,
}
def permissions_for(role: Role) -> frozenset[str]:
return ROLE_PERMISSIONS.get(role, frozenset())
def role_of(user: PMUser) -> Role:
try:
return Role(user.role)
except ValueError:
logger.warning(
"unknown role %r on user %s — falling back to %s", user.role, user.username, DEFAULT_ROLE.value
)
return DEFAULT_ROLE
# --- request context ----------------------------------------------------------
@dataclass(frozen=True)
class AuthContext:
"""Who is calling, what they may do, and which projects they may touch.
`project_uuids` — native projects (`projects.id`).
None for an unscoped role (project manager). For a scoped role a set — possibly empty — means "only these".
"""
user: PMUser
role: Role
project_uuids: frozenset[uuid.UUID] | None = None
@property
def permissions(self) -> frozenset[str]:
return permissions_for(self.role)
@property
def role_label(self) -> str:
return ROLE_LABELS.get(self.role.value, self.role.value)
@property
def all_projects(self) -> bool:
return self.project_uuids is None
def can(self, permission: Perm | str) -> bool:
value = permission.value if isinstance(permission, Perm) else permission
return value in self.permissions
def require(self, permission: Perm | str) -> None:
if not self.can(permission):
raise ForbiddenError(f"نقش «{self.role_label}» به این عملیات دسترسی ندارد")
def allows_project(self, project_id) -> bool:
"""Check whether the user is allowed to access the specified project."""
if self.project_uuids is None:
return True
try:
pid = uuid.UUID(str(project_id))
except (ValueError, AttributeError):
return False
return pid in self.project_uuids
def ensure_project(self, project_id) -> None:
"""Raise 403 when the project is outside this user's scope."""
if not self.allows_project(project_id):
raise ForbiddenError("این پروژه به شما اختصاص داده نشده است")
allows_native_project = allows_project
ensure_native_project = ensure_project
def scope(self, stmt: Select, column) -> Select:
"""Restrict a query over project UUIDs."""
if self.project_uuids is None:
return stmt
return stmt.where(column.in_(self.project_uuids))
scope_native = scope
@property
def scoped_project_count(self) -> int:
"""How many projects a scoped user may see (0 when nothing is assigned)."""
return len(self.project_uuids or ())
def as_dict(self) -> dict:
return {
"role": self.role.value,
"role_label": self.role_label,
"permissions": sorted(self.permissions),
"scope": {
"all_projects": self.all_projects,
"project_count": None if self.all_projects else self.scoped_project_count,
},
}
async def assigned_scope(db: AsyncSession, user_id) -> frozenset[uuid.UUID]:
"""Load assigned project UUIDs for a scoped user."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
assigned = set(r for r in rows if r is not None)
# Also resolve projects assigned via projects.pm_user_id or user_project_members in the shared DB
pm_user = await db.get(PMUser, user_id)
if pm_user and pm_user.username:
erp_rows = (
await db.execute(
text("""
SELECT p.id FROM projects p JOIN users u ON p.pm_user_id = u.id WHERE u.username = :username
UNION
SELECT upm.project_id FROM user_project_members upm JOIN users u ON upm.user_id = u.id WHERE u.username = :username
"""),
{"username": pm_user.username},
)
).scalars().all()
for r in erp_rows:
if r:
try:
assigned.add(uuid.UUID(str(r)))
except (ValueError, TypeError):
pass
return frozenset(assigned)
async def build_context(db: AsyncSession, user: PMUser) -> AuthContext:
"""Resolve role + project scope for a user."""
role = role_of(user)
if role not in SCOPED_ROLES:
return AuthContext(user=user, role=role)
native = await assigned_scope(db, user.id)
return AuthContext(user=user, role=role, project_uuids=native)
async def get_auth_context(
user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> AuthContext:
return await build_context(db, user)
def require_permission(permission: Perm) -> Callable[..., Awaitable[AuthContext]]:
"""Dependency: authenticated **and** allowed to perform `permission`."""
async def _guard(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
ctx.require(permission)
return ctx
return _guard
# --- data-access guards -------------------------------------------------------
async def resolve_task(db: AsyncSession, task_ref: str | uuid.UUID) -> Task | None:
"""Find a native task by UUID."""
task_ref = task_ref.strip() if isinstance(task_ref, str) else task_ref
try:
task_uuid = uuid.UUID(str(task_ref))
except (ValueError, AttributeError):
return None
return await db.scalar(select(Task).where(Task.id == task_uuid))
async def scoped_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> Task:
"""Load a native task: 404 unknown, 403 out of scope."""
task = await resolve_task(db, task_ref)
if task is None:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_project(task.project_id)
return task
scoped_native_task = scoped_task
resolve_native_task = resolve_task
# --- development role switching ----------------------------------------------
def dev_switch_enabled() -> bool:
return bool(settings.DEV_ROLE_SWITCH)
def dev_login_usernames() -> list[str]:
"""Demo accounts the dev switcher may log in as (never arbitrary users)."""
return [name for name in settings.dev_demo_usernames() if name]
+184
View File
@@ -0,0 +1,184 @@
"""Casdoor SSO for PM — one identity source for the whole suite.
The Casdoor JWT issued by the ERP login flow is accepted on every PM endpoint:
- The token is verified against Casdoor's JWKS (same as the ERP backend):
signature (RS256 family), issuer = ``PM_CASDOOR_ISSUER`` (the browser-facing
origin Casdoor advertises — ``casdoor.origin`` in the compose env) and
audience = ``PM_CASDOOR_AUDIENCE`` (the inventory-app client).
- The caller's system-wide role lives in the ERP (``users.role``) and is
resolved by calling ``GET {ERP_BASE_URL}/api/v1/users/me`` with the SAME
Bearer token (the ERP verifies the identical JWT). Result is cached briefly
per casdoor id — one upstream call per role change window, not per request.
- Mapping to the PM world (single source of truth stays the ERP):
admin -> project_manager (full PM access)
pm (مدیر پروژه) -> project_manager (full PM access)
supervisor -> project_supervisor (project-scoped, as today)
procurement_manager -> 403 — مدیر تدارکات به پنل PM دسترسی ندارد
- The PMUser row is auto-provisioned / role-synced by username on first use,
so تخصیص سرپرست‌ها به پروژه‌ها (ProjectAssignment) بلافاصله بعد از اولین
ورود قابل انجام است.
Legacy HMAC session tokens (POST /auth/login, dev role switcher, standalone
:8083 deployments) keep working: ``get_current_user`` dispatches on token
shape (two dot-separated segments = legacy, three = Casdoor JWT).
"""
import logging
import time
import httpx
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.errors import AuthError, ForbiddenError, UpstreamError
from app.models import PMUser
# نقش‌های PM به‌صورت متن (PMUser.role ستون متنی است) — import از authz اینجا
# چرخه‌ی import می‌سازد (authz → security → casdoor_auth → authz)
_PM_PROJECT_MANAGER = "project_manager"
_PM_PROJECT_SUPERVISOR = "project_supervisor"
logger = logging.getLogger("pm.casdoor")
_JWKS_CACHE: dict | None = None
# casdoor_id -> (erp_role, expiry monotonic seconds)
_ROLE_CACHE: dict[str, tuple[str, float]] = {}
_ROLE_CACHE_TTL_SECONDS = 30
def _jwks_url() -> str:
return f"{settings.ERP_CASDOOR_ENDPOINT.rstrip('/')}/.well-known/jwks"
async def _get_jwks() -> dict:
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
except httpx.HTTPError as exc:
logger.error("Casdoor JWKS fetch failed (%s): %s", _jwks_url(), exc)
raise UpstreamError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc
return _JWKS_CACHE
def _key_from_jwk(jwk: dict):
kty = jwk.get("kty")
if kty == "RSA":
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
if kty == "EC":
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
return jwk
async def verify_casdoor_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims (AuthError on failure)."""
try:
kid = jwt.get_unverified_header(token).get("kid")
except jwt.PyJWTError as exc:
logger.warning("JWT unreadable: %s", exc)
raise AuthError("توکن نامعتبر است") from exc
jwks = await _get_jwks()
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if entry is None:
logger.warning("JWT kid %r not found in JWKS", kid)
raise AuthError("امضای توکن نامعتبر است")
key = _key_from_jwk(entry)
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
issuer=settings.CASDOOR_ISSUER,
audience=settings.CASDOOR_AUDIENCE,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise AuthError("توکن نامعتبر یا منقضی شده است") from exc
async def _erp_identity_for_token(token: str, casdoor_id: str) -> dict:
"""نقش + پنل‌های کاربر از ERP (users/me) — کش کوتاه."""
cached = _ROLE_CACHE.get(casdoor_id)
if cached and cached[1] > time.monotonic():
return cached[0]
url = f"{settings.ERP_BASE_URL.rstrip('/')}/api/v1/users/me"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
logger.error("ERP /users/me unreachable (%s): %s", url, exc)
raise UpstreamError("سامانه تدارکات (ERP) برای تعیین دسترسی در دسترس نیست") from exc
if resp.status_code == 401:
raise AuthError("توکن نامعتبر یا منقضی شده است")
if resp.status_code != 200:
logger.error("ERP /users/me failed (%s): HTTP %s", url, resp.status_code)
raise UpstreamError("تعیین دسترسی کاربر از ERP ناموفق بود")
data = resp.json().get("data") or {}
identity = {"role": data.get("role"), "panels": data.get("panels") or {}}
_ROLE_CACHE[casdoor_id] = (identity, time.monotonic() + _ROLE_CACHE_TTL_SECONDS)
return identity
# سطح پنل PM -> نقش PM: full = همه بخش‌ها با اختیار کامل تغییر؛
# limited = پاسخ QC + درصد پیشرفت + مشاهده جزئیات (محدود به پروژه‌های تخصیصی).
# مدیر تدارکات به PM دسترسی ندارد.
async def casdoor_user_from_token(token: str, db: AsyncSession) -> PMUser:
"""Verify the JWT, resolve the system role from ERP and return the
provisioned PMUser (role synced). 403 for roles without PM access."""
claims = await verify_casdoor_token(token)
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
username = str(claims.get("name") or claims.get("preferred_username") or "")
if not casdoor_id or not username:
raise AuthError("توکن فاقد شناسه کاربر است")
identity = await _erp_identity_for_token(token, casdoor_id)
panels = identity.get("panels") or {}
erp_role = identity.get("role")
pm_level = panels.get("pm")
if erp_role == "admin":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "full":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "limited":
pm_role = _PM_PROJECT_SUPERVISOR
elif not panels and erp_role in ("admin", "pm"): # میراث — دوره مهاجرت فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_MANAGER
elif not panels and erp_role == "supervisor": # میراث — فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_SUPERVISOR
else:
raise ForbiddenError("به پنل مدیریت پروژه دسترسی ندارید")
user = await db.scalar(select(PMUser).where(PMUser.username == username))
if user is None:
display_name = str(
claims.get("displayName") or claims.get("firstName") or username
)
user = PMUser(
username=username,
password_hash="", # SSO identities never authenticate locally
display_name=display_name,
role=pm_role,
)
db.add(user)
await db.commit()
await db.refresh(user)
elif user.role != pm_role:
# نقش سراسری عوض شده — با PM همگام می‌شود
user.role = pm_role
await db.commit()
await db.refresh(user)
return user
def is_casdoor_token(token: str) -> bool:
"""Legacy session tokens are ``body.signature`` (one dot); Casdoor JWTs
have the standard three header/payload/signature segments."""
return token.count(".") == 2
+49
View File
@@ -0,0 +1,49 @@
"""PM settings — env prefix PM_ (mirrors the ERP PROCUREMENT_ pattern)."""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PM_", env_file=".env", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://pm:pm@localhost:5432/pm"
SESSION_SECRET: str = "change-me"
SESSION_TTL_HOURS: int = 24 * 14
ADMIN_USER: str = "admin"
ADMIN_PASSWORD: str = "admin"
# Development role switching — the dashboard shows a role picker so the two
# roles can be tried without separate logins. Set PM_DEV_ROLE_SWITCH=false
# (production) to disable the dev endpoints entirely.
DEV_ROLE_SWITCH: bool = False
DEV_SUPERVISOR_USER: str = "supervisor"
DEV_SUPERVISOR_PASSWORD: str = "pm123456"
DEV_SUPERVISOR2_USER: str = "supervisor2"
DEV_SUPERVISOR2_PASSWORD: str = "pm123456"
def dev_demo_usernames(self) -> list[str]:
"""Accounts the dev role switcher may log in as (manager + supervisors)."""
return [self.ADMIN_USER, self.DEV_SUPERVISOR_USER, self.DEV_SUPERVISOR2_USER]
FILES_DIR: Path = Path("/data/files")
# ERP (procurement/inventory) integration — read-only service account
ERP_BASE_URL: str = "http://procurement-api:8000"
ERP_CASDOOR_ENDPOINT: str = "http://casdoor:8000"
ERP_CASDOOR_CLIENT_ID: str = "inventory-app-client"
ERP_CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
ERP_CASDOOR_ORGANIZATION: str = "inventory"
ERP_SERVICE_USERNAME: str = "pm-service"
ERP_SERVICE_PASSWORD: str = ""
ERP_CACHE_TTL_SECONDS: int = 120
# Casdoor SSO — PM accepts the ERP-issued JWT as its own session.
# ISSUER = the browser-facing origin Casdoor advertises (casdoor `origin`),
# not the in-network endpoint; AUDIENCE = the inventory-app client id.
CASDOOR_ISSUER: str = "http://localhost:8080"
CASDOOR_AUDIENCE: str = "inventory-app-client"
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
"""Async SQLAlchemy engine/session for PM database."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
try:
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_size=5, max_overflow=5)
except Exception:
# Fallback for testing environments or when asyncpg driver is not installed on host
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
+151
View File
@@ -0,0 +1,151 @@
"""Development-only demo accounts for the role switcher on the dashboard.
While the two roles are being built, the «امروز» dashboard shows a role picker
so nobody has to juggle logins. This module creates those demo users and gives
the demo supervisors a project scope, so the picker is immediately useful —
the enforcement itself is the real thing (see app/authz.py).
Everything here is inert unless `PM_DEV_ROLE_SWITCH` is enabled; the endpoints
in routers/auth.py return 404 in that case. For production set the flag to
false and manage users through `PUT /users/{id}/projects`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import Role, dev_switch_enabled
from app.config import settings
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
logger = logging.getLogger("pm.dev")
@dataclass(frozen=True)
class DemoAccount:
username: str
password: str
display_name: str
role: Role
share: tuple[int, int] | None = None
def demo_accounts() -> list[DemoAccount]:
total = len(settings.dev_demo_usernames())
return [
DemoAccount(settings.ADMIN_USER, settings.ADMIN_PASSWORD, "مدیر سیستم", Role.project_manager),
DemoAccount(
settings.DEV_SUPERVISOR_USER,
settings.DEV_SUPERVISOR_PASSWORD,
"سرپرست پروژه الف",
Role.project_supervisor,
share=(1, 2),
),
DemoAccount(
settings.DEV_SUPERVISOR2_USER,
settings.DEV_SUPERVISOR2_PASSWORD,
"سرپرست پروژه ب",
Role.project_supervisor,
share=(2, 2),
),
][:total]
def _share_of(items: list, share: tuple[int, int]) -> list:
"""Deterministic, non-overlapping slice: part N of M (1-based)."""
part, of = share
size = -(-len(items) // of)
start = (part - 1) * size
return items[start : start + size]
def demo_account(username: str) -> DemoAccount | None:
for account in demo_accounts():
if account.username == username:
return account
return None
async def ensure_demo_accounts(db: AsyncSession) -> None:
if not dev_switch_enabled():
# در پروداکشن، حساب‌های نمایشی توسعه نباید فعال باشند
demo_names = [settings.DEV_SUPERVISOR_USER, settings.DEV_SUPERVISOR2_USER]
demo_users = (
await db.execute(select(PMUser).where(PMUser.username.in_(demo_names)))
).scalars().all()
for u in demo_users:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == u.id))
await db.delete(u)
logger.info("cleaned up demo account %s for production", u.username)
admin_u = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if admin_u is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role=Role.project_manager.value,
)
)
logger.info("seeded admin account in production mode")
else:
# همیشه رمز عبور را از تنظیمات به‌روز کن تا لاگین همیشه کار کند
admin_u.password_hash = hash_password(settings.ADMIN_PASSWORD)
admin_u.role = Role.project_manager.value
logger.info("refreshed admin password hash in production mode")
await db.commit()
return
for account in demo_accounts():
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
db.add(
PMUser(
username=account.username,
password_hash=hash_password(account.password),
display_name=account.display_name,
role=account.role.value,
)
)
logger.info("seeded %s account %s", account.role.value, account.username)
else:
user.role = account.role.value
user.display_name = account.display_name
await db.commit()
async def ensure_demo_assignments(db: AsyncSession) -> None:
if not dev_switch_enabled():
return
native_ids = [p for p in (await db.execute(select(Project.id).order_by(Project.name))).scalars().all()]
if not native_ids:
return
for account in demo_accounts():
if account.share is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
existing = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
if existing:
continue
mine = _share_of(native_ids, account.share)
for project_uuid in mine:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
logger.info(
"assigned %d demo projects to %s",
len(mine),
account.username,
)
await db.commit()
async def clear_assignments(db: AsyncSession, user_id) -> None:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == user_id))
+53
View File
@@ -0,0 +1,53 @@
"""PM error envelope — same contract as the ERP: {"error": {code, message, field}}."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
status_code = 400
code = "BAD_REQUEST"
def __init__(self, message: str, field: str | None = None, code: str | None = None):
super().__init__(message)
self.message = message
self.field = field
if code:
self.code = code
@property
def body(self) -> dict:
error = {"code": self.code, "message": self.message}
if self.field is not None:
error["field"] = self.field
return {"error": error}
class AuthError(AppError):
status_code, code = 401, "UNAUTHORIZED"
class ValidationError(AppError):
status_code, code = 400, "VALIDATION_ERROR"
class ForbiddenError(AppError):
status_code, code = 403, "FORBIDDEN"
class NotFoundError(AppError):
status_code, code = 404, "NOT_FOUND"
class ConflictError(AppError):
status_code, code = 409, "CONFLICT"
class UpstreamError(AppError):
"""Upstream service unreachable / errored."""
status_code, code = 503, "UPSTREAM_UNAVAILABLE"
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.body)
+1
View File
@@ -0,0 +1 @@
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
+160
View File
@@ -0,0 +1,160 @@
"""ERP (procurement/inventory) read-only connector.
Auth: Casdoor password grant with a dedicated service account (no changes to
the ERP codebase). The token is cached until expiry. ERP stays the source of
truth for physical stock; PM only READS it (reservations/purchase requests are
PM-owned operational overlays).
"""
import asyncio
import logging
import time
from typing import Any
import httpx
from app.config import settings
from app.errors import UpstreamError
logger = logging.getLogger("pm.erp")
class ERPClient:
def __init__(self):
self._token: str | None = None
self._token_exp: float = 0
self._lock = asyncio.Lock()
async def _ensure_token(self) -> str:
async with self._lock:
if self._token and time.time() < self._token_exp - 30:
return self._token
usernames = [
settings.ERP_SERVICE_USERNAME,
f"{settings.ERP_CASDOOR_ORGANIZATION}/{settings.ERP_SERVICE_USERNAME}",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=12) as client:
for username in usernames:
try:
resp = await client.post(
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token",
data={
"grant_type": "password",
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
"username": username,
"password": settings.ERP_SERVICE_PASSWORD,
},
)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if token:
self._token = token
self._token_exp = time.time() + int(data.get("expires_in") or 3600)
return token
except Exception as exc: # noqa: BLE001
last_error = exc
logger.warning("ERP token grant failed for %s: %s", username, exc)
raise UpstreamError(f"احراز هویت سرویس با ERP/Casdoor ناموفق بود: {last_error}")
async def get(self, path: str, params: dict | None = None) -> Any:
token = await self._ensure_token()
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{settings.ERP_BASE_URL}{path}",
params=params,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise UpstreamError(f"ERP در دسترس نیست: {exc.__class__.__name__}") from exc
if resp.status_code == 401:
self._token = None
raise UpstreamError("توکن سرویس ERP نامعتبر شد")
if resp.status_code >= 400:
logger.warning("ERP GET %s → %s %s", path, resp.status_code, resp.text[:200])
raise UpstreamError(f"خطای ERP ({resp.status_code})")
if not resp.content:
return {}
return resp.json()
# --- domain reads (normalize to PM-friendly dicts) -----------------------
async def materials(self) -> list[dict]:
"""Global material catalog: [{id, name, unit_id, unit_name}]"""
materials: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/materials?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
materials.extend(elements)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(materials)):
break
page += 1
unit_names: dict[int, str] = {}
try:
units = await self.get("/api/v1/units?page_size=100")
for u in units.get("data", []):
unit_names[u.get("id")] = u.get("name_fa") or u.get("name") or u.get("code") or ""
except Exception: # noqa: BLE001 — unit names are cosmetic
pass
return [
{
"id": m.get("id"),
"name": m.get("name") or "",
"unit_id": m.get("canonical_unit_id"),
"unit_name": unit_names.get(m.get("canonical_unit_id"), ""),
}
for m in materials
if m.get("id") and (m.get("status") or "active") == "active"
]
async def projects(self) -> list[dict]:
"""ERP projects for the mapping picker: [{id, name}]"""
data = await self.get("/api/v1/projects?page_size=100")
elements = data.get("data", []) if isinstance(data, dict) else []
return [{"id": p.get("id"), "name": p.get("name") or ""} for p in elements if p.get("id")]
async def warehouses(self, erp_project_id: str) -> list[dict]:
data = await self.get("/api/v1/inventory/warehouses")
elements = data.get("data", data) if isinstance(data, dict) else data
if isinstance(elements, dict):
elements = elements.get("data", [])
return [w for w in (elements or []) if w.get("project_id") == erp_project_id]
async def inventory_rows(self) -> list[dict]:
"""Normalized stock rows: [{warehouse_id, material_id, quantity}].
ERP returns nested objects (warehouse: {...}, material: {...}) plus a
numeric `quantity_number` — flatten to the three fields PM needs.
"""
rows: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/inventory?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
for r in elements:
warehouse = r.get("warehouse") or {}
material = r.get("material") or {}
qty = r.get("quantity_number")
if qty is None:
qty = r.get("quantity")
rows.append(
{
"warehouse_id": warehouse.get("id"),
"material_id": material.get("id"),
"quantity": float(qty or 0),
}
)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(rows)):
break
page += 1
return rows
erp_client = ERPClient()
+79
View File
@@ -0,0 +1,79 @@
"""PM — Project Management operational layer. FastAPI entrypoint."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import select
from app.config import settings
from app.db import SessionFactory, engine
from app.dev_accounts import ensure_demo_accounts, ensure_demo_assignments
from app.errors import install_error_handlers
from app.models import Base, PMUser
from app.routers import auth, dashboard, files, projects, qc, tasks, users
from app.security import hash_password
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
async def _ensure_admin() -> None:
"""Seed the local admin user (role: project_manager) from env on first boot."""
async with SessionFactory() as db:
existing = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if existing is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role="project_manager",
)
)
await db.commit()
logging.getLogger("pm.boot").info("seeded admin user %s", settings.ADMIN_USER)
# dev role switcher: demo accounts for the dashboard picker
await ensure_demo_accounts(db)
@asynccontextmanager
async def lifespan(_: FastAPI):
if settings.DEV_ROLE_SWITCH:
logging.getLogger("pm.boot").warning(
"PM_DEV_ROLE_SWITCH is ON — /auth/dev-login issues tokens without a password. "
"Set PM_DEV_ROLE_SWITCH=false for production."
)
async with engine.begin() as conn:
from app.schema_sync import drop_legacy, ensure_schema
await drop_legacy(conn)
await conn.run_sync(Base.metadata.create_all)
await ensure_schema(conn)
await _ensure_admin()
asyncio.create_task(_init_demo_assignments())
yield
async def _init_demo_assignments() -> None:
await asyncio.sleep(1)
try:
async with SessionFactory() as db:
await ensure_demo_assignments(db)
except Exception: # noqa: BLE001
logging.getLogger("pm.boot").exception("demo assignment failed")
app = FastAPI(title="PM — Construction Project Management Platform", version="1.0.0", lifespan=lifespan)
API_V1_PREFIX = "/api/v1"
for module in (auth, users, dashboard, projects, tasks, qc, files):
app.include_router(module.router, prefix=API_V1_PREFIX)
@app.get("/health")
async def health():
return {"status": "ok", "service": "pm-api"}
install_error_handlers(app)
+446
View File
@@ -0,0 +1,446 @@
"""PM data model — Native Construction Project Management Platform.
- Project and Task entities are owned directly by PM with hierarchical WBS,
CPM scheduling, dependencies, and milestones.
- QC domain (templates, definitions, inspections, results, defects) is PM-owned.
- Material requirements and reservations integrate directly with ERP stock.
"""
import enum
import uuid
from datetime import date, datetime
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
def _uuid() -> uuid.UUID:
return uuid.uuid4()
# --- Native PM Platform Models -----------------------------------------------
class ProjectStatus(str, enum.Enum):
PLANNED = "PLANNED"
ACTIVE = "ACTIVE"
ON_HOLD = "ON_HOLD"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskStatus(str, enum.Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
BLOCKED = "BLOCKED"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class TaskRelationType(str, enum.Enum):
FS = "FS" # Finish-to-Start (پایان به شروع)
SS = "SS" # Start-to-Start (شروع به شروع)
FF = "FF" # Finish-to-Finish (پایان به پایان)
SF = "SF" # Start-to-Finish (شروع به پایان)
class Project(Base):
"""Native Construction Project entity owned directly by PM."""
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255), index=True)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(String(255), default="")
project_manager: Mapped[str] = mapped_column(String(255), default="")
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
default=ProjectStatus.ACTIVE.value,
server_default=ProjectStatus.ACTIVE.value,
index=True,
)
progress: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["ProjectMember"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
tasks: Mapped[list["Task"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
relations: Mapped[list["TaskRelation"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
class ProjectMember(Base):
"""Team members assigned to a project with designated roles."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(32), default="site_engineer")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="members")
user: Mapped["PMUser"] = relationship(lazy="selectin")
class Task(Base):
"""Hierarchical Construction Task / Activity entity owned directly by PM.
Supports arbitrary depth: Project -> Phase -> Activity -> Subtask through self-referencing `parent_id`.
"""
__tablename__ = "tasks"
__table_args__ = (
Index("ix_tasks_project", "project_id"),
Index("ix_tasks_parent", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(
String(32),
default=TaskStatus.TODO.value,
server_default=TaskStatus.TODO.value,
index=True,
)
priority: Mapped[str] = mapped_column(
String(32),
default=TaskPriority.MEDIUM.value,
server_default=TaskPriority.MEDIUM.value,
)
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="SET NULL"), nullable=True
)
assignee_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
duration: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
qc_status: Mapped[str] = mapped_column(
String(32),
default="none",
server_default="none",
comment="none|pre_qc_pending|pre_qc_failed|ready|exec_qc_pending|in_progress|qc_failed_rework|final_passed",
)
human_resources: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
materials: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
project: Mapped["Project"] = relationship(back_populates="tasks", lazy="selectin")
parent: Mapped["Task | None"] = relationship(
back_populates="children", remote_side=[id], lazy="selectin"
)
children: Mapped[list["Task"]] = relationship(
back_populates="parent", cascade="all, delete-orphan", lazy="selectin"
)
assignee: Mapped["PMUser | None"] = relationship(lazy="selectin")
outgoing_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.from_task_id]",
back_populates="from_task",
cascade="all, delete-orphan",
)
incoming_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.to_task_id]",
back_populates="to_task",
cascade="all, delete-orphan",
)
progress_logs: Mapped[list["TaskProgressLog"]] = relationship(
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
)
class TaskProgressLog(Base):
"""ثبت گزارش پیشرفت مقطعی/روزانه وظیفه به همراه مصرف واقعی نیروی انسانی و مصالح."""
__tablename__ = "task_progress_logs"
__table_args__ = (
Index("ix_task_progress_logs_task", "task_id"),
Index("ix_task_progress_logs_created", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
progress_before: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
progress_after: Mapped[int] = mapped_column(Integer)
human_resources_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
materials_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
description: Mapped[str] = mapped_column(Text, default="", server_default="")
logged_by: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
task: Mapped["Task"] = relationship(back_populates="progress_logs")
class TaskRelation(Base):
"""Dependency / precedence between two tasks in a project (FS, SS, FF, SF + lag)."""
__tablename__ = "task_relations"
__table_args__ = (
UniqueConstraint("from_task_id", "to_task_id", name="uq_task_relations_pair"),
Index("ix_task_relations_project", "project_id"),
Index("ix_task_relations_from", "from_task_id"),
Index("ix_task_relations_to", "to_task_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
from_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
to_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
relation_type: Mapped[str] = mapped_column(
String(8), default=TaskRelationType.FS.value, server_default=TaskRelationType.FS.value
)
lag_days: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="relations")
from_task: Mapped["Task"] = relationship(
foreign_keys=[from_task_id], back_populates="outgoing_relations"
)
to_task: Mapped["Task"] = relationship(
foreign_keys=[to_task_id], back_populates="incoming_relations"
)
# --- PM-owned QC domain ------------------------------------------------------
class QCQuestionPhase(str, enum.Enum):
"""مرحله‌ای که یک سوال QC به آن تعلق دارد.
pre = پیش از اجرا (آمادگی شروع کار)
during = هنگام اجرا
after = پس از اجرا
"""
pre = "pre"
during = "during"
after = "after"
class QCAnswer(str, enum.Enum):
pass_ = "pass"
fail = "fail"
na = "na"
class QCTemplate(Base):
__tablename__ = "qc_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255))
activity_type: Mapped[str] = mapped_column(String(64), default="", index=True)
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
questions: Mapped[list["QCQuestion"]] = relationship(
back_populates="template", cascade="all, delete-orphan", order_by="QCQuestion.sort_order"
)
class QCQuestion(Base):
__tablename__ = "qc_questions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("qc_templates.id", ondelete="CASCADE"),
index=True,
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
template: Mapped[QCTemplate] = relationship(back_populates="questions")
class TaskQCDefinition(Base):
__tablename__ = "task_qc_definitions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255), default="")
# provenance only — deleting/editing the template never affects this copy
source_template_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
questions: Mapped[list["TaskQCQuestion"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="TaskQCQuestion.sort_order",
)
class TaskQCQuestion(Base):
__tablename__ = "task_qc_questions"
__table_args__ = (Index("ix_task_qc_q_def", "definition_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
definition_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_definitions.id", ondelete="CASCADE")
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
definition: Mapped[TaskQCDefinition] = relationship(back_populates="questions")
results: Mapped[list["TaskQCResult"]] = relationship(
back_populates="question", cascade="all, delete-orphan", order_by="TaskQCResult.evaluated_at"
)
class TaskQCResult(Base):
"""Evaluation of ONE task-specific question — append-only history.
The current answer is the latest row for the question; keeping history is
what makes rework → re-inspection → pass traceable.
"""
__tablename__ = "task_qc_results"
__table_args__ = (Index("ix_task_qc_res_q", "question_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
question_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="CASCADE")
)
answer: Mapped[QCAnswer] = mapped_column(Enum(QCAnswer, name="qc_answer"))
comment: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
evaluated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
evaluated_by: Mapped[str] = mapped_column(String(255), default="")
question: Mapped[TaskQCQuestion] = relationship(back_populates="results")
class Defect(Base):
"""عیب ثبت‌شده روی یک تسک (اختیاری متصل به سؤال QC مردود)."""
__tablename__ = "defects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
question_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
severity: Mapped[str] = mapped_column(String(16), default="medium", server_default="medium")
status: Mapped[str] = mapped_column(String(16), default="open", server_default="open")
corrective_action: Mapped[str] = mapped_column(Text, default="")
resolution_notes: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Auth & access control ------------------------------------------------------
class PMUser(Base):
__tablename__ = "pm_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(255), default="")
# Values live in app/authz.py (Role); stored as text so new roles need no
# Postgres enum migration.
role: Mapped[str] = mapped_column(
String(32),
default="project_manager",
server_default="project_manager",
comment="project_manager|project_supervisor",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectAssignment(Base):
"""Projects a scoped role may access (e.g. a supervisor's assigned projects).
Unscoped roles (project_manager) see every project and need no rows here;
scoped roles are limited to exactly the rows present for them. Source of
truth for the scoping done in app/authz.py.
"""
__tablename__ = "project_assignments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

Some files were not shown because too many files have changed in this diff Show More