Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31300728b6 | ||
|
|
42fb314834 | ||
|
|
264c7cc906 | ||
|
|
707e40e840 | ||
|
|
ca2c1bb8ce |
@@ -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`.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,6 +462,9 @@ 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)
|
||||||
)
|
)
|
||||||
|
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")
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+53
-103
@@ -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
|
||||||
|
کاربر (بله: متن یا صوت)
|
||||||
|
│ getUpdates (Long-polling)
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
│ ERP api (/api/v1) │
|
│ procurement_ai_agent │
|
||||||
│ quotes · suppliers · materials│
|
│ 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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 "";
|
||||||
|
|||||||
@@ -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 (raw) {
|
||||||
|
const d = new Date(raw);
|
||||||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
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,21 +986,7 @@ 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">جستجو در متریالها (اختیاری)</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 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>
|
تحویل به (تحویلگیرنده) <span className="text-rose-500 font-bold">*</span>
|
||||||
@@ -972,10 +999,18 @@ export function ChangeInventoryPage() {
|
|||||||
onChange={(e) => setDeliveredTo(e.target.value)}
|
onChange={(e) => setDeliveredTo(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
)}
|
|
||||||
|
|
||||||
{(type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
|
|
||||||
<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
|
||||||
@@ -990,13 +1025,89 @@ 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">
|
||||||
|
<label className="text-xs font-semibold text-muted">
|
||||||
|
{type === "DAMAGE" ? "تاریخ و ساعت وقوع خرابی" : type === "WASTE" ? "تاریخ و ساعت ثبت ضایعات" : "تاریخ و ساعت ثبت اتلاف"} <span className="text-rose-500 font-bold">*</span>
|
||||||
|
</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
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === "STOCK_COUNT" && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="field">
|
||||||
|
<label className="text-xs font-semibold text-muted">
|
||||||
|
تاریخ و ساعت انجام انبارگردانی / شمارش <span className="text-rose-500 font-bold">*</span>
|
||||||
|
</label>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={sectionDates.STOCK_COUNT}
|
||||||
|
onChange={(iso) => setSectionDate("STOCK_COUNT", iso)}
|
||||||
|
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
|
||||||
|
withTime
|
||||||
|
maxDate={new Date()}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* فیلتر نام متریال برای انتقال داخلی و برگشت و پیدا شده */}
|
{/* فیلتر نام متریال و تاریخ برای انتقال داخلی */}
|
||||||
{isTransfer && (
|
{isTransfer && (
|
||||||
<div className="field mb-3">
|
<div className="grid gap-3 sm:grid-cols-2 mb-3">
|
||||||
|
<div className="field">
|
||||||
|
<label className="text-xs font-semibold text-muted">
|
||||||
|
تاریخ و ساعت انتقال بین انبارها <span className="text-rose-500 font-bold">*</span>
|
||||||
|
</label>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={sectionDates.TRANSFER}
|
||||||
|
onChange={(iso) => setSectionDate("TRANSFER", iso)}
|
||||||
|
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
|
||||||
|
withTime
|
||||||
|
maxDate={new Date()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
<label className="text-xs font-semibold text-muted">جستجو در متریالها (اختیاری)</label>
|
<label className="text-xs font-semibold text-muted">جستجو در متریالها (اختیاری)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1010,6 +1121,7 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---------- موجودی اولیه: فرم ثبت پیوسته ---------- */}
|
{/* ---------- موجودی اولیه: فرم ثبت پیوسته ---------- */}
|
||||||
@@ -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,9 +1212,22 @@ export function ChangeInventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---------- انتقال بین کارگاهی: یادداشت بار + لیست اقلام ---------- */}
|
{/* ---------- انتقال بین کارگاهی: یادداشت بار + تاریخ + لیست اقلام ---------- */}
|
||||||
{type === "PROJECT_TRANSFER" && (
|
{type === "PROJECT_TRANSFER" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="field">
|
||||||
|
<label className="text-xs font-semibold text-muted">
|
||||||
|
تاریخ و ساعت انتقال بین کارگاهی <span className="text-rose-500 font-bold">*</span>
|
||||||
|
</label>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={sectionDates.PROJECT_TRANSFER}
|
||||||
|
onChange={(iso) => setSectionDate("PROJECT_TRANSFER", 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
|
||||||
@@ -1110,6 +1238,7 @@ export function ChangeInventoryPage() {
|
|||||||
onChange={(e) => setNote(e.target.value)}
|
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>
|
||||||
<input
|
<input
|
||||||
@@ -1135,11 +1264,24 @@ export function ChangeInventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---------- برگشت: فیلتر بازه + لیست برداشتها ---------- */}
|
{/* ---------- برگشت: فیلتر بازه + تاریخ ثبت برگشت + لیست برداشتها ---------- */}
|
||||||
{type === "RETURN" && (
|
{type === "RETURN" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="field">
|
||||||
|
<label className="text-xs font-semibold text-muted">
|
||||||
|
تاریخ و ساعت ثبت برگشت به انبار <span className="text-rose-500 font-bold">*</span>
|
||||||
|
</label>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={sectionDates.RETURN}
|
||||||
|
onChange={(iso) => setSectionDate("RETURN", iso)}
|
||||||
|
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
|
||||||
|
withTime
|
||||||
|
maxDate={new Date()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">بازهی روز</label>
|
<label className="mb-1 block text-xs font-semibold text-muted">بازهی روز برداشتها (فیلتر جستجو)</label>
|
||||||
<ReturnFilterBar
|
<ReturnFilterBar
|
||||||
preset={retPreset}
|
preset={retPreset}
|
||||||
onPreset={(p) => setRetPreset(p)}
|
onPreset={(p) => setRetPreset(p)}
|
||||||
@@ -1149,6 +1291,7 @@ export function ChangeInventoryPage() {
|
|||||||
onCustomTo={(iso) => setRetCustomTo(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>
|
||||||
<input
|
<input
|
||||||
@@ -1208,6 +1351,19 @@ export function ChangeInventoryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{type === "FOUND" && (
|
{type === "FOUND" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="field max-w-sm">
|
||||||
|
<label className="text-xs font-semibold text-muted">
|
||||||
|
تاریخ و ساعت پیدا شدن کالا <span className="text-rose-500 font-bold">*</span>
|
||||||
|
</label>
|
||||||
|
<JalaliDatePicker
|
||||||
|
value={sectionDates.FOUND}
|
||||||
|
onChange={(iso) => setSectionDate("FOUND", iso)}
|
||||||
|
placeholder="۱۴۰۳/۰۵/۱۲ --:--"
|
||||||
|
withTime
|
||||||
|
maxDate={new Date()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<FoundTable
|
<FoundTable
|
||||||
items={pendingLosses}
|
items={pendingLosses}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -1216,6 +1372,7 @@ export function ChangeInventoryPage() {
|
|||||||
emptyMsg={foundEmptyMsg}
|
emptyMsg={foundEmptyMsg}
|
||||||
loading={foundLoading && !pendingLosses.length}
|
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();
|
||||||
|
|||||||
@@ -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>
|
</span>
|
||||||
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</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>
|
||||||
|
<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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user