5 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
14 changed files with 889 additions and 656 deletions
+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` | | **postgres** | `procurement-postgres` | PostgreSQL 18 (دیتابیس مشترک `procurement` + `casdoor`) | `5455:5432` |
| **minio** | `procurement-minio` | MinIO object storage (S3-compatible) | `9000` (API), `9001` (console) | | **minio** | `procurement-minio` | MinIO Object Storage (S3-compatible) برای فایل‌ها و پیش‌فاکتورها | `9000` (API), `9001` (کنسول) |
| **casdoor** | `procurement-casdoor` | Casdoor (OIDC identity provider) | `8080` | | **casdoor** | `procurement-casdoor` | ارائه‌دهنده متمرکز هویت سازمانی (OIDC Provider) | `8080:8000` |
| **api** | `procurement-api` | FastAPI + SQLAlchemy (asyncpg) | `8000` | | **api** | `procurement-api` | بک‌اند اصلی تدارکات و انبار (FastAPI + Asyncpg) | `8000:8000` |
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik | | **frontend** | `procurement-frontend` | فرانت‌اند وب ERP (React 19 + Vite + Tailwind) | سرو از طریق Traefik روی پورت `80` |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) | | **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) |
Browser ──► Traefik :80 (edge)
│
├── Host(localhost) ──► frontend (nginx SPA :80)
│ │
│ └── /api/* proxy ──► api :8000 (/api/v1)
│
├── Host(api.localhost) ──► api :8000
├── Host(auth.localhost) ──► casdoor :8000 (OIDC login)
└── Host(s3/storage.localhost) ──► minio (API / console)
│
├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files)
```
--- ---
## 📁 Layout ## 🌐 معماری شبکه و هدایت ترافیک (Traefik Edge Routing)
```text ```text
v1.3/ ┌────────────────────────┐
├── docker-compose.yml ← all paths are relative to this folder │ Browser / Client │
├── docker-compose.prod.yml ← production overlay (real domains + HTTPS via Let's Encrypt) └───────────┬────────────┘
├── start.ps1 ← PowerShell helper (build + start + status) │ :80 / :443
├── mc.cmd ← MinIO Client helper (inspect the bucket) ▼
├── README.md ┌────────────────────────┐
│ 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/ ├── sql/
│ ├── migration.sql ← DB schema v2.6 (auto-runs on first boot) │ ├── migration.sql ← اسکریپت اولیه پایه دیتابیس
│ └── 02-casdoor-db.sql ← creates the `casdoor` role+DB on the same instance │ └── 02-casdoor-db.sql ← اسکریپت راه‌اندازی دیتابیس هویت Casdoor
├── infra/ └── tests/ ← تست‌های یکپارچگی NLU و ویزارد استعلام
│ └── traefik-dynamic.yml ← Traefik shared middlewares (HTTP→HTTPS redirect)
├── docs/ ← ERDs + migration plan + auth guide
│ ├── erd.txt (v2.4 — old procurement schema)
│ ├── new erd.txt (v2.5 — material → supplier → category)
│ ├── NewERD.txt (v2.6 — integrated procurement + inventory)
│ ├── casdoor-auth.md (v2.6 — Casdoor login, roles, failure scenarios)
│ └── material-supplier-category-migration-plan.md
├── backend/ ← build context of the `api` service
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── .dockerignore
│ ├── app/ (source code — procurement + inventory + auth)
│ └── tools/ (smoke-test + seed scripts, copied into the image)
└── frontend/ ← build context of the `frontend` service
├── Dockerfile
├── nginx.conf
├── .dockerignore
├── .env.example (copy to .env if you need a custom API base URL)
├── index.html
├── package.json
├── package-lock.json
├── tsconfig*.json
├── vite.config.ts
├── public/
└── src/
└── pages/
├── Dashboard/ (procurement office)
├── Wizard/ (add new offer)
├── Suppliers/ (suppliers & topics)
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
``` ```
--- ---
## 🔐 Login ## 🔐 سیستم احراز هویت، ماتریس پنل‌ها و کنترل دسترسی (RBAC)
The whole system is behind a login page (`/login`). Login is **in-page**: سامانه از سیستم احراز هویت **Casdoor OIDC** بهره می‌برد؛ ورود به صورت In-Page بوده و توکن JWT صادر شده توسط بک‌اند با JWKS اعتبارسنجی می‌شود.
the form (username / password) is sent to
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor
(password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor.
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record مدل دسترسی سامانه بر پایه **پنل‌ها (`user_panels`)** بازطراحی شده است:
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only.
**Developer Mode** (top-left button on the login page) is a 3-step flow for ### ۱. لایه‌های دسترسی پنل‌ها
the admin (super user): ① superuser auth gate (admin credentials only) → * **پنل تدارکات (`procurement`):**
② role switcher (ادمین / مدیر تدارکات / مدیر پروژه + project) → ③ session * `full`: دسترسی کامل به کارتابل استعلام‌ها، تایید یا رد پیشنهادها، تامین‌کنندگان و قیمت‌ها.
activation by role (admin → **پنل مدیریت ادمین** at `/admin`, مدیر تدارکات → * `limited`: فقط امکان ثبت استعلام جدید در ویزارد برای پروژه‌های منتسب به کاربر (کاربر پس از ورود مستقیماً به `/wizard` یا `/requester` هدایت می‌شود).
procurement office, مدیر پروژه → their project's inventory panel). It is * **پنل انبارداری (`inventory`):**
controlled by the `VITE_ENABLE_DEV_LOGIN` env var (in Docker: the * `full`: مشاهده موجودی همه انبارها، رهگیری «کالاهای در راه» و کارتابل تایید رسید.
`DEV_LOGIN_ENABLED` compose build arg, default `true` — set it to `false` * `limited`: فقط مشاهده و مدیریت انبارهای پروژه‌های تحت عضویت، همراه با دسترسی به ثبت تغییر موجودی کارگاهی (`/inventory/change`).
for production builds). * **پنل مدیریت پروژه (`pm`):**
* دسترسی به مشاهده یا ویرایش تسک‌ها، چارت گانت، ثبت عیوب و فرم‌های QC در `/panel/pm`.
| What | Where | Credentials | ### ۲. قوانین و نقش سرپرست پروژه (`supervisor`)
| --- | --- | --- | * کاربرانی با نقش `supervisor` تنها در صورتی مجاز به ورود به سامانه هستند که حداقل یک پروژه در جدول `user_project_members` یا `projects.pm_user_id` به آن‌ها منتسب شده باشد. در غیر این صورت، پیام عدم تخصیص پروژه نمایش داده شده و اجازه ورود نخواهند داشت.
| 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).
--- ---
## 🌐 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 ```powershell
cd <this-folder> # ۱. رفتن به پوشه پروژه و ساخت/اجرای کانتینرها
docker compose up -d --build docker compose up -d --build
``` # ۲. بررسی وضعیت سلامت سرویس‌ها
First run downloads base images and builds the frontend (takes a few minutes).
Subsequent runs: `docker compose up -d`.
Wait until all services are up:
```powershell
docker compose ps 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 | ربات پیام‌رسان بله به عنوان یک سرویس اختصاصی درون شبکه فعال است:
| --- | --- | 1. پیام صوتی یا متنی فرستاده شده از طریق هوش مصنوعی (DeepSeek NLU) و تبدیل صوت به متن پردازش می‌شود.
| Frontend UI | http://localhost | 2. قلم‌ها، متریال‌ها، پروژه‌ها و تامین‌کنندگان به صورت فازی جستجو و شناسایی می‌شوند.
| API docs (Swagger) | http://localhost:8000/docs | 3. پیش‌نویس در جدول `bot_drafts` ذخیره شده و شناسه پیش‌نویس تولید می‌شود.
| API health | http://localhost:8000/health → `{"status":"ok","service":"procurement-api"}` | 4. کاربر می‌تواند با زدن دکمه روی بله یا مراجعه به لینک مستقیم:
| API readiness (DB + MinIO + Casdoor) | http://localhost:8000/health/ready | `http://localhost/wizard?draft=<UUID>`
| Metrics | http://localhost:8000/metrics | اطلاعات استخراج‌شده را در ویزارد فرانت‌اند مرور، تکمیل و نهایی کند.
| MinIO console | http://localhost:9001 (user/pass `minioadmin`/`minioadmin`) | 5. راهنمای تکمیلی تنظیم توکن در [bot/docs/SETUP.md](file:///c:/Users/Reza/Desktop/v1.6/bot/docs/SETUP.md) قرار دارد.
| 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).
--- ---
## MinIO (object storage for invoice / proforma files) ## 🔄 انتقال بین‌کارگاهی کالاها (`project_transfers`)
| Item | Value | سامانه دارای سیستم ردیابی انتقال متریال بین کارگاه‌هاست:
| --- | --- | * ثبت انتقال از طریق صفحه «تغییر موجودی» با نوع `انتقال بین کارگاهی`.
| S3 API endpoint | `localhost:9000` (from the API container: `minio:9000`) | * وضعیت‌های انتقال: `در حال انتقال (in_transit)`، `تحویل‌شده (received)` و `لغوشده (cancelled)`.
| 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
```
--- ---
## Database credentials / connection ## 🛠️ عملیات نگهداری و دیپلوی در سرور (Operations)
```text * **دیپلوی خودکار:** سرور با هر Push به برنچ `production` از طریق وب‌هوک اختصاصی به صورت خودکار بیلد و بالا می‌آید. راهنمای کامل: [docs/OPERATIONS.md](file:///c:/Users/Reza/Desktop/v1.6/docs/OPERATIONS.md).
Host: localhost * **پشتیبان‌گیری پایگاه داده:** سرویس `db-backup` به صورت روزانه از هر دو پایگاه داده `procurement` و `casdoor` فایل‌های `.dump` تولید و در پوشه `backups/` نگهداری می‌کند.
Port: 5433 * **مانیتورینگ و مشاهده‌پذیری:** ابزارهای Prometheus، لاگ‌های Loki و ردیابی خطاهای GlitchTip در پوشه [monitoring/](file:///c:/Users/Reza/Desktop/v1.6/monitoring/README.md) سازمان‌دهی شده‌اند.
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`.
```
+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.""" volume — the RBAC model expects these and login 500s without them."""
stmts = ( stmts = (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT", "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 location TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager 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", "ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
+5 -1
View File
@@ -217,6 +217,7 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"performed_by": tx.performed_by, "performed_by": tx.performed_by,
"delivered_to": tx.delivered_to, "delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date), "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, "note": tx.note,
} }
@@ -461,7 +462,10 @@ async def list_transactions(
InventoryTransaction.transaction_date InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1) < datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
) )
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date") 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) items, meta = await paginate(stmt, page, page_size, db)
# Enrich: warehouse name + material/brand/specs/unit for the UI rows. # Enrich: warehouse name + material/brand/specs/unit for the UI rows.
+11 -5
View File
@@ -22,6 +22,7 @@ SHA256 of the sorted canonical specs JSON (or 'default' when empty).
import hashlib import hashlib
import json import json
import uuid import uuid
from datetime import datetime, timezone, timedelta
from decimal import Decimal from decimal import Decimal
from sqlalchemy import select from sqlalchemy import select
@@ -175,6 +176,11 @@ async def apply_transaction(
t = payload.transaction_type t = payload.transaction_type
specs = payload.specs or {} specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs) 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. # STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT": if t == "STOCK_COUNT":
@@ -197,7 +203,7 @@ async def apply_transaction(
recorded_qty=recorded, recorded_qty=recorded,
physical_qty=payload.physical_qty, physical_qty=payload.physical_qty,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
@@ -238,7 +244,7 @@ async def apply_transaction(
quantity=-quantity, quantity=-quantity,
transfer_pair_id=pair_id, transfer_pair_id=pair_id,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(out_tx) db.add(out_tx)
@@ -256,7 +262,7 @@ async def apply_transaction(
quantity=quantity, quantity=quantity,
transfer_pair_id=pair_id, transfer_pair_id=pair_id,
performed_by=performed_by, performed_by=performed_by,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(in_tx) db.add(in_tx)
@@ -291,7 +297,7 @@ async def apply_transaction(
quantity=quantity, quantity=quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to, delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
@@ -321,7 +327,7 @@ async def apply_transaction(
quantity=-quantity, quantity=-quantity,
performed_by=performed_by, performed_by=performed_by,
delivered_to=payload.delivered_to, delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date, transaction_date=tx_date,
note=payload.note, note=payload.note,
) )
db.add(tx) db.add(tx)
+55 -105
View File
@@ -1,120 +1,70 @@
# procurement_ai_agent — Bale chatbot for the procurement ERP # procurement_ai_agent — ایجنت هوشمند بله برای سامانه ERP
A **Bale messenger bot** that turns Persian **chat messages and voice notes** بات پیام‌رسان بله (Bale Messenger Bot) که پیام‌های صوتی و متنی فارسی کاربران را دریافت کرده و با استفاده از مدل‌های هوش مصنوعی (DeepSeek NLU + Whisper STT) آن‌ها را به اقدامات و پیش‌نویس‌های استعلام در سامانه ERP تبدیل می‌کند.
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 │ ## 🏗️ وضعیت استقرار در استک ERP
▼ │ ffmpeg → STT (Persian) │
┌──────────────────┐ POST {base}/bot<T> │ LLM → intent + entities │ این ایجنت **به صورت سرویس بومی (`procurement-bot`) مستقیماً درون `docker-compose.yml` ریشه پروژه** تعریف شده است و نیازی به نصب یا راه‌اندازی دستی ندارد.
│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │
└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│ * **نوع اتصال:** Long-polling از سرور بله (`tapi.bale.ai`) — بدون نیاز به باز کردن پورت خارجی، وب‌هوک یا TLS.
└───────────────┬─────────────────┘ * **ارتباط با بک‌اند:** به صورت مستقیم و درون‌شبکه‌ای با سرویس `api` در آدرس `http://api:8000/api/v1`.
│ HTTP + X-Actor-Id * **پایگاه داده:** استفاده از جدول `bot_drafts` در پایگاه‌داده مشترک PostgreSQL.
▼
┌─────────────────────────────────┐ ```text
│ ERP api (/api/v1) │ کاربر (بله: متن یا صوت)
│ quotes · suppliers · materials│ │ getUpdates (Long-polling)
└─────────────────────────────────┘ ▼
┌─────────────────────────────────┐
│ procurement_ai_agent │
│ ffmpeg → STT تبدیل صوت به متن │
│ LLM → استخراج نیت و موجودیت‌ها │
│ Fuzzy Match → انطباق با ERP │
│ ایجاد پیش‌نویس در bot_drafts │
└────────────────┬────────────────┘
│
├── ثبت نهایی از بله با دکمه تایید
└── یا باز کردن مستقیم در ویزارد وب:
/wizard?draft=<UUID>
``` ```
## What it can do ---
| Activity | Example (voice or text) | Committed via | ## 🌟 قابلیت‌ها و کاربردها
| فعالیت | نمونه پیام (صوت یا متن) | اقدام نهایی |
|---|---|---| |---|---|---|
| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` | | **ثبت استعلام جدید** | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` |
| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` | | **تعریف تأمین‌کننده** | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` |
| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` | | **تعریف متریال جدید** | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` |
| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` | | **استعلام وضعیت** | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes` |
| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` | | **پذیرش استعلام** | «استعلام سازان نو را قبول کن» | `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 ## 🔗 حلقه پیش‌نویس و تایید (Draft-First Loop)
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 1. کاربر پیام صوتی یا متنی می‌فرستد. صوت به متن تبدیل شده و مدل هوش مصنوعی موجودیت‌ها را استخراج می‌کند.
2. اسامی تامین‌کننده، متریال، پروژه و واحدها با کاتالوگ ERP تطبیق داده می‌شوند.
3. کارت مرور پیش‌نویس همراه با فیلدهای مشخص‌شده و اقلام خالی («چه چیزهایی ناقص است») برای کاربر ارسال می‌شود.
4. **دو روش برای تکمیل و ثبت نهایی وجود دارد:**
* **از طریق چت بله:** با ارسال پیام‌های اصلاحی و در نهایت لمس دکمه «✅ ثبت نهایی».
* **از طریق پنل وب ویزارد:** با کلیک روی لینک مستقیم `/wizard?draft=<UUID>` که اطلاعات به صورت خودکار در فرم ۵ مرحله‌ای ویزارد بارگذاری می‌شود و کاربر می‌تواند فایل فاکتور را نیز ضمیمه کند.
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) تنظیمات بات از طریق فایل `.env` در ریشه پروژه خوانده می‌شود:
```bash ```env
cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY BALE_BOT_TOKEN=توکن_دریافتی_از_بات_فادر_بله
docker build -t procurement-ai-agent . BALE_API_BASE=https://tapi.bale.ai
docker run -d --name procurement-bot --env-file .env \ AI_BASE_URL=https://api.deepseek.com
--add-host host.docker.internal:host-gateway \ AI_API_KEY=کلید_سرویس_هوش_مصنوعی
procurement-ai-agent AI_LLM_MODEL=deepseek-flash
docker logs -f procurement-bot BALE_ADMIN_CHAT_IDS=شناسه_عددی_مدیران_در_بله
``` ```
Or as part of the ERP stack — see [`integration/`](integration/README.md) راهنمای گام‌به‌گام اتصال و عیب‌یابی در [docs/SETUP.md](docs/SETUP.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
```
+166 -52
View File
@@ -21,6 +21,7 @@ export interface JalaliDatePickerProps {
className?: string; className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean; disabled?: boolean;
maxDate?: Date | string; // حداکثر تاریخ مجاز (جلوگیری از انتخاب تاریخ/زمان آینده)
} }
function todayJalali(): [number, number, number] { function todayJalali(): [number, number, number] {
@@ -76,6 +77,7 @@ export function JalaliDatePicker({
className, className,
withTime = false, withTime = false,
disabled = false, disabled = false,
maxDate,
}: JalaliDatePickerProps) { }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); 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 [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).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>(() => { const [text, setText] = useState<string>(() => {
if (!value) return ""; if (!value) return "";
const j = isoToJalaliStr(value); const j = isoToJalaliStr(value);
@@ -145,23 +203,30 @@ export function JalaliDatePicker({
const dim = daysInMonth(jy, jm); const dim = daysInMonth(jy, jm);
const today = new Date(); const today = new Date();
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; 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 }[] = []; const cells: { d: number; iso: string; isToday: boolean; disabled: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false }); for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false, disabled: false });
for (let d = 1; d <= dim; d++) { for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d); const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; 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; return cells;
}, [curJ]); }, [curJ, maxDateIso]);
const nav = (dir: string) => { const nav = (dir: string) => {
setCurJ(([jy, jm]) => { setCurJ(([jy, jm]) => {
let ny = jy; let ny = jy;
let nm = jm; 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 === "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 (dir === "y+1") ny -= 1;
if (nm < 1) { if (nm < 1) {
nm = 12; nm = 12;
@@ -171,15 +236,27 @@ export function JalaliDatePicker({
nm = 1; nm = 1;
ny += 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]; return [ny, nm, 1];
}); });
}; };
const selectDay = (jy: number, jm: number, jd: number) => { 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 currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: validM } = getClampedTime(currentH, currentM, jy, jm, jd);
const validM = Math.min(59, Math.max(0, currentM)); 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); const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
onChange(val); onChange(val);
setText( setText(
@@ -200,30 +277,28 @@ export function JalaliDatePicker({
return; return;
} }
const n = parseInt(digits, 10); 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 [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM)); const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
onChange(buildIso(jy, jm, jd, clamped, validM)); const textToShow = digits.length === 2 && n > 23 ? String(clampedH) : digits;
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); setHourText(faNum(textToShow));
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).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 handleHourBlur = () => { const handleHourBlur = () => {
const digits = toAsciiDigits(hourText).replace(/\D/g, ""); const digits = toAsciiDigits(hourText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0; 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 [jy, jm, jd] = curJ;
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
const validM = Math.min(59, Math.max(0, currentM)); const { h: clampedH, m: validM } = getClampedTime(n, currentM, jy, jm, jd);
onChange(buildIso(jy, jm, jd, clamped, validM)); setHourText(faNum(String(clampedH).padStart(2, "0")));
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"))}`); 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) => { const handleMinuteChange = (raw: string) => {
@@ -238,37 +313,46 @@ export function JalaliDatePicker({
return; return;
} }
const n = parseInt(digits, 10); 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 [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
onChange(buildIso(jy, jm, jd, validH, clamped)); const textToShow = digits.length === 2 && n > 59 ? String(clampedM) : digits;
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); setMinuteText(faNum(textToShow));
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).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 handleMinuteBlur = () => { const handleMinuteBlur = () => {
const digits = toAsciiDigits(minuteText).replace(/\D/g, ""); const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
const n = digits ? parseInt(digits, 10) : 0; 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 [jy, jm, jd] = curJ;
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
const validH = Math.min(23, Math.max(0, currentH)); const { h: validH, m: clampedM } = getClampedTime(currentH, n, jy, jm, jd);
onChange(buildIso(jy, jm, jd, validH, clamped)); setMinuteText(faNum(String(clampedM).padStart(2, "0")));
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"))}`); 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 pickToday = () => {
const now = new Date(); const now = new Date();
const [jy, jm, jd] = todayJalali(); const [jy, jm, jd] = todayJalali();
const nh = now.getHours(); let nh = now.getHours();
const nm = now.getMinutes(); 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"))); setHourText(faNum(String(nh).padStart(2, "0")));
setMinuteText(faNum(String(nm).padStart(2, "0"))); setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime const val = withTime
@@ -300,20 +384,23 @@ export function JalaliDatePicker({
} }
const iso = jalaliStrToIso(v); const iso = jalaliStrToIso(v);
if (iso) { if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) { if (!withTime) {
const nums = String(v) const nums = String(v)
.split(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10)); .map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); 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 { } else {
onChange(iso.slice(0, 10)); onChange(validIso.slice(0, 10));
} }
} else { } else {
onChange(iso); onChange(validIso);
} }
const j = isoToJalali(iso); const j = isoToJalali(validIso);
if (j) setCurJ([j[0], j[1], j[2]]); if (j) setCurJ([j[0], j[1], j[2]]);
} }
}; };
@@ -325,27 +412,32 @@ export function JalaliDatePicker({
} }
const iso = jalaliStrToIso(text); const iso = jalaliStrToIso(text);
if (iso) { if (iso) {
const validIso = clampIfFuture(iso) || iso;
if (!withTime) { if (!withTime) {
const nums = String(text) const nums = String(text)
.split(/[/\-.]/) .split(/[/\-.]/)
.map((x) => parseInt(toAsciiDigits(x), 10)); .map((x) => parseInt(toAsciiDigits(x), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); 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); onChange(dOnly);
setText(isoToJalaliStr(dOnly)); setText(isoToJalaliStr(dOnly));
} else { } else {
onChange(iso.slice(0, 10)); const dOnly = validIso.slice(0, 10);
setText(isoToJalaliStr(iso)); onChange(dOnly);
setText(isoToJalaliStr(dOnly));
} }
} else { } else {
onChange(iso); onChange(validIso);
const j = isoToJalaliStr(iso); const j = isoToJalaliStr(validIso);
const d = new Date(iso); const d = new Date(validIso);
if (!Number.isNaN(d.getTime())) { if (!Number.isNaN(d.getTime())) {
const hh = String(d.getHours()).padStart(2, "0"); const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`); setText(`${j} ${faNum(hh + ":" + mm)}`);
setHourText(faNum(hh));
setMinuteText(faNum(mm));
} }
} }
} else if (value) { } else if (value) {
@@ -397,10 +489,22 @@ export function JalaliDatePicker({
<span className="text-[13px] font-bold"> <span className="text-[13px] font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])} {MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span> </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>
<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> </button>
</div> </div>
@@ -415,6 +519,16 @@ export function JalaliDatePicker({
{grid.map((cell, i) => {grid.map((cell, i) =>
cell.d === 0 ? ( cell.d === 0 ? (
<div key={i} /> <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 <button
key={i} key={i}
+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"))}`; 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: "۱۸ مرداد ۱۴۰۴" // format with month name: "۱۸ مرداد ۱۴۰۴"
export function isoToJalaliLong(iso: string | null | undefined): string { export function isoToJalaliLong(iso: string | null | undefined): string {
if (!iso) return ""; if (!iso) return "";
+1
View File
@@ -172,6 +172,7 @@ export interface InventoryTransaction {
performed_by?: string | null; performed_by?: string | null;
delivered_to?: string | null; delivered_to?: string | null;
transaction_date: string; transaction_date: string;
created_at?: string;
note?: string | null; note?: string | null;
material?: { id: string; name: string } | null; material?: { id: string; name: string } | null;
brand?: { id: string; name: string } | null; brand?: { id: string; name: string } | null;
@@ -81,7 +81,35 @@ export function ChangeInventoryPage() {
const [matTxt, setMatTxt] = useState(""); const [matTxt, setMatTxt] = useState("");
const [brandTxt, setBrandTxt] = useState(""); const [brandTxt, setBrandTxt] = useState("");
const [qty, setQty] = useState(""); const [qty, setQty] = useState("");
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString()); const [sectionDates, setSectionDates] = useState<Record<FormType, string>>(() => {
const now = new Date().toISOString();
return {
PURCHASE: now,
WITHDRAWAL: now,
PROJECT_TRANSFER: now,
RETURN: now,
TRANSFER: now,
TRANSFER_IN: now,
TRANSFER_OUT: now,
DAMAGE: now,
WASTE: now,
LOSS: now,
FOUND: now,
STOCK_COUNT: now,
};
});
const setSectionDate = (t: FormType, val: string | null) => {
setSectionDates((prev) => ({ ...prev, [t]: val ?? "" }));
};
const isSectionDateValid = (t: FormType): boolean => {
const raw = sectionDates[t];
if (!raw || raw.trim() === "") return false;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return false;
return d.getTime() <= Date.now() + 60000;
};
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [deliveredTo, setDeliveredTo] = useState(""); const [deliveredTo, setDeliveredTo] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]); const [rows, setRows] = useState<RowDraft[]>([]);
@@ -171,10 +199,13 @@ export function ChangeInventoryPage() {
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null; const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null; const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
const toServerIso = () => { const toServerIso = (t: FormType) => {
try { try {
const d = new Date(dateInput); const raw = sectionDates[t];
if (!Number.isNaN(d.getTime())) return d.toISOString(); if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d.toISOString();
}
} catch { /* نادیده */ } } catch { /* نادیده */ }
return new Date().toISOString(); return new Date().toISOString();
}; };
@@ -433,13 +464,15 @@ export function ChangeInventoryPage() {
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: unitId, specs, note, quantity: n, unit_id: unitId, specs, note,
transaction_date: toServerIso(), transaction_date: toServerIso("PURCHASE"),
}; };
await postOne(op); await postOne(op);
toast.success("ثبت شد ✓"); toast.success("ثبت شد ✓");
notifyDataChanged("inventory"); notifyDataChanged("inventory");
const pIso = toServerIso("PURCHASE");
const pTime = new Date(pIso).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" });
setSessionLog((prev) => [ setSessionLog((prev) => [
{ material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: new Date().toTimeString().slice(0, 5), whName: whName(warehouses, whId) }, { material: matName, brand: brandName, specs, qty: n, unitLabel: unitLabel, time: pTime, whName: whName(warehouses, whId) },
...prev, ...prev,
]); ]);
// حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا // حفظ زمینه (انبار + تاریخ) و ریست فیلدهای محتوا
@@ -498,7 +531,7 @@ export function ChangeInventoryPage() {
unit_id: w.unit?.id ?? null, unit_id: w.unit?.id ?? null,
specs: w.specs || {}, specs: w.specs || {},
note: buildReturnNote(retNote[w.id], w.id), note: buildReturnNote(retNote[w.id], w.id),
transaction_date: new Date().toISOString(), transaction_date: toServerIso("RETURN"),
}); });
okCount++; okCount++;
setRetDone((prev) => new Set(prev).add(w.id)); setRetDone((prev) => new Set(prev).add(w.id));
@@ -550,7 +583,7 @@ export function ChangeInventoryPage() {
quantity: parseNum(ptQty[r.id]), quantity: parseNum(ptQty[r.id]),
specs: r.specs || {}, specs: r.specs || {},
note, note,
transfer_date: new Date().toISOString(), transfer_date: toServerIso("PROJECT_TRANSFER"),
}, user?.project_id); }, user?.project_id);
ok++; ok++;
setPtSucceeded((prev) => new Set(prev).add(r.id)); setPtSucceeded((prev) => new Set(prev).add(r.id));
@@ -586,7 +619,7 @@ export function ChangeInventoryPage() {
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
if (t === "STOCK_COUNT") { if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso("STOCK_COUNT") };
} }
return { return {
transaction_type: t, transaction_type: t,
@@ -598,7 +631,7 @@ export function ChangeInventoryPage() {
specs: rec.specs || {}, specs: rec.specs || {},
note: r.note, note: r.note,
delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined, delivered_to: t === "WITHDRAWAL" ? deliveredTo.trim() : undefined,
transaction_date: toServerIso(), transaction_date: toServerIso(t),
}; };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
@@ -613,7 +646,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null; if (!(n > 0)) return null;
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() }; return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso("TRANSFER") };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -629,7 +662,7 @@ export function ChangeInventoryPage() {
if (!item) return null; if (!item) return null;
if (n > item.remaining) return null; if (n > item.remaining) return null;
const t = item.tx; const t = item.tx;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date().toISOString() }; return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso("FOUND") };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است"); if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
@@ -652,7 +685,7 @@ export function ChangeInventoryPage() {
brand_id: rec.brand?.id || null, brand_id: rec.brand?.id || null,
physical_qty: n, physical_qty: n,
note: r.note, note: r.note,
transaction_date: toServerIso(), transaction_date: toServerIso("STOCK_COUNT"),
}); });
confirm.push({ confirm.push({
material: rec.material.name, material: rec.material.name,
@@ -686,6 +719,13 @@ export function ChangeInventoryPage() {
}; };
const doSubmit = () => { const doSubmit = () => {
const rawDate = sectionDates[type];
if (!rawDate || !rawDate.trim() || Number.isNaN(new Date(rawDate).getTime())) {
return toast.error(`ثبت تاریخ و زمان در بخش «${meta.label}» الزامی است.`);
}
if (new Date(rawDate).getTime() > Date.now() + 60000) {
return toast.error("امکان ثبت تراکنش در تاریخ یا زمان آینده وجود ندارد.");
}
try { try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch(); else if (type === "PROJECT_TRANSFER") void submitProjectTransferBatch();
@@ -700,6 +740,7 @@ export function ChangeInventoryPage() {
}; };
const canSubmit = (): boolean => { const canSubmit = (): boolean => {
if (!isSectionDateValid(type)) return false;
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0); return !!(whId && matTxt.trim() && brandTxt.trim() && unitId && parseNum(qty) > 0);
} }
@@ -945,7 +986,31 @@ export function ChangeInventoryPage() {
</div> </div>
{type === "WITHDRAWAL" && ( {type === "WITHDRAWAL" && (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-3">
<div className="field">
<label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span>
</label>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…"
value={deliveredTo}
onChange={(e) => setDeliveredTo(e.target.value)}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">
تاریخ و ساعت خروج کالا <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker
value={sectionDates.WITHDRAWAL}
onChange={(iso) => setSectionDate("WITHDRAWAL", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input <input
@@ -960,55 +1025,102 @@ export function ChangeInventoryPage() {
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} {data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist> </datalist>
</div> </div>
</div>
)}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS") && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted"> <label className="text-xs font-semibold text-muted">
تحویل به (تحویل‌گیرنده) <span className="text-rose-500 font-bold">*</span> {type === "DAMAGE" ? "تاریخ و ساعت وقوع خرابی" : type === "WASTE" ? "تاریخ و ساعت ثبت ضایعات" : "تاریخ و ساعت ثبت اتلاف"} <span className="text-rose-500 font-bold">*</span>
</label> </label>
<JalaliDatePicker
value={sectionDates[type]}
onChange={(iso) => setSectionDate(type, iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input <input
type="text" type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام فرد، اکیپ یا پیمانکار تحویل‌گیرنده اقلام…" placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={deliveredTo} value={matTxt}
onChange={(e) => setDeliveredTo(e.target.value)} onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/> />
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div> </div>
</div> </div>
)} )}
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && ( {type === "STOCK_COUNT" && (
<div className="field"> <div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انجام انبارگردانی / شمارش <span className="text-rose-500 font-bold">*</span>
list="dl-mat" </label>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" <JalaliDatePicker
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" value={sectionDates.STOCK_COUNT}
value={matTxt} onChange={(iso) => setSectionDate("STOCK_COUNT", iso)}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
<datalist id="dl-mat"> maxDate={new Date()}
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} />
</datalist> </div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div> </div>
)} )}
</div> </div>
)} )}
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */} {/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
{isTransfer && ( {isTransfer && (
<div className="field mb-3"> <div className="grid gap-3 sm:grid-cols-2 mb-3">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انتقال بین انبارها <span className="text-rose-500 font-bold">*</span>
list="dl-mat2" </label>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" <JalaliDatePicker
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…" value={sectionDates.TRANSFER}
value={matTxt} onChange={(iso) => setSectionDate("TRANSFER", iso)}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
<datalist id="dl-mat2"> maxDate={new Date()}
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)} />
</datalist> </div>
<div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
<input
type="text"
list="dl-mat2"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="فیلتر بر اساس نام متریال، برند یا مشخصه (خالی = نمایش همه)…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat2">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div> </div>
)} )}
@@ -1073,12 +1185,15 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label> <label className="text-xs font-semibold text-muted">
تاریخ و زمان (شمسی) <span className="text-rose-500 font-bold">*</span>
</label>
<JalaliDatePicker <JalaliDatePicker
value={dateInput} value={sectionDates.PURCHASE}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())} onChange={(iso) => setSectionDate("PURCHASE", iso)}
placeholder="۱۴۰۳/۰۵/۱۲" placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime withTime
maxDate={new Date()}
/> />
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
@@ -1097,18 +1212,32 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */} {/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */}
{type === "PROJECT_TRANSFER" && ( {type === "PROJECT_TRANSFER" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="field"> <div className="grid gap-3 sm:grid-cols-2">
<label className="text-xs font-semibold text-muted">یادداشت بار (اختیاری — برای همه اقلام)</label> <div className="field">
<input <label className="text-xs font-semibold text-muted">
type="text" تاریخ و ساعت انتقال بین کارگاهی <span className="text-rose-500 font-bold">*</span>
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm" </label>
placeholder="توضیحات بار ارسالی، شماره بارنامه، راننده…" <JalaliDatePicker
value={note} value={sectionDates.PROJECT_TRANSFER}
onChange={(e) => setNote(e.target.value)} onChange={(iso) => setSectionDate("PROJECT_TRANSFER", iso)}
/> placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">یادداشت بار (اختیاری — برای همه اقلام)</label>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="توضیحات بار ارسالی، شماره بارنامه، راننده…"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1135,19 +1264,33 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* ---------- برگشت: فیلتر بازه + لیست برداشت‌ها ---------- */} {/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشت‌ها ---------- */}
{type === "RETURN" && ( {type === "RETURN" && (
<div className="space-y-3"> <div className="space-y-3">
<div> <div className="grid gap-3 sm:grid-cols-2">
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز</label> <div className="field">
<ReturnFilterBar <label className="text-xs font-semibold text-muted">
preset={retPreset} تاریخ و ساعت ثبت برگشت به انبار <span className="text-rose-500 font-bold">*</span>
onPreset={(p) => setRetPreset(p)} </label>
customFrom={retCustomFrom} <JalaliDatePicker
customTo={retCustomTo} value={sectionDates.RETURN}
onCustomFrom={(iso) => setRetCustomFrom(iso)} onChange={(iso) => setSectionDate("RETURN", iso)}
onCustomTo={(iso) => setRetCustomTo(iso)} placeholder="۱۴۰۳/۰۵/۱۲ --:--"
/> withTime
maxDate={new Date()}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-muted">بازه‌ی روز برداشت‌ها (فیلتر جستجو)</label>
<ReturnFilterBar
preset={retPreset}
onPreset={(p) => setRetPreset(p)}
customFrom={retCustomFrom}
customTo={retCustomTo}
onCustomFrom={(iso) => setRetCustomFrom(iso)}
onCustomTo={(iso) => setRetCustomTo(iso)}
/>
</div>
</div> </div>
<div className="field"> <div className="field">
<label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label> <label className="text-xs font-semibold text-muted">جستجو در متریال‌ها (اختیاری)</label>
@@ -1208,14 +1351,28 @@ export function ChangeInventoryPage() {
)} )}
{type === "FOUND" && ( {type === "FOUND" && (
<FoundTable <div className="space-y-3">
items={pendingLosses} <div className="field max-w-sm">
rows={rows} <label className="text-xs font-semibold text-muted">
setRows={setRows} تاریخ و ساعت پیدا شدن کالا <span className="text-rose-500 font-bold">*</span>
unitNameFn={(id) => unitName(data.lookup.units, id)} </label>
emptyMsg={foundEmptyMsg} <JalaliDatePicker
loading={foundLoading && !pendingLosses.length} value={sectionDates.FOUND}
/> onChange={(iso) => setSectionDate("FOUND", iso)}
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
withTime
maxDate={new Date()}
/>
</div>
<FoundTable
items={pendingLosses}
rows={rows}
setRows={setRows}
unitNameFn={(id) => unitName(data.lookup.units, id)}
emptyMsg={foundEmptyMsg}
loading={foundLoading && !pendingLosses.length}
/>
</div>
)} )}
{/* دکمه ثبت — دسکتاپ */} {/* دکمه ثبت — دسکتاپ */}
@@ -1533,7 +1690,7 @@ export function ChangeInventoryPage() {
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود."
: type === "RETURN" : type === "RETURN"
? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند." ? "برگشت، دفتر را تغییر نمی‌دهد؛ تراکنش جدیدی ثبت می‌شود و مانده قابل برگشت هر برداشت به‌روز می‌ماند."
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`} : `تاریخ انتخابی: ${jalDateLabel(new Date(sectionDates[type] || new Date()))}`}
</div> </div>
</PageLayout> </PageLayout>
@@ -140,7 +140,7 @@ export function DailyControlPage() {
if (t.transaction_date > g.last) g.last = t.transaction_date; 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]); }, [withdrawals]);
const isToday = sel === todayKey(); const isToday = sel === todayKey();
+199 -25
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom"; 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 { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
@@ -10,8 +10,24 @@ import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData"; import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr, isoToJalaliDateTime, isoToTimeStr } from "../../lib/jalali";
import { cn } from "../../lib/utils"; 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 }> = { 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 canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams(); 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") || "", warehouse: params.get("warehouse") || "",
material: "", material: "",
project: "", project: "",
transactionType: "",
dateFrom: null,
dateTo: null,
}); });
const [sort, setSort] = useState<"asc" | "desc" | "">(""); const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false); const [showMobileFilters, setShowMobileFilters] = useState(false);
@@ -77,14 +103,40 @@ export function InventoryPage() {
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]); 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 groups = useMemo<Group[]>(() => {
const gmap = new Map<string, Group>(); const gmap = new Map<string, Group>();
const order: string[] = []; const order: string[] = [];
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر می‌شوند
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
inventory.forEach((r) => { inventory.forEach((r) => {
const whId = r.warehouse?.id; const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return; if (!whId || !visibleIds.has(whId)) return;
if (filters.warehouse && whId !== filters.warehouse) return; if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) 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)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk); let g = gmap.get(gk);
if (!g) { if (!g) {
@@ -104,7 +156,6 @@ export function InventoryPage() {
order.push(gk); order.push(gk);
} }
g.qty += r.quantity_number || 0; g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r); g.recs.push(r);
if (r.brand) { if (r.brand) {
if (!(r.brand.id in g.brands)) { if (!(r.brand.id in g.brands)) {
@@ -114,26 +165,61 @@ export function InventoryPage() {
g.brands[r.brand.id] += r.quantity_number || 0; 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)!); const out = order.map((k) => gmap.get(k)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.qty); if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
else if (sort === "desc") out.sort((a, b) => b.qty - a.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()); else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return out; return out;
}, [inventory, visibleIds, filters, sort]); }, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
const groupTxs = (g: Group) => const groupTxs = (g: Group) =>
transactions filteredTransactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id)) .filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice() .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) => const brandTxs = (g: Group, bid: string) =>
transactions filteredTransactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id)) .filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice() .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 (loading) return <div className="min-h-screen" />;
if (!user) { if (!user) {
@@ -285,13 +371,72 @@ export function InventoryPage() {
</div> </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 && ( {hasFilter && (
<div className="pt-1 flex items-center justify-between"> <div className="pt-1 flex items-center justify-between">
<button <button
type="button" type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1" className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1 cursor-pointer"
onClick={() => setFilters({ warehouse: "", material: "", project: "" })} onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
> >
<X className="h-3 w-3" /> <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"> <tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" /> <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>
<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> <span className="text-muted/60">—</span>
)} )}
</div> </div>
<span className="shrink-0 tabular-nums"> <span className="shrink-0 tabular-nums flex items-center gap-1">
{isoToJalaliStr(g.updatedAt)} <Clock className="w-3 h-3 text-muted/70" />
<span>{isoToJalaliDateTime(g.updatedAt)}</span>
</span> </span>
</div> </div>
</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(" · ") || "—"} {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td> </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 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 font-medium text-ink/80">{unit}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td> <td className="py-2.5 pe-3 text-muted tabular-nums">
<td className="py-2.5 text-muted">🕘</td> <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> </tr>
{isExp && ( {isExp && (
<tr className="border-b border-line/40 bg-accent-bg/10"> <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> </tr>
)} )}
</> </>
@@ -592,14 +747,29 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setSelTx(sel ? null : t.id)} 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}` }} style={{ borderTop: `3px solid ${meta.color}` }}
> >
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span> <div className="flex items-center justify-between gap-1">
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}> <span className="text-[11px] font-bold" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit} {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>
<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> </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}` }}> <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> <b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted"> <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" ? ( {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> <span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
@@ -212,6 +212,9 @@ export function OrganizationPage() {
{users.map((u) => { {users.map((u) => {
const isAdmin = u.role === "admin"; const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm); const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
return ( return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30"> <tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
@@ -320,7 +323,7 @@ export function OrganizationPage() {
<td className="py-2.5"> <td className="py-2.5">
{isAdmin ? ( {isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span> <span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : hasPanel ? ( ) : canAssignProjects ? (
<div className="w-72"> <div className="w-72">
<ProjectMultiSelect <ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))} options={projects.map((p) => ({ id: p.id, name: p.name }))}
+35 -67
View File
@@ -1,91 +1,59 @@
# PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت # PM — سامانه یکپارچه مدیریت پروژه‌های ساخت و کنترل کیفیت (Aramesh PM)
اپلیکیشن موبایل‌محور و تحت وب برای مدیریت جامع پروژه‌های عمرانی و ساخت: مدیریت سلسله‌مراتب فعالیت‌ها (WBS)، زمان‌بندی و گانت چارت، مسیر بحرانی (CPM)، سیستم پیشرفته کنترل کیفیت (QC)، مدیریت مصالح و انبار (با اتصال به ERP) و داشبورد عملیاتی روزانه. پلتفرم تخصصی مدیریت پروژه‌های عمرانی، کنترل کیفیت و زمان‌بندی کارگاهی که به صورت وندور شده و یکپارچه در کنار سامانه ERP قرار دارد.
--- ---
## دسترسی سریع ## 🏗️ نحوه استقرار و دسترسی در سامانه ERP
| سامانه | آدرس شبکه محلی | آدرس محلی سیستم | پلتفرم PM در استک یکپارچه پروژه از دو سرویس داکر تشکیل شده است:
|---|---|---| * **`pm-backend` (`pm-api`):** وب‌سرویس پایتون مبتنی بر FastAPI روی شبکه داخلی استک.
| **PM (وب + نمای موبایل)** | http://192.168.1.103:8083 | http://localhost:8083 | * **`pm-frontend` (`pm-frontend`):** اپلیکیشن کلاینت React با سرور داخلی Nginx که از طریق ریورس‌پروکسی Traefik زیر مسیر `/pm` قرار گرفته است.
| **PM API Health** | http://192.168.1.103:8083/api/v1/health | http://localhost:8083/api/v1/health |
**ورود پیش‌فرض:** `admin` / `pm123456` (قابل تغییر از طریق فایل `.env`) ### راه‌های دسترسی:
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`).
PM Frontend (React 19, Tailwind CSS, تقویم شمسی, نمای موبایل و دسکتاپ) * **احراز هویت یکپارچه (SSO):** ورود با همان حساب کاربری Casdoor انجام شده و سطح دسترسی پنل PM (`user_panels.pm`) کنترل می‌شود.
│ /api/v1
▼
PM Backend (FastAPI, Python 3.12, SQLAlchemy 2.0 Async)
│
├── Projects & Tasks Engine (سلسله‌مراتب نامحدود WBS، زمان‌بندی CPM، گانت)
├── QC Module (قالب‌های سراسری، تعریف‌های مستقل تسک، چک‌لیست، ثبت عیوب)
├── Materials & Resources (تخصیص نیروی انسانی، ماشین‌آلات، مصالح و رزرو انبار)
├── ERP Integration (استعلام موجودی کاتالوگ و انبارهای پروژه)
└── PM Postgres (ذخیره‌سازی بومی داده‌ها با پشتیبانی از JSONB و UUID)
```
--- ---
## مالکیت داده ## ⚙️ قابلیت‌های اصلی پلتفرم PM
| حوزه داده | متولی و مالک | توضیحات | ### ۱. مدیریت زمان‌بندی و شکست کار (WBS & CPM Engine)
|---|---|---| * ساختار شکست کار با عمق نامحدود (فاز، بخش، فعالیت، زیرفعالیت).
| پروژه‌ها، فعالیت‌ها، وابستگی‌ها، مایلستون‌ها | **PM Platform** | مدیریت کامل در دیتابیس بومی PM | * روابط وابستگی چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) به همراه زمان وقفه (Lag).
| چک‌لیست‌ها و کنترل کیفیت (QC) | **PM Platform** | پیش از اجرا، حین اجرا و پس از اجرا | * محاسبه خودکار مسیر بحرانی (Critical Path Method) و شناوری کل فعالیت‌ها.
| ثبت عیوب و اقدامات اصلاحی | **PM Platform** | الصاق تصویر، شدت عیب، پیگیری رفع | * گانت چارت شمسی و تعریف مایلستون‌های کلیدی پروژه.
| کاتالوگ مصالح و موجودی واقعی انبار | **ERP** | استعلام برخط موجودی از طریق API |
| رزرو مصالح و درخواست‌های خرید | **PM Platform** | رزرو نرم بدون دستکاری انبار تا زمان تأیید | ### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC Module)
* **قالب‌های عمومی بازرسی (QC Templates):** تعریف چک‌لیست‌های استاندارد کیفیت برای انواع فعالیت‌های ساختمانی.
* **مراحل ارزیابی:**
* **پیش از اجرا (`pre`):** دروازه ورود به تسک؛ تا زمان تایید این مرحله، پیشرفت تسک نمی‌تواند بیشتر از ۰٪ شود.
* **حین اجرا (`during`):** ثبت بازرسی‌های فنی حین عملیات اجرایی.
* **پس از اجرا (`after`):** کنترل نهایی؛ تا زمان تایید تمامی سوالات الزامی، پیشرفت تسک اجازه ۱۰۰٪ شدن ندارد.
* **ثبت و پیگیری عیوب (Defects):** ثبت عیوب همراه با تصاویر، سطح بحرانی بودن و لزوم بازرسی مجدد پس از اقدامات اصلاحی.
### ۳. مدیریت منابع و اتصال به انبار کارگاه
* امکان تخصیص متریال و ثبت رزرو نرم با استعلام موجودی واقعی انبارهای متناظر در ERP.
--- ---
## قابلیت‌های کلیدی ## 🧪 اجرای آزمایشی مستقل (Standalone Dev)
### ۱. مدیریت زمان‌بندی و گانت چارت (CPM Engine)
- ایجاد پروژه‌ها با کدهای اختصاصی و تیم پروژه
- ساختار شکست کار (WBS) در عمق‌های نامحدود (فاز، بخش، فعالیت، زیرفعالیت)
- وابستگی‌های چهارگانه بین فعالیت‌ها (`FS`, `SS`, `FF`, `SF`) همراه با روزهای تأخیر (Lag)
- محاسبه خودکار مسیر بحرانی (Critical Path) و شناوری کل فعالیت‌ها
- تعریف مایلستون‌ها و نقاط عطف کلیدی پروژه
### ۲. چرخه سه‌مرحله‌ای کنترل کیفیت (QC)
- **مراحل ارزیابی:** پیش از اجرا (`pre`) · حین اجرا (`during`) · پس از اجرا (`after`)
- **قوانین دروازه‌ای سرور:**
- فعالیت تا زمان تأیید الزامات پیش از اجرا اجازه شروع (پیشرفت > ۰) ندارد.
- فعالیت تا زمان پاس شدن همه الزامات الزامی اجازه رسیدن به ۱۰۰٪ ندارد.
- ثبت عیوب همراه با تصاویر و الزام به بازرسی مجدد پس از اصلاح.
### ۳. مدیریت منابع و ارتباط با ERP
- تخصیص نفرات، ماشین‌آلات و مصالح به تفکیک فعالیت
- اتصال پروژه به پروژه متناظر در ERP جهت استعلام موجودی انبارهای کارگاه
- رزرو نرم مصالح جهت جلوگیری از تداخل مصرف در بخش‌های مختلف پروژه
### ۴. تقویم و تاریخ‌های شمسی
- تقویم بومی جلالی بدون خطا و با رعایت روزهای کاری
- ثبت و نمایش روان تاریخ‌ها در رابط کاربری به زبان فارسی
---
## راه‌اندازی و اجرا
اجرای آسان با اسکریپت پاورشل یا داکر کامپوز:
در صورت تمایل به اجرای مستقل پلتفرم PM برای تست‌های ایزوله:
```powershell ```powershell
# اجرای خودکار از پوشه PM: # از درون پوشه pm:
powershell -ExecutionPolicy Bypass -File .\start.ps1 powershell -ExecutionPolicy Bypass -File .\start.ps1
``` ```
این حالت روی پورت محلی `8083` به صورت ایزوله اجرا خواهد شد.
یا مستقیماً با داکر:
```powershell
docker compose up -d --build
```
--- ---
## قرارداد API ## 📑 قرارداد و مستندات API
مستندات کامل اندپوینت‌ها: [`API_CONTRACT.md`](API_CONTRACT.md) قرارداد جامع اندپوینت‌ها و نمونه پیام‌ها در فایل [API_CONTRACT.md](API_CONTRACT.md) و راهنمای تفصیلی ساختار جداول در [DATABASE_GUIDE.md](DATABASE_GUIDE.md) مستند شده است.
+1 -1
View File
@@ -687,7 +687,7 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS actual_end DATE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE'; ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'ACTIVE';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0; ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT 0.0;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
(revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')) -- (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
COMMIT; COMMIT;