Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97bda79de9 | ||
|
|
67fd0222c7 | ||
|
|
ce1421d038 | ||
|
|
707e40e840 | ||
|
|
ca2c1bb8ce | ||
|
|
d38cf38bd9 | ||
|
|
a1220db844 | ||
|
|
1190ec42eb | ||
|
|
2747f9d4bb | ||
|
|
934b74ebbd | ||
|
|
dcc4d693f5 | ||
|
|
64bd292335 | ||
|
|
98c4b28815 | ||
|
|
aba9ba6486 | ||
|
|
965054093e | ||
|
|
3931baadd5 | ||
|
|
1bf55ba8a6 | ||
|
|
5458a2f99f | ||
|
|
638076dbaa | ||
|
|
df3ff4ea14 |
@@ -30,7 +30,6 @@ frontend/.env
|
||||
scratch/
|
||||
v1.2 - Copy/
|
||||
inv dash/
|
||||
bot/
|
||||
voice/
|
||||
|
||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||
|
||||
+24
-15
@@ -145,18 +145,6 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
user.username = username
|
||||
await db.flush()
|
||||
|
||||
project_ids: list = []
|
||||
if user.role in ("supervisor", "pm"):
|
||||
projects = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
project_ids = [p.id for p in projects]
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
|
||||
# لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای (جدا از نقش)
|
||||
panels: dict = {}
|
||||
panel_rows = (
|
||||
@@ -166,6 +154,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
).scalars().all()
|
||||
for row in panel_rows:
|
||||
panels[row.panel] = row.level
|
||||
|
||||
member_project_ids: list = [
|
||||
row.project_id
|
||||
for row in (
|
||||
@@ -177,6 +166,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
).scalars().all()
|
||||
]
|
||||
|
||||
pm_projects = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
pm_project_ids_list = [p.id for p in pm_projects]
|
||||
|
||||
# ترکیب پروژههای تحت مدیریت مستقیم و پروژههای عضویت با حفظ اولویت و بدون تکرار
|
||||
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
|
||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||
return UserContext(
|
||||
user=user,
|
||||
@@ -190,15 +192,22 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
|
||||
|
||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||
"""Every project id managed by the given PM/supervisor user (oldest first)."""
|
||||
rows = (
|
||||
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
|
||||
pm_rows = (
|
||||
await db.execute(
|
||||
select(Project.id)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
member_rows = (
|
||||
await db.execute(
|
||||
select(UserProjectMembership.project_id)
|
||||
.where(UserProjectMembership.user_id == user.id)
|
||||
.order_by(UserProjectMembership.created_at)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
|
||||
|
||||
|
||||
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
|
||||
|
||||
@@ -420,6 +420,9 @@ async def ensure_rbac_schema(engine: AsyncEngine) -> None:
|
||||
volume — the RBAC model expects these and login 500s without them."""
|
||||
stmts = (
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
|
||||
# The live user_role enum predates 'supervisor' — CREATE TYPE no-ops on
|
||||
# an existing type, so without this ALTER, assigning the role 500s.
|
||||
"ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'supervisor'",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
|
||||
|
||||
@@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
|
||||
claims = await _verify_token(access_token)
|
||||
ctx = await context_from_claims(claims, db)
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
if ctx.role == "supervisor" and not projects:
|
||||
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
|
||||
if ctx.role == "supervisor" and not has_projects:
|
||||
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
|
||||
await analytics.track(
|
||||
"login_success",
|
||||
|
||||
@@ -12,6 +12,7 @@ import base64
|
||||
import uuid
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
@@ -448,9 +449,18 @@ async def list_transactions(
|
||||
if transaction_type is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
||||
# asyncpg تایپ سختگیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
|
||||
# دیتابیس با TZ=UTC اجرا میشود، پس «YYYY-MM-DD» یعنی نیمهشب UTC
|
||||
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
|
||||
stmt = stmt.where(
|
||||
InventoryTransaction.transaction_date
|
||||
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||
)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
||||
stmt = stmt.where(
|
||||
InventoryTransaction.transaction_date
|
||||
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
|
||||
)
|
||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
|
||||
@@ -169,11 +169,14 @@ async def create_quote(
|
||||
):
|
||||
# سطح limited: استعلام فقط برای پروژههای عضویت خودش
|
||||
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
|
||||
member = set(ctx.member_project_ids or [])
|
||||
wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
|
||||
if ctx.project_id:
|
||||
allowed.add(ctx.project_id)
|
||||
allowed_str = {str(pid) for pid in allowed if pid}
|
||||
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
|
||||
if not wanted:
|
||||
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
|
||||
if not wanted <= member:
|
||||
if not wanted <= allowed_str:
|
||||
raise ForbiddenError("استعلام فقط برای پروژههای تخصیصیافته به شما مجاز است")
|
||||
quote = await quote_service.create_quote(db, payload, actor)
|
||||
return {"data": await serialize_quote_with_children(db, quote)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import uuid
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import delete, select, update, text
|
||||
from sqlalchemy import delete, select, update, text, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_current_user, require_role
|
||||
@@ -100,7 +100,9 @@ async def user_to_dict(user: User, projects: list[Project] | None = None, db: As
|
||||
}
|
||||
if db is not None:
|
||||
out["panels"] = await _panels_of(db, user.id)
|
||||
out["member_project_ids"] = await _member_project_ids_of(db, user.id)
|
||||
member_pids = await _member_project_ids_of(db, user.id)
|
||||
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
|
||||
out["member_project_ids"] = all_pids
|
||||
return out
|
||||
|
||||
|
||||
@@ -112,13 +114,23 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
|
||||
|
||||
|
||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||
"""Every project assigned to the user (supervisor or PM) (oldest first)."""
|
||||
"""Every project assigned to the user (supervisor or PM) (oldest first).
|
||||
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
|
||||
"""
|
||||
if user.role not in ("supervisor", "pm"):
|
||||
return []
|
||||
member_subq = select(UserProjectMembership.project_id).where(
|
||||
UserProjectMembership.user_id == user.id
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.where(
|
||||
or_(
|
||||
Project.pm_user_id == user.id,
|
||||
Project.id.in_(member_subq),
|
||||
)
|
||||
)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -246,12 +258,12 @@ async def create_user(
|
||||
async def update_user_role(
|
||||
user_id,
|
||||
payload: UserRoleUpdate,
|
||||
ctx: UserContext = Depends(require_role("admin")),
|
||||
ctx: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
|
||||
assign or elevate roles. Revoking (or switching away from «pm») also
|
||||
unassigns the user from their project."""
|
||||
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
|
||||
if payload.role == "admin" and ctx.role != "admin":
|
||||
raise ForbiddenError("فقط ادمین میتواند نقش ادمین تعیین کند")
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
@@ -386,13 +398,14 @@ async def assign_user_project(
|
||||
|
||||
|
||||
@router.put("/{user_id}/panels")
|
||||
@router.patch("/{user_id}/panels")
|
||||
async def set_user_panels(
|
||||
user_id,
|
||||
payload: UserPanelsUpdate,
|
||||
_: UserContext = Depends(require_role("admin")),
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین."""
|
||||
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
@@ -409,10 +422,11 @@ async def set_user_panels(
|
||||
|
||||
|
||||
@router.put("/{user_id}/panel-projects")
|
||||
@router.patch("/{user_id}/panel-projects")
|
||||
async def set_user_panel_projects(
|
||||
user_id,
|
||||
payload: UserPanelProjectsUpdate,
|
||||
_: UserContext = Depends(require_role("admin")),
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""عضویت پروژهای کاربر (M:N) — محدوده دادهی سطح limited."""
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
|
||||
|
||||
> این سند وضعیت نهایی یکپارچهسازی بات تلگرام/بله (procurement AI agent) با پروژهی ERP را
|
||||
> توضیح میدهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
|
||||
> نکات امنیتی و نحوهی استفاده. تاریخ آخرین بهروزرسانی: برنچ `prod-v7`.
|
||||
|
||||
---
|
||||
|
||||
## ۱. مشکل چی بود؟
|
||||
|
||||
سرویس `bot` در `docker-compose.yml` اینطور تعریف شده بود:
|
||||
|
||||
```yaml
|
||||
bot:
|
||||
build:
|
||||
context: ./bot # ← پوشهای که در گیت وجود نداشت!
|
||||
volumes:
|
||||
- ./bot/app:/srv/bot/app
|
||||
```
|
||||
|
||||
اما:
|
||||
|
||||
1. **پوشهی `bot/` هیچوقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
|
||||
2. کد ایجنت در پوشهای به نام `ai procurement/` (با فاصله در نام) زندگی میکرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمیکرد.
|
||||
3. روی دستگاه reza.g یک **Windows Junction** (میانبر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره میکرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب میداد.
|
||||
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
|
||||
|
||||
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست میخورد —
|
||||
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
|
||||
|
||||
## ۲. راهحل اعمالشده (راهحل شماره ۲)
|
||||
|
||||
1. Junction ویندوزی `bot` حذف شد (فقط میانبر؛ فایلی از دست نرفت).
|
||||
2. پوشهی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
|
||||
**تاریخچهی گیت هر ۴۱ فایل بهصورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
|
||||
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشهی بات دیگر ignore نشود.
|
||||
(`voice/` سر جایش ماند — نتهای صوتی رuntimes هستند و نباید کامیت شوند.)
|
||||
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار میکند:
|
||||
`context: ./bot` و `./bot/app` دقیقاً به فایلهای کامیتشده اشاره میکنند.
|
||||
5. همهچیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایلها دسترسی داشته باشد.
|
||||
|
||||
### قواعد ignore فعلی (عمدی)
|
||||
|
||||
| مسیر | وضعیت | دلیل |
|
||||
|---|---|---|
|
||||
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکنها — هرگز کامیت نمیشوند |
|
||||
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
|
||||
| `voice/` | ignore (ریشه) | فایلهای صوتی دریافتی از بله در زمان اجرا |
|
||||
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیتشده** | بکاپ تاریخچهی اصلی ایجنت (~۲۰۰KB) |
|
||||
|
||||
## ۳. وضعیت فعلی گیت (تاریخ این سند)
|
||||
|
||||
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` همSHA).
|
||||
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **بهعلاوهی** `bot/`
|
||||
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
|
||||
(اندپوینتهای همگامسازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
|
||||
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
|
||||
تا «feat(bot,wizard): smart message filtering…».
|
||||
- بقیهی برنچهای تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایلهای بات را ندارند** —
|
||||
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
|
||||
|
||||
## ۴. ⚠️ نکتهی امنیتی (مهم — اقدام لازم)
|
||||
|
||||
در همان `docker-compose.yml`، مقادیر **واقعی** بهعنوان پیشفرض env کامیت و پوش شدهاند:
|
||||
|
||||
```yaml
|
||||
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
|
||||
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
|
||||
```
|
||||
|
||||
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچهی مخزن مشترک است و **هر کسی که به ریپو
|
||||
دسترسی دارد میتواند آنها را ببیند** (`.env` کامیت نشده، ولی این پیشفرضها عملاً همان لو رفتن راز است).
|
||||
|
||||
اقدامات توصیهشده (هنوز انجام نشده — نیاز به تصمیم مالک توکنها):
|
||||
|
||||
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
|
||||
2. مقادیر پیشفرض را از compose حذف کنید و بهجای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
|
||||
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
|
||||
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
|
||||
|
||||
## ۵. نحوهی استفاده
|
||||
|
||||
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
|
||||
|
||||
```powershell
|
||||
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
|
||||
cd ERP
|
||||
git checkout prod-v7
|
||||
|
||||
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
|
||||
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
|
||||
# BALE_BOT_TOKEN=... توکن بات از بله
|
||||
# AI_API_KEY=... کلید سرویس LLM
|
||||
# BALE_ADMIN_CHAT_IDS=... چتآیدی مدیران
|
||||
|
||||
# ۲) کل استک + بات:
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
سرویس `bot` (کانتینر `procurement-bot`) بهصورت long-polling به `tapi.bale.ai` وصل میشود،
|
||||
به `api:8000/api/v1` (همان ERP) و پایگاهدادهی `procurement` وصل است و
|
||||
draft های گفتگو را در جدول `bot_drafts` مینویسد.
|
||||
|
||||
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
|
||||
|
||||
```powershell
|
||||
docker compose build bot
|
||||
docker compose up -d bot
|
||||
docker compose logs -f bot
|
||||
```
|
||||
|
||||
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
|
||||
|
||||
1. کاربر در بله به بات پیام میدهد (متن یا وویس — وویس با STT به متن تبدیل میشود).
|
||||
2. بات با NLU موجودی/تأمینکننده/کالا را استخراج میکند و یک **draft** میسازد
|
||||
(وضعیت `active` در جدول `bot_drafts`) و کارت پیشنمایش با فیلدهای خالی میفرستد.
|
||||
3. کاربر با دکمهی ✅ تأیید میکند؛ بات draft را به ERP کامیت میکند
|
||||
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
|
||||
4. ویزارد فرانتاند همان draft را از `GET /api/v1/drafts` میخواند و پیشپر میکند —
|
||||
همگامسازی دوطرفه با ذخیرهی خودکار (debounced) بین مرورگر و بات.
|
||||
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی میشود (reply-based edit، بدون کارت تکراری).
|
||||
|
||||
### ۵.۴ تست سریع سلامت
|
||||
|
||||
```powershell
|
||||
docker compose ps bot # باید Up باشد
|
||||
docker compose logs bot | Select-Object -Last 20
|
||||
# داخل کانتینر:
|
||||
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
|
||||
```
|
||||
|
||||
### ۵.۵ مسیرهای مهم
|
||||
|
||||
| مسیر | محتوا |
|
||||
|---|---|
|
||||
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
|
||||
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
|
||||
| `bot/docs/SETUP.md` | راهاندازی جزئیتر ایجنت |
|
||||
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
|
||||
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
|
||||
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچهی مستقل قبلی ایجنت |
|
||||
|
||||
## ۶. نکات باقیمانده برای تیم
|
||||
|
||||
- **ادغام با برنچهای جدید تیم:** فایلهای بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
|
||||
آنها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
|
||||
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
|
||||
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
|
||||
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
|
||||
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
|
||||
@@ -350,6 +350,78 @@ services:
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
|
||||
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
|
||||
# created additively at startup) and reads ERP users/user_panels for SSO.
|
||||
# ---------------------------------------------------------------------------
|
||||
pm-api:
|
||||
image: v15-pm-api:latest
|
||||
build:
|
||||
context: ./pm/backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pm-api
|
||||
|
||||
volumes:
|
||||
- pm_files:/data/files
|
||||
- ./pm/backend/app:/srv/pm/app
|
||||
|
||||
environment:
|
||||
# Same database as the ERP — PM tables are additive, created at startup.
|
||||
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
|
||||
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
|
||||
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
|
||||
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
||||
PM_FILES_DIR: /data/files
|
||||
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
||||
# ERP integration — in-network service URLs.
|
||||
PM_ERP_BASE_URL: http://api:8000
|
||||
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
|
||||
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
||||
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
||||
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
||||
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_started
|
||||
|
||||
networks:
|
||||
- default
|
||||
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
|
||||
pm-frontend:
|
||||
image: v15-pm-frontend:latest
|
||||
build:
|
||||
context: ./pm/frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pm-frontend
|
||||
|
||||
depends_on:
|
||||
- pm-api
|
||||
|
||||
# PM platform under /pm on the same domain and the same Traefik router.
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
|
||||
- traefik.http.routers.pm.entrypoints=web
|
||||
# Higher priority than the ERP frontend Host router — otherwise /pm/api
|
||||
# would reach the ERP SPA (POST → 405).
|
||||
- traefik.http.routers.pm.priority=100
|
||||
- traefik.http.services.pm.loadbalancer.server.port=80
|
||||
|
||||
networks:
|
||||
- traefik_network
|
||||
- default
|
||||
|
||||
restart: unless-stopped
|
||||
mem_limit: 128m
|
||||
|
||||
networks:
|
||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||
@@ -366,3 +438,4 @@ volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
traefik_certs:
|
||||
pm_files:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# گزارش گپهای دفتر موجودی (Inventory Ledger Gaps)
|
||||
|
||||
بررسی کامل منطق تراکنشهای انبار (بکاند + فرانتاند) در جریان بازطراحی صفحه «تغییر موجودی».
|
||||
**این گزارش فقط فهرست گپهاست برای تصمیمگیری معماری — بهجز مورد ۱، هیچکدام در این نسخه اصلاح نشدهاند.**
|
||||
|
||||
وضعیتها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
|
||||
|
||||
---
|
||||
|
||||
## ۱. ✅ اصلاحشده در همین نسخه (پیشنیاز فیچر برگشت)
|
||||
|
||||
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
|
||||
(`backend/app/routers/inventory.py` — list_transactions)
|
||||
|
||||
ستون `transaction_date` (timestamptz) با رشتهی خام مقایسه میشد. درایور asyncpg تایپ سختگیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش میکرد. هیچجای UI قدیمی این فیلترها را صدا نمیزد، برای همین مخفی مانده بود.
|
||||
|
||||
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل میشوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکتهی مرتبط: مورد ۱۲.
|
||||
|
||||
---
|
||||
|
||||
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
|
||||
|
||||
بکاند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) میپذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API میتوان موجودی یک انبار را بینهایت تورم داد.
|
||||
|
||||
UI جدید این را کنترل میکند (تگ `[REF:<tx-id>]` در note + محاسبهی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمیشود.**
|
||||
|
||||
**پیشنهاد برای تصمیمگیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
|
||||
|
||||
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته میشود
|
||||
|
||||
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته میشود (سرویس آن را افزایش عمومی حساب میکند). یعنی از مسیر API میتوان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساختهشده توسط خود سرویس `transfer_pair_id` دارد.
|
||||
|
||||
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفتسازی داخلی سرویس ثبت شود).
|
||||
|
||||
## ۴. 🟡 create انتقال بینکارگاهی «ردیف انبار» نمیگیرد
|
||||
|
||||
`POST /inventory/project-transfers` بهجای `inventory_row_id` فقط material/brand/unit/specs میگیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر میکند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
|
||||
|
||||
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
|
||||
|
||||
## ۵. 🟡 Audit Trail ناقص است
|
||||
|
||||
فقط `POST /inventory/transactions` رکورد audit مینویسد (`inventory.py:491-500`). اینها audit ندارند:
|
||||
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بینکارگاهی
|
||||
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
|
||||
- CRUD انبارها و تغییرات کاربران
|
||||
|
||||
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمیکند).
|
||||
|
||||
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال میشوند
|
||||
|
||||
- `delivered_to` (تحویلگیرنده برداشت) در UI اجباری است؛ API بدون آن 201 میدهد.
|
||||
- ظرفیتسنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
|
||||
|
||||
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
|
||||
|
||||
در بخشهای لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیفهای ۱..k-1 **موفق ثبتشده** در جدول باقی میمانند → retry کاربر = ثبت تکراری. (در انتقال بینکارگاهی و برگشتِ این نسخه، ردیفهای موفق از لیست حذف میشوند — الگوی درست.)
|
||||
|
||||
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
|
||||
|
||||
## ۸. 🟡 برگشتهای قدیمی بدون REF قابل انتساب دقیق نیستند
|
||||
|
||||
برگشتهای ثبتشده قبل از این نسخه هیچ اشارهای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشتهای همردیف (متریال+برند+مشخصات+واحد) جبران میکند، ولی این حدس است و در دو حالت خطا دارد:
|
||||
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمیشود.
|
||||
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحههای بعد را جابهجا کند.
|
||||
|
||||
**پیشنهاد (داده):** اسکریپت یکبارهی backfill — تطبیق برگشتهای قدیمی با برداشتهای همان ردیف (قدیمیترین برداشتِ باقیمانده) و درج REF در note.
|
||||
|
||||
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
|
||||
|
||||
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC میبرد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC میافتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
|
||||
|
||||
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
|
||||
|
||||
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich میکند). UI فعلی از آن استفاده نمیکند ولی برای دیباگ/گزارشها گمراهکننده است.
|
||||
|
||||
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشیهای لیست
|
||||
|
||||
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی میشوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمیشود (محدودیت قدیمی، در لیستهای جدید این صفحه هم از همان دیتا میخواند). لیست برگشت و مفقودیها صفحهبندی/واکشی کامل دارند.
|
||||
|
||||
## ۱۲. 🔵 لیست انتقالهای بینکارگاهی بدون صفحهبندی
|
||||
|
||||
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همهی ردیفها را بدون pagination برمیگردانند؛ با رشد داده بار پاسخ زیاد میشود.
|
||||
|
||||
---
|
||||
|
||||
## چیزهایی که بررسی شد و درست است ✅
|
||||
|
||||
- **Immutable Ledger در سطح API:** برای تراکنشها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بینکارگاهی است که تراکنش خروجی خودش را بازمیگرداند و موجودی را ترمیم میکند.
|
||||
- **سقف FOUND در بکاند اعمال میشود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
|
||||
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامتدار صحیح.
|
||||
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمیشود.
|
||||
- **دریافت انتقال بینکارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
|
||||
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتبشدهی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
||||
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
|
||||
"short_name": "دفترچه تأمین",
|
||||
"description": "سامانه تدارکات و مدیریت انبار پروژههای ساختمانی",
|
||||
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
|
||||
"short_name": "مدیریت پروژه و تأمین",
|
||||
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
|
||||
"dir": "rtl",
|
||||
"lang": "fa",
|
||||
"start_url": "/",
|
||||
|
||||
+155
-29
@@ -59,6 +59,7 @@ function Loading() {
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
// در صورت داشتن شناسه پیشنویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -73,7 +74,10 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
if (user.role === "supervisor") {
|
||||
// اگر سرپرست پروژهای نداشته باشد، دسترسی مسدود میشود
|
||||
if (!user.projects || user.projects.length === 0) {
|
||||
const hasProjects =
|
||||
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||
if (!hasProjects) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
|
||||
@@ -96,43 +100,137 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
|
||||
if (!location.pathname.startsWith("/inventory") && location.pathname !== "/panel/pm") {
|
||||
return <Navigate to="/panel/pm" replace />;
|
||||
}
|
||||
|
||||
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنلهای تخصیصی:
|
||||
const panels = user.panels ?? {};
|
||||
const isAdmin = user.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user.role === "pm" || user.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user.role === "pm" || user.role === "supervisor");
|
||||
|
||||
const isProcurementRoute =
|
||||
pathname === "/" ||
|
||||
pathname.startsWith("/wizard") ||
|
||||
pathname.startsWith("/suppliers") ||
|
||||
pathname.startsWith("/projects") ||
|
||||
pathname.startsWith("/units") ||
|
||||
pathname.startsWith("/requester");
|
||||
|
||||
if (isProcurementRoute && !hasProcurement) {
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/inventory") && !hasInventory) {
|
||||
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// صفحهی اصلی بر اساس لایه پنلها (جایگزین نقشها):
|
||||
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
|
||||
// نقشهای میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شدهاند.
|
||||
function HomeRoute() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const panels = user?.panels ?? {};
|
||||
if (panels.inventory) return <Navigate to="/inventory" replace />;
|
||||
if (panels.procurement === "limited") return <RequesterHome />;
|
||||
// فقط پنل PM: مستقیم به پنل مدیریت پروژه
|
||||
if (panels.pm && !panels.inventory && !panels.procurement) {
|
||||
return <Navigate to="/panel/pm" replace />;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
// ۱. اگر پنل تدارکات دارد:
|
||||
if (hasProcurement) {
|
||||
if (panels.procurement === "limited" && !isAdmin) {
|
||||
return <Navigate to="/wizard" replace />;
|
||||
}
|
||||
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
|
||||
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
|
||||
return <DashboardPage />;
|
||||
}
|
||||
|
||||
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای میراث، تدارکات full
|
||||
// و تدارکات limited (محدود به پروژههای عضویت — صفحه /requester)
|
||||
// ۲. اگر پنل انبار دارد:
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
|
||||
// ۳. اگر پنل PM دارد:
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
|
||||
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
|
||||
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنلهای سامانه</h3>
|
||||
<p className="mt-2 text-xs text-muted leading-6">
|
||||
حساب کاربری شما فعال است اما در حال حاضر به هیچیک از پنلهای سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقشهای تدارکات و پنلهای تدارکات (full یا limited)
|
||||
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const panels = user?.panels ?? {};
|
||||
if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") {
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager" || user?.role === "pm");
|
||||
|
||||
if (hasProcurement) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
if (panels.procurement === "full") return <>{children}</>;
|
||||
if (panels.procurement === "limited") return <Navigate to="/requester" replace />;
|
||||
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
if (hasPm) return <Navigate to="/panel/pm" replace />;
|
||||
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو میشود
|
||||
@@ -146,25 +244,52 @@ function PmPanelPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای میراث pm/supervisor —
|
||||
// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد
|
||||
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقشهای pm/supervisor
|
||||
function RequirePmPanel({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const panels = user?.panels ?? {};
|
||||
const allowed =
|
||||
user?.role === "admin" ||
|
||||
Boolean(panels.pm) ||
|
||||
user?.role === "pm" ||
|
||||
user?.role === "supervisor";
|
||||
if (!allowed) return <Navigate to="/" replace />;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
if (!hasPm) {
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
if (hasProcurement) {
|
||||
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||
}
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// دسترسی پنل سازماندهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
|
||||
function RequireOrganization({ children }: { children: React.ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
|
||||
const isAdmin = user?.role === "admin";
|
||||
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
if (!allowed) {
|
||||
const panels = user?.panels ?? {};
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
|
||||
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
|
||||
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
|
||||
if (hasInventory) return <Navigate to="/inventory" replace />;
|
||||
return <Navigate to="/panel/pm" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -217,6 +342,7 @@ export default function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
|
||||
<Route
|
||||
path="/suppliers"
|
||||
element={
|
||||
|
||||
@@ -17,49 +17,61 @@ export function AppSwitcherMenu({
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isSupervisor = user?.role === "supervisor";
|
||||
const panels = user?.panels ?? {};
|
||||
|
||||
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده میشود
|
||||
if (isSupervisor) {
|
||||
const supervisorItems: MenuItem[] = [
|
||||
{
|
||||
const isAdmin = user?.role === "admin";
|
||||
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
|
||||
|
||||
const hasProcurement =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.procurement)
|
||||
: user?.role === "procurement_manager");
|
||||
|
||||
const hasInventory =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.inventory)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
const hasPm =
|
||||
isAdmin ||
|
||||
(hasPanelsConfig
|
||||
? Boolean(panels.pm)
|
||||
: user?.role === "pm" || user?.role === "supervisor");
|
||||
|
||||
// فقط نقشهای ادمین، مدیر پروژه و مدیر تدارکات به پنل سازماندهی دسترسی دارند
|
||||
const canManageOrg =
|
||||
isAdmin ||
|
||||
user?.role === "procurement_manager" ||
|
||||
user?.role === "pm";
|
||||
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (hasInventory) {
|
||||
items.push({
|
||||
label: "انبار",
|
||||
to: "/inventory",
|
||||
active: activeModule === "warehouse",
|
||||
},
|
||||
{
|
||||
label: "مدیریت پروژه",
|
||||
to: "/panel/pm",
|
||||
},
|
||||
{ divider: true },
|
||||
{ label: "خروج از حساب", onClick: logout },
|
||||
];
|
||||
return <PanelMenu title="پنلها" items={supervisorItems} />;
|
||||
});
|
||||
}
|
||||
|
||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: "انبار",
|
||||
to: "/inventory",
|
||||
active: activeModule === "warehouse",
|
||||
},
|
||||
];
|
||||
|
||||
if (canSwitchToProcurement) {
|
||||
if (hasProcurement && canSwitchToProcurement) {
|
||||
items.push({
|
||||
label: "تدارکات",
|
||||
to: "/",
|
||||
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
|
||||
active: activeModule === "procurement",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPm) {
|
||||
items.push({
|
||||
label: "مدیریت پروژه",
|
||||
to: "/panel/pm",
|
||||
});
|
||||
}
|
||||
|
||||
if (canManage) {
|
||||
if (canManageOrg) {
|
||||
items.push({
|
||||
label: "سازماندهی",
|
||||
to: "/organization",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
@@ -17,6 +17,7 @@ export interface ComboboxProps {
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||
addSuffix?: string; // متن بعد از +
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
@@ -25,6 +26,8 @@ export interface ComboboxProps {
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||
openOnFocus?: boolean;
|
||||
@@ -44,6 +47,7 @@ export function Combobox({
|
||||
onChange,
|
||||
items,
|
||||
allowAdd = true,
|
||||
addPrefix,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
placeholder,
|
||||
className,
|
||||
@@ -52,6 +56,7 @@ export function Combobox({
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
inputRef,
|
||||
openOnFocus = true,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
@@ -125,6 +130,7 @@ export function Combobox({
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
inputClassName,
|
||||
@@ -182,9 +188,15 @@ export function Combobox({
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
{addPrefix ? (
|
||||
<span>
|
||||
{addPrefix} «<b>{text.trim()}</b>»
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,11 @@ import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||
value: string | null;
|
||||
onChange: (val: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||
withTime?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,20 +42,16 @@ function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||
}
|
||||
|
||||
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
|
||||
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||
}
|
||||
|
||||
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
|
||||
function buildDateOnly(jy: number, jm: number, jd: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
||||
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
||||
function nowTime(): { h: number; m: number } {
|
||||
const d = new Date();
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
@@ -68,7 +64,6 @@ function timeOf(iso: string | null): { h: number; m: number } {
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -78,6 +73,7 @@ export function JalaliDatePicker({
|
||||
disabled = false,
|
||||
}: JalaliDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند
|
||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||
const initialTime = timeOf(value);
|
||||
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||
@@ -111,7 +107,6 @@ export function JalaliDatePicker({
|
||||
const mm = String(t.m).padStart(2, "0");
|
||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||
|
||||
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
|
||||
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||
if (curH !== t.h) {
|
||||
setHourText(faNum(hh));
|
||||
@@ -127,7 +122,18 @@ export function JalaliDatePicker({
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
// تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
|
||||
const handleOpen = () => {
|
||||
if (disabled) return;
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const isLeftHalf = rect.left + rect.width / 2 < window.innerWidth / 2;
|
||||
// اگر فیلد در نیمه چپ صفحه بود، تراز را از لبه چپ (end) میبندیم تا به سمت راست باز شود
|
||||
setAlignment(isLeftHalf ? "end" : "start");
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
@@ -141,7 +147,7 @@ export function JalaliDatePicker({
|
||||
const grid = useMemo(() => {
|
||||
const [jy, jm] = curJ;
|
||||
const firstG = jalaliToGregorian(jy, jm, 1);
|
||||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7;
|
||||
const dim = daysInMonth(jy, jm);
|
||||
const today = new Date();
|
||||
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
@@ -190,11 +196,8 @@ export function JalaliDatePicker({
|
||||
|
||||
const handleHourChange = (raw: string) => {
|
||||
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||
if (digits.length === 3) {
|
||||
digits = digits.slice(-1);
|
||||
} else if (digits.length > 3) {
|
||||
digits = digits.slice(0, 2);
|
||||
}
|
||||
if (digits.length === 3) digits = digits.slice(-1);
|
||||
else if (digits.length > 3) digits = digits.slice(0, 2);
|
||||
if (!digits) {
|
||||
setHourText("");
|
||||
return;
|
||||
@@ -228,11 +231,8 @@ export function JalaliDatePicker({
|
||||
|
||||
const handleMinuteChange = (raw: string) => {
|
||||
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||
if (digits.length === 3) {
|
||||
digits = digits.slice(-1);
|
||||
} else if (digits.length > 3) {
|
||||
digits = digits.slice(0, 2);
|
||||
}
|
||||
if (digits.length === 3) digits = digits.slice(-1);
|
||||
else if (digits.length > 3) digits = digits.slice(0, 2);
|
||||
if (!digits) {
|
||||
setMinuteText("");
|
||||
return;
|
||||
@@ -269,11 +269,7 @@ export function JalaliDatePicker({
|
||||
const [jy, jm, jd] = todayJalali();
|
||||
const nh = now.getHours();
|
||||
const nm = now.getMinutes();
|
||||
setHourText(faNum(String(nh).padStart(2, "0")));
|
||||
setMinuteText(faNum(String(nm).padStart(2, "0")));
|
||||
const val = withTime
|
||||
? buildIso(jy, jm, jd, nh, nm)
|
||||
: buildDateOnly(jy, jm, jd);
|
||||
const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
withTime
|
||||
@@ -364,7 +360,7 @@ export function JalaliDatePicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<div ref={ref} className={cn("relative w-full", className)}>
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
@@ -372,43 +368,45 @@ export function JalaliDatePicker({
|
||||
value={text}
|
||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||
autoComplete="off"
|
||||
onFocus={() => !disabled && setOpen(true)}
|
||||
onClick={() => !disabled && setOpen(true)}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||
// حداکثر عرض = viewport (موبایل باریک سرریز نمیشود؛ دسکتاپ بیتغییر)
|
||||
"max-w-[calc(100vw-2rem)]",
|
||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||
// پاپآپ مطلق که بر اساس کلاس تراز تنظیم میشود
|
||||
"absolute top-full z-[100] mt-1",
|
||||
alignment === "start" ? "start-0" : "end-0", // اگر فیلد چپ بود -> end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست)
|
||||
"rounded-xl border border-line bg-panel shadow-2xl p-3.5",
|
||||
withTime ? "w-80" : "w-[280px]",
|
||||
"max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
|
||||
)}
|
||||
>
|
||||
{/* 1. Header: Month / Year Navigation */}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-1">
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||
«
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-[13px] font-bold">
|
||||
<span className="text-sm font-bold">
|
||||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||||
</span>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||||
›
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Day of Week Header & Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||
<div key={w} className="py-1 text-[11px] font-bold text-muted">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
@@ -420,7 +418,7 @@ export function JalaliDatePicker({
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
|
||||
"cursor-pointer rounded-lg py-2 text-[13px] hover:bg-slate-bg transition-colors",
|
||||
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
||||
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
||||
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
||||
@@ -433,44 +431,43 @@ export function JalaliDatePicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||
{/* 3. Time Inputs Row */}
|
||||
{withTime && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||
<div className="mt-3 flex items-center justify-center gap-3 rounded-lg border border-line/70 bg-slate-bg/40 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={minuteText}
|
||||
inputMode="numeric"
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||
onBlur={handleMinuteBlur}
|
||||
/>
|
||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||
<span className="text-xs font-semibold text-muted">دقیقه</span>
|
||||
</div>
|
||||
<span className="font-bold text-muted">:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={hourText}
|
||||
inputMode="numeric"
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleHourChange(e.target.value)}
|
||||
onBlur={handleHourBlur}
|
||||
/>
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
<span className="text-xs font-semibold text-muted">ساعت</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 4. Action Buttons Footer */}
|
||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||
<div className="mt-3 flex justify-between border-t border-line pt-2">
|
||||
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||
امروز
|
||||
</button>
|
||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
پاک کردن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+14
-3
@@ -263,6 +263,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function putData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
}
|
||||
async function deleteData<T>(path: string): Promise<T> {
|
||||
const res = await request<any>(path, { method: "DELETE" });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
@@ -1806,9 +1810,9 @@ export const apiV26 = {
|
||||
patchData<UserRow>(`/users/${id}`, { role }),
|
||||
// لایه پنلها — تخصیص پنل + سطح دسترسی و عضویت پروژهای (فقط ادمین)
|
||||
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
|
||||
patchData<UserRow>(`/users/${id}/panels`, { panels }),
|
||||
putData<UserRow>(`/users/${id}/panels`, { panels }),
|
||||
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
|
||||
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
||||
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
|
||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||
@@ -1856,11 +1860,18 @@ export const apiV26 = {
|
||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||
return data.map(rawInventory);
|
||||
},
|
||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||
},
|
||||
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||
},
|
||||
createTransaction: async (body: {
|
||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||
|
||||
@@ -79,7 +79,10 @@ export function LoginPage() {
|
||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||
return;
|
||||
}
|
||||
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
|
||||
const hasProjects =
|
||||
(Boolean(user.projects && user.projects.length > 0)) ||
|
||||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
|
||||
if (user.role === "supervisor" && !hasProjects) {
|
||||
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
|
||||
return;
|
||||
}
|
||||
@@ -92,10 +95,24 @@ export function LoginPage() {
|
||||
if (returnUrl) {
|
||||
navigate(decodeURIComponent(returnUrl));
|
||||
} else {
|
||||
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیر پروژه → پنل انبار؛ بقیه → دفتر
|
||||
if (user.role === "supervisor") navigate("/panel/pm");
|
||||
else navigate(user.role === "pm" ? "/inventory" : "/");
|
||||
} (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
|
||||
// هدایت پس از ورود بر اساس پنلها یا نقش:
|
||||
const panels = user.panels ?? {};
|
||||
if (panels.procurement === "full") {
|
||||
navigate("/");
|
||||
} else if (panels.procurement === "limited") {
|
||||
navigate("/requester");
|
||||
} else if (panels.inventory) {
|
||||
navigate("/inventory");
|
||||
} else if (panels.pm) {
|
||||
navigate("/panel/pm");
|
||||
} else if (user.role === "supervisor") {
|
||||
navigate("/panel/pm");
|
||||
} else if (user.role === "pm") {
|
||||
navigate("/inventory");
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||
} finally {
|
||||
@@ -285,10 +302,21 @@ export function LoginPage() {
|
||||
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
|
||||
<p className="mt-1 text-[12.5px] leading-6 text-muted">
|
||||
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
|
||||
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
|
||||
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
|
||||
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
|
||||
</p>
|
||||
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
|
||||
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
|
||||
مدیریت پروژه
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
|
||||
تدارکات
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
|
||||
انبارداری
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FilePlus2, Info } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useProjects } from "../../hooks";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای
|
||||
// پروژههای عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمیشود.
|
||||
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژههای تخصیصیافته
|
||||
export function RequesterHome() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const projects = useProjects();
|
||||
const memberIds = new Set(user?.member_project_ids ?? []);
|
||||
const myProjects = memberIds.size
|
||||
? projects.filter((p) => memberIds.has(p.id))
|
||||
: [];
|
||||
|
||||
// پروژههای کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژههای عمومی)
|
||||
const myProjects = useMemo(() => {
|
||||
const list: { id: string; name: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// ۱. پروژههای موجود در آرایه user.projects
|
||||
for (const p of user?.projects ?? []) {
|
||||
if (p.id && !seen.has(p.id)) {
|
||||
seen.add(p.id);
|
||||
list.push({ id: p.id, name: p.name });
|
||||
}
|
||||
}
|
||||
|
||||
// ۲. پروژهی اختصاصی فعال (project_id)
|
||||
if (user?.project_id && !seen.has(user.project_id)) {
|
||||
seen.add(user.project_id);
|
||||
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیصیافته" });
|
||||
}
|
||||
|
||||
// ۳. پروژههای عضویت member_project_ids تطبیقیافته با لیست پروژهها
|
||||
const memberIdSet = new Set(user?.member_project_ids ?? []);
|
||||
for (const p of projects) {
|
||||
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
|
||||
seen.add(p.id);
|
||||
list.push({ id: p.id, name: p.name });
|
||||
}
|
||||
}
|
||||
|
||||
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
|
||||
if (list.length === 0 && user?.project_name) {
|
||||
const match = projects.find((p) => p.name === user.project_name);
|
||||
if (match && !seen.has(match.id)) {
|
||||
list.push({ id: match.id, name: match.name });
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [user, projects]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="افزودن استعلام" brand />
|
||||
<Header
|
||||
title="افزودن استعلام"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
شما فقط مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
||||
شما مجاز به ثبت استعلام برای پروژههای تخصیصیافتهی خود هستید.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +76,8 @@ export function RequesterHome() {
|
||||
{myProjects.map((p) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to="/wizard"
|
||||
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
|
||||
to={`/wizard?project=${encodeURIComponent(p.name)}`}
|
||||
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
|
||||
>
|
||||
<div>
|
||||
<b className="text-[13.5px]">🏢 {p.name}</b>
|
||||
@@ -50,7 +90,10 @@ export function RequesterHome() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link to="/wizard" className="btn-line-file no-underline">
|
||||
<Link
|
||||
to="/wizard"
|
||||
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
|
||||
>
|
||||
<FilePlus2 className="h-3.5 w-3.5" />
|
||||
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
|
||||
</Link>
|
||||
@@ -60,3 +103,4 @@ export function RequesterHome() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -268,8 +268,6 @@ export function ArrivalsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin && !isPM) {
|
||||
|
||||
if (!canAccess) {
|
||||
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
// ============================================================
|
||||
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||
// ============================================================
|
||||
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine, type RowDraft } from "./StockTable";
|
||||
|
||||
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||
|
||||
interface FoundTableProps {
|
||||
items: LossItem[];
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function parseLocal(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||
}
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">متریال</th>
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||
<th className="py-2.5 px-3">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{items.map(({ tx, remaining }, i) => {
|
||||
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||
return (
|
||||
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="۰"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{items.map(({ tx, remaining }, i) => {
|
||||
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||
return (
|
||||
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||
</div>
|
||||
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="تعداد پیداشده"
|
||||
value={rows[i]?.qty || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// ============================================================
|
||||
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine, type StockRecord } from "./StockTable";
|
||||
|
||||
interface ProjectTransferListProps {
|
||||
records: StockRecord[];
|
||||
qtyOf: (id: string) => string;
|
||||
onQty: (id: string, v: string) => void;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||
errors: Record<string, string>;
|
||||
emptyMsg: string;
|
||||
}
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[600px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">متریال</th>
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||
<th className="py-2.5 px-3">تعداد انتقال</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{records.map((r) => {
|
||||
const err = errors[r.id];
|
||||
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||
return (
|
||||
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="۰"
|
||||
value={qtyOf(r.id)}
|
||||
onChange={(e) => onQty(r.id, e.target.value)}
|
||||
/>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد میشود.</span>
|
||||
<span>{faNum(records.length)} قلم کالا</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{records.map((r) => {
|
||||
const err = errors[r.id];
|
||||
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||
return (
|
||||
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||
</div>
|
||||
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">موجودی</div>
|
||||
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="تعداد انتقال"
|
||||
value={qtyOf(r.id)}
|
||||
onChange={(e) => onQty(r.id, e.target.value)}
|
||||
/>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// ============================================================
|
||||
// جدول «اقلام ثبتشده در این نشست» برای موجودی اولیه
|
||||
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظهی همان صفحه.
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { specsLine } from "./StockTable";
|
||||
|
||||
export interface PurchaseSessionEntry {
|
||||
material: string;
|
||||
brand: string;
|
||||
specs: Record<string, string>;
|
||||
qty: number;
|
||||
unitLabel: string;
|
||||
time: string; // HH:MM آمادهی نمایش
|
||||
whName: string;
|
||||
}
|
||||
|
||||
interface PurchaseSessionLogProps {
|
||||
entries: PurchaseSessionEntry[];
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
|
||||
if (!entries.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
|
||||
<h3 className="text-[13px] font-bold">
|
||||
🧾 اقلام ثبتشده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
|
||||
>
|
||||
پاک کردن لیست
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* دسکتاپ: جدول فشرده */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12px]">
|
||||
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2 px-3">متریال</th>
|
||||
<th className="py-2 px-3">برند</th>
|
||||
<th className="py-2 px-3">مشخصهها</th>
|
||||
<th className="py-2 px-3">تعداد</th>
|
||||
<th className="py-2 px-3">ساعت</th>
|
||||
<th className="py-2 px-3">انبار</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{[...entries].reverse().map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
|
||||
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
|
||||
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
|
||||
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
|
||||
<td className="py-1.5 px-3 text-muted">{e.time}</td>
|
||||
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: ردیفهای فشرده */}
|
||||
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
|
||||
{[...entries].reverse().map((e, i) => (
|
||||
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
|
||||
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted">
|
||||
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ============================================================
|
||||
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||
// ============================================================
|
||||
|
||||
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||
|
||||
interface ReturnFilterBarProps {
|
||||
preset: DatePreset;
|
||||
onPreset: (p: DatePreset) => void;
|
||||
customFrom: string | null;
|
||||
customTo: string | null;
|
||||
onCustomFrom: (iso: string | null) => void;
|
||||
onCustomTo: (iso: string | null) => void;
|
||||
}
|
||||
|
||||
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onPreset(p.id)}
|
||||
className={cn(
|
||||
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||
preset === p.id
|
||||
? "bg-accent font-bold text-white shadow-xs"
|
||||
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// ============================================================
|
||||
// جدول برداشتهای بازه برای بخش «برگشت»
|
||||
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
|
||||
// برگشت محاسبهشده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
|
||||
// ورودیها کلید-محورند (کلید = tx-id) تا حذف ردیفهای موفق، بقیه را
|
||||
// بههم نریزد.
|
||||
// ============================================================
|
||||
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
import { faNum, fmtDateTime } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine } from "./StockTable";
|
||||
|
||||
interface ReturnTableProps {
|
||||
withdrawals: InventoryTransaction[];
|
||||
qtyOf: (id: string) => string;
|
||||
noteOf: (id: string) => string;
|
||||
onQty: (id: string, v: string) => void;
|
||||
onNote: (id: string, v: string) => void;
|
||||
remainders: Map<string, number>;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشتها…</div>;
|
||||
}
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[760px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">تاریخ</th>
|
||||
<th className="py-2.5 px-3">متریال</th>
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">برداشت</th>
|
||||
<th className="py-2.5 px-3">مانده قابل برگشت</th>
|
||||
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{withdrawals.map((w) => {
|
||||
const cap = remainders.get(w.id) ?? 0;
|
||||
const q = parseNum(qtyOf(w.id));
|
||||
const over = q > cap + 1e-9;
|
||||
return (
|
||||
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
|
||||
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
|
||||
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
|
||||
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
|
||||
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
|
||||
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
|
||||
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="۰"
|
||||
value={qtyOf(w.id)}
|
||||
onChange={(e) => onQty(w.id, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={noteOf(w.id)}
|
||||
onChange={(e) => onNote(w.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
«مانده قابل برگشت» = مقدار برداشت − برگشتهای ثبتشده همان ردیف. سقف ورودی همین مانده است.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{withdrawals.map((w) => {
|
||||
const cap = remainders.get(w.id) ?? 0;
|
||||
const q = parseNum(qtyOf(w.id));
|
||||
const over = q > cap + 1e-9;
|
||||
return (
|
||||
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
|
||||
</div>
|
||||
{w.specs && Object.keys(w.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
|
||||
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||
<span>{fmtDateTime(w.transaction_date)}</span>
|
||||
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
|
||||
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
|
||||
)}
|
||||
placeholder="مقدار برگشت"
|
||||
value={qtyOf(w.id)}
|
||||
onChange={(e) => onQty(w.id, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={noteOf(w.id)}
|
||||
onChange={(e) => onNote(w.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// ============================================================
|
||||
// ویرایشگر مشخصههای فنی Key-Value (موجودی اولیه)
|
||||
// خروجی: jsonb استاندارد {عنوان: مقدار} همخوان با specs_hash بکاند.
|
||||
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته میشود.
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
|
||||
export interface SpecKvRow { key: string; value: string; }
|
||||
|
||||
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of rows) {
|
||||
const k = r.key.trim();
|
||||
if (k) out[k] = r.value.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface SpecsKvEditorProps {
|
||||
rows: SpecKvRow[];
|
||||
onChange: (rows: SpecKvRow[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
|
||||
const update = (i: number, patch: Partial<SpecKvRow>) =>
|
||||
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="عنوان مشخصه (مثلاً ضخامت)"
|
||||
value={r.key}
|
||||
onChange={(e) => update(i, { key: e.target.value })}
|
||||
/>
|
||||
<span className="shrink-0 text-[12px] text-muted">:</span>
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="مقدار (مثلاً ۲ میلیمتر)"
|
||||
value={r.value}
|
||||
onChange={(e) => update(i, { value: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
|
||||
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||
title="حذف این مشخصه"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...rows, { key: "", value: "" }])}
|
||||
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
|
||||
>
|
||||
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// ============================================================
|
||||
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||||
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||||
//
|
||||
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
|
||||
// موبایل (<640px): کارتهای عمودی لمسی با فیلد بزرگ ورود مقدار.
|
||||
// هر بخش ستونهای نمایش یکسان را از اینجا میگیرد و فقط رفتار ورودی
|
||||
// (تعداد ساده یا شمارششده) را با isCount انتخاب میکند.
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
|
||||
export interface StockRecord {
|
||||
id: string;
|
||||
material: { id: string; name: string };
|
||||
brand: { id: string; name: string } | null;
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
quantity_number: number;
|
||||
specs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RowDraft { key: string; qty: string; note: string; }
|
||||
|
||||
export function specsLine(specs: Record<string, string> | undefined): string {
|
||||
return (
|
||||
Object.entries(specs || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · ") || "—"
|
||||
);
|
||||
}
|
||||
|
||||
interface StockTableProps {
|
||||
records: StockRecord[];
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
/** شمارش موجودی: placeholder «شمارششده» و پیشنمایش مقدار فعلی هنگام خالی بودن */
|
||||
isCount?: boolean;
|
||||
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است میتوان پنهان کرد */
|
||||
showMaterial?: boolean;
|
||||
emptyMsg: string;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
}
|
||||
|
||||
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
const qtyPlaceholder = isCount ? "شمارششده" : "۰";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[560px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
|
||||
<tr className="text-right text-muted">
|
||||
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">موجودی فعلی</th>
|
||||
<th className="py-2.5 px-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||||
<th className="py-2.5 px-3">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{records.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
|
||||
{showMaterial && (
|
||||
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||
)}
|
||||
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
|
||||
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
|
||||
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
|
||||
placeholder={qtyPlaceholder}
|
||||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||||
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
|
||||
<span>{faNum(records.length)} قلم کالا</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتهای عمودی لمسی */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{records.map((r, i) => (
|
||||
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||||
</div>
|
||||
{r.specs && Object.keys(r.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">موجودی</div>
|
||||
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
|
||||
placeholder={qtyPlaceholder}
|
||||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// ============================================================
|
||||
// منطق بخش «برگشت» — واکشی برداشتها/برگشتها و محاسبه مانده قابل برگشت
|
||||
//
|
||||
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمیشود؛
|
||||
// مانده هر برداشت = مقدار برداشت − مجموع برگشتهای متصل به آن.
|
||||
// اتصال دو راه دارد:
|
||||
// ۱) برگشتهای جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
|
||||
// ۲) برگشتهای قدیمی بدون REF (ثبتشده در همان انبار مبدأ) → تخصیص FIFO
|
||||
// بین برداشتهای همان ردیف انبار (متریال+برند+مشخصات+واحد).
|
||||
// همهی جمع/تفریقها با round4 گرد میشوند تا خطای اعشار جاوااسکریپت
|
||||
// (مثل 0.30000000000000004) در ماندهها و سقفها نشت نکند.
|
||||
// ============================================================
|
||||
|
||||
import { apiV26 } from "../../../lib/api";
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
|
||||
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همهی محاسبات مانده */
|
||||
export function round4(n: number): number {
|
||||
return Math.round(n * 10000) / 10000;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// تاریخ — کلید روز میلادی محلی و بازههای پیشفرض
|
||||
// ------------------------------------------------------------
|
||||
|
||||
export function gregKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return x;
|
||||
}
|
||||
|
||||
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
|
||||
|
||||
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
|
||||
{ id: "today", label: "امروز" },
|
||||
{ id: "yesterday", label: "دیروز" },
|
||||
{ id: "7d", label: "۷ روز اخیر" },
|
||||
{ id: "30d", label: "۳۰ روز اخیر" },
|
||||
{ id: "custom", label: "بازه دلخواه" },
|
||||
];
|
||||
|
||||
/** بازهی سرور-ساید برای هر پیشفرض؛ date_to روزِ (inclusive بکاند: < date_to + 24h) */
|
||||
export function presetRange(
|
||||
preset: DatePreset,
|
||||
customFrom?: string,
|
||||
customTo?: string,
|
||||
): { date_from: string; date_to: string } {
|
||||
const today = new Date();
|
||||
switch (preset) {
|
||||
case "today":
|
||||
return { date_from: gregKey(today), date_to: gregKey(today) };
|
||||
case "yesterday": {
|
||||
const y = gregKey(addDays(today, -1));
|
||||
return { date_from: y, date_to: y };
|
||||
}
|
||||
case "7d":
|
||||
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
|
||||
case "30d":
|
||||
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
|
||||
case "custom":
|
||||
return {
|
||||
date_from: customFrom || gregKey(today),
|
||||
date_to: customTo || customFrom || gregKey(today),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// تگ رفرنس [REF:<uuid>]
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
|
||||
|
||||
export function parseRef(note?: string | null): string | null {
|
||||
if (!note) return null;
|
||||
const m = note.match(REF_RE);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
|
||||
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
|
||||
const tag = `[REF:${withdrawalTxId}]`;
|
||||
const note = (userNote || "").trim();
|
||||
return note ? `${note} ${tag}` : tag;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// واکشی صفحهبندیشده (هر صفحه ۱۰۰ — سقف بکاند)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const MAX_PAGES = 10; // سقف محافظهکارانه: ۱۰۰۰ رکورد
|
||||
|
||||
export async function fetchWithdrawalsPage(
|
||||
sourceWhId: string,
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
page: number,
|
||||
): Promise<{ rows: InventoryTransaction[]; total: number }> {
|
||||
return apiV26.inventory.transactionsPaged({
|
||||
warehouse_id: sourceWhId,
|
||||
transaction_type: "WITHDRAWAL",
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
sort: "transaction_date",
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
/** همهی برگشتهای پروژه از ابتدای بازه (برای تطبیق REF) — صفحهبهصفحه تا سقف */
|
||||
export async function fetchProjectReturns(
|
||||
dateFrom: string,
|
||||
): Promise<InventoryTransaction[]> {
|
||||
const out: InventoryTransaction[] = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||
transaction_type: "RETURN",
|
||||
date_from: dateFrom,
|
||||
sort: "transaction_date",
|
||||
page,
|
||||
});
|
||||
out.push(...rows);
|
||||
if (out.length >= total || rows.length === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** همهی تراکنشهای یک نوع در یک انبار (برای محاسبه مانده مفقودیها) */
|
||||
export async function fetchAllTypeInWarehouse(
|
||||
warehouseId: string,
|
||||
transactionType: string,
|
||||
): Promise<InventoryTransaction[]> {
|
||||
const out: InventoryTransaction[] = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||
warehouse_id: warehouseId,
|
||||
transaction_type: transactionType,
|
||||
sort: "transaction_date",
|
||||
page,
|
||||
});
|
||||
out.push(...rows);
|
||||
if (out.length >= total || rows.length === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// مانده قابل برگشت
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتبشده) + واحد — همارز specs_hash بکاند */
|
||||
function rowKey(t: InventoryTransaction): string {
|
||||
const specs = Object.keys(t.specs || {})
|
||||
.sort()
|
||||
.map((k) => `${k}=${String((t.specs || {})[k])}`)
|
||||
.join("&");
|
||||
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* مانده قابل برگشت هر برداشت را حساب میکند.
|
||||
* - برگشتهای REF-دار: مستقیماً از برداشت مقصد کم میشوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
|
||||
* - برگشتهای REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته میشوند (به برداشت خودشان تعلق دارند).
|
||||
* - برگشتهای قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشتهای همردیف تخصیص مییابند.
|
||||
*/
|
||||
export function computeRemainders(
|
||||
withdrawals: InventoryTransaction[],
|
||||
returns: InventoryTransaction[],
|
||||
sourceWhId: string,
|
||||
): Map<string, number> {
|
||||
const rem = new Map<string, number>();
|
||||
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
|
||||
|
||||
const inList = new Set(withdrawals.map((w) => w.id));
|
||||
const legacy: InventoryTransaction[] = [];
|
||||
|
||||
for (const r of returns) {
|
||||
const qty = round4(Math.abs(r.quantity_number || 0));
|
||||
if (qty <= 0) continue;
|
||||
const ref = parseRef(r.note);
|
||||
if (ref) {
|
||||
if (inList.has(ref)) {
|
||||
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
|
||||
}
|
||||
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
|
||||
} else {
|
||||
legacy.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// تخصیص FIFO برگشتهای قدیمیِ همانبار مبدأ بین برداشتهای همردیف (قدیمی→جدید)
|
||||
const buckets = new Map<string, InventoryTransaction[]>();
|
||||
for (const w of withdrawals) {
|
||||
const k = rowKey(w);
|
||||
const list = buckets.get(k);
|
||||
if (list) list.push(w);
|
||||
else buckets.set(k, [w]);
|
||||
}
|
||||
for (const list of buckets.values()) {
|
||||
list.sort(
|
||||
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
|
||||
);
|
||||
}
|
||||
for (const r of legacy) {
|
||||
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
|
||||
const list = buckets.get(rowKey(r));
|
||||
if (!list) continue;
|
||||
let q = round4(Math.abs(r.quantity_number || 0));
|
||||
for (const w of list) {
|
||||
if (q <= 0) break;
|
||||
const cur = rem.get(w.id) ?? 0;
|
||||
if (cur <= 0) continue;
|
||||
const take = Math.min(cur, q);
|
||||
rem.set(w.id, round4(cur - take));
|
||||
q = round4(q - take);
|
||||
}
|
||||
}
|
||||
|
||||
// کف صفر (محافظت در برابر دادههای ناسازگار قدیمی)
|
||||
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
|
||||
return rem;
|
||||
}
|
||||
|
||||
/** مانده مفقودیِ باقیمانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
|
||||
export function computeLossRemainders(
|
||||
losses: InventoryTransaction[],
|
||||
founds: InventoryTransaction[],
|
||||
): { tx: InventoryTransaction; remaining: number }[] {
|
||||
const foundByKey = new Map<string, number>();
|
||||
for (const f of founds) {
|
||||
const k = rowKey(f);
|
||||
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
|
||||
}
|
||||
return losses
|
||||
.map((tx) => {
|
||||
const loss = Math.abs(tx.quantity_number || 0);
|
||||
const found = foundByKey.get(rowKey(tx)) || 0;
|
||||
return { tx, remaining: Math.max(0, round4(loss - found)) };
|
||||
})
|
||||
.filter((x) => x.remaining > 0);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { apiV26, type UserRow } from "../../lib/api";
|
||||
import { api, apiV26, type UserRow } from "../../lib/api";
|
||||
import type { Project } from "../../lib/types";
|
||||
import { refreshProcurementStore, useProjects } from "../../hooks";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||
@@ -17,10 +18,12 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
supervisor: "سرپرست پروژه",
|
||||
};
|
||||
|
||||
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها به سرپرستان
|
||||
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها و پنلها
|
||||
export function OrganizationPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const projects = useProjects();
|
||||
const storeProjects = useProjects();
|
||||
const [directProjects, setDirectProjects] = useState<Project[]>([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
@@ -28,11 +31,25 @@ export function OrganizationPage() {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
// فقط ادمین میتواند نقشها را تغییر دهد؛ مدیران میتوانند پروژهها را به سرپرستان تخصیص دهند
|
||||
const canChangeRoles = user?.role === "admin";
|
||||
const canChangeRoles = canManage;
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoadingProjects(true);
|
||||
const list = await api.projects.list();
|
||||
if (list && Array.isArray(list)) {
|
||||
setDirectProjects(list);
|
||||
}
|
||||
} catch {
|
||||
// fallback silently
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoadingUsers(true);
|
||||
const list = await apiV26.users.list();
|
||||
setUsers(list);
|
||||
} catch (err) {
|
||||
@@ -43,8 +60,13 @@ export function OrganizationPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManage) void loadUsers();
|
||||
}, [canManage, loadUsers]);
|
||||
if (canManage) {
|
||||
void loadUsers();
|
||||
void fetchProjects();
|
||||
}
|
||||
}, [canManage, loadUsers, fetchProjects]);
|
||||
|
||||
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
|
||||
|
||||
const syncUsers = async () => {
|
||||
setSyncing(true);
|
||||
@@ -57,6 +79,7 @@ export function OrganizationPage() {
|
||||
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||||
);
|
||||
refreshProcurementStore();
|
||||
void fetchProjects();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
||||
} finally {
|
||||
@@ -91,39 +114,8 @@ export function OrganizationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const assignedProjectsOf = (u: UserRow) =>
|
||||
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
||||
|
||||
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
|
||||
const current = assignedProjectsOf(u).map((p) => p.id);
|
||||
const added = nextIds.filter((id) => !current.includes(id));
|
||||
const removed = current.filter((id) => !nextIds.includes(id));
|
||||
if (!added.length && !removed.length) return;
|
||||
setBusyId(u.id);
|
||||
try {
|
||||
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
||||
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
||||
const names = nextIds
|
||||
.map((id) => projects.find((p) => p.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join("، ");
|
||||
toast.success(
|
||||
nextIds.length
|
||||
? `پروژههای «${u.first_name} ${u.last_name}» بهروزرسانی شد: ${names} ✓`
|
||||
: `«${u.first_name} ${u.last_name}» از همهی پروژهها جدا شد`,
|
||||
);
|
||||
await loadUsers();
|
||||
refreshProcurementStore();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
||||
await loadUsers();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- لایه پنلها: تخصیص پنل + سطح دسترسی و عضویت پروژهای ----------
|
||||
const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => {
|
||||
const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
|
||||
setBusyId(u.id);
|
||||
const next = { ...(u.panels ?? {}) };
|
||||
if (level) next[panel] = level;
|
||||
@@ -157,12 +149,14 @@ export function OrganizationPage() {
|
||||
const panelLabel = (level: string | undefined) =>
|
||||
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
|
||||
|
||||
// سرپرستان تخصیصیافته به یک پروژه
|
||||
// سرپرستان و مسئولان تخصیصیافته به یک پروژه
|
||||
const supervisorsOf = (projectId: string) =>
|
||||
users.filter(
|
||||
(u) =>
|
||||
(u.role === "supervisor" || u.role === "pm") &&
|
||||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
||||
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
|
||||
(u.projects?.some((p) => p.id === projectId) ||
|
||||
u.project_id === projectId ||
|
||||
u.member_project_ids?.includes(projectId)),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -172,34 +166,33 @@ export function OrganizationPage() {
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="organization" />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگامسازی» اضافه میشوند. نقشها در این پنل توسط ادمین تعیین میگردند.
|
||||
مدیران پروژه و تدارکات میتوانند پروژهها را به سرپرستان پروژه تخصیص دهند.
|
||||
<PageLayout className="flex flex-col gap-5">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
|
||||
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگامسازی» اضافه میشوند. نقشهای سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین میگردد.
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۱: جدول کاربران و نقشها ---------- */}
|
||||
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقشها و سطوح دسترسی پنلها ---------- */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold">👥 کاربران و نقشها</h3>
|
||||
<h3 className="text-[14px] font-bold">👥 کاربران، نقشها و سطوح دسترسی پنلها</h3>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||
تعیین نقش کاربران و تخصیص پروژهها به سرپرستان پروژه
|
||||
تعیین نقش سازمانی، تخصیص پنلها (تدارکات، انبار، PM) و مشخص نمودن پروژههای عضویت کاربر
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void syncUsers()}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||
) : !users.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
|
||||
کاربری ثبت نشده است — دکمهی «همگامسازی از Casdoor» را بزنید.
|
||||
</div>
|
||||
) : (
|
||||
@@ -207,26 +200,48 @@ export function OrganizationPage() {
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">نام</th>
|
||||
<th className="py-2 pe-3">نام کاربری</th>
|
||||
<th className="py-2 pe-3">نقش</th>
|
||||
<th className="py-2 pe-3">پروژههای تخصیصیافته (سرپرست پروژه)</th>
|
||||
<th className="py-2">وضعیت</th>
|
||||
<th className="py-2.5 pe-3">نام و مشخصات</th>
|
||||
<th className="py-2.5 pe-3">نقش سازمانی</th>
|
||||
<th className="py-2.5 pe-3">پنل تدارکات</th>
|
||||
<th className="py-2.5 pe-3">پنل انبار</th>
|
||||
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
|
||||
<th className="py-2.5">پروژههای عضویت (محدوده سطح محدود)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-line/50">
|
||||
{users.map((u) => {
|
||||
const isAdmin = u.role === "admin";
|
||||
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
|
||||
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
|
||||
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
|
||||
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
|
||||
{/* ۱. نام و نام کاربری */}
|
||||
<td className="py-2.5 pe-3">
|
||||
<b>{u.first_name} {u.last_name}</b>
|
||||
</td>
|
||||
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
|
||||
{u.username || "—"}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-foreground">
|
||||
{u.first_name} {u.last_name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted">
|
||||
({u.username || "—"})
|
||||
</span>
|
||||
</div>
|
||||
{busyId === u.id && (
|
||||
<span className="text-[10px] text-accent animate-pulse font-medium">
|
||||
در حال ذخیره تغییرات…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* ۲. نقش سازمانی */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.role ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||
@@ -238,132 +253,19 @@ export function OrganizationPage() {
|
||||
<option value="supervisor">سرپرست پروژه</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
|
||||
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pe-3">
|
||||
{u.role === "supervisor" || u.role === "pm" ? (
|
||||
<div className="w-72">
|
||||
<ProjectMultiSelect
|
||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
values={assignedProjectsOf(u).map((p) => p.id)}
|
||||
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
|
||||
disabled={busyId === u.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted">
|
||||
{u.role === "admin"
|
||||
? "دسترسی کامل به همه پروژهها"
|
||||
: u.role === "procurement_manager"
|
||||
? "دسترسی کامل مدیریتی"
|
||||
: "—"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 text-[11px] text-muted">
|
||||
{busyId === u.id ? (
|
||||
<span className="text-accent">در حال ذخیره…</span>
|
||||
) : (
|
||||
<span className="text-emerald-600">✓ ثبتشده</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
سرپرستان پروژهای که پروژهای به آنها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => {
|
||||
const sups = supervisorsOf(p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
|
||||
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
|
||||
{p.status || "پروژه فعال"}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
<div>
|
||||
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
|
||||
{!sups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
|
||||
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{sups.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
|
||||
<span className="font-semibold">{s.first_name} {s.last_name}</span>
|
||||
<span className="text-[10px] text-muted font-mono">({s.username})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* ---------- بخش ۳: پنلها و سطوح دسترسی (جایگزین نقشها) ---------- */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-[14px] font-bold">🔑 پنلها و سطوح دسترسی</h3>
|
||||
<p className="mt-0.5 text-[11.5px] leading-5 text-muted">
|
||||
به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن
|
||||
استعلام برای پروژههای عضویت. انبار — کامل: همه پروژهها بدون تغییر موجودی؛ محدود: فقط پروژههای عضویت با
|
||||
امکان تغییر موجودی. ادمین دستنخورده است.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-right text-muted">
|
||||
<th className="py-2 pe-3">نام</th>
|
||||
<th className="py-2 pe-3">پنل تدارکات</th>
|
||||
<th className="py-2 pe-3">پنل انبار</th>
|
||||
<th className="py-2">پروژههای عضویت (محدوده سطح محدود)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => {
|
||||
if (u.role === "admin") {
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-line/50">
|
||||
<td className="py-2.5 pe-3"><b>{u.first_name} {u.last_name}</b></td>
|
||||
<td colSpan={3} className="py-2.5 text-[11px] text-muted">
|
||||
ادمین — دسترسی کامل به همه پنلها (دستنخورده)
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory);
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-line/50">
|
||||
{/* ۳. پنل تدارکات */}
|
||||
<td className="py-2.5 pe-3">
|
||||
<b>{u.first_name} {u.last_name}</b>
|
||||
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
|
||||
</td>
|
||||
<td className="py-2.5 pe-3">
|
||||
{canChangeRoles ? (
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||
) : canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.panels?.procurement ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
|
||||
@@ -376,10 +278,14 @@ export function OrganizationPage() {
|
||||
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۴. پنل انبار */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{canChangeRoles ? (
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||
) : canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.panels?.inventory ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
|
||||
@@ -392,8 +298,32 @@ export function OrganizationPage() {
|
||||
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۵. پنل مدیریت پروژه (PM) */}
|
||||
<td className="py-2.5 pe-3">
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">کامل (ادمین)</span>
|
||||
) : canChangeRoles ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
value={u.panels?.pm ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
|
||||
>
|
||||
<option value="">— بدون تخصیص —</option>
|
||||
<option value="full">کامل</option>
|
||||
<option value="limited">محدود</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* ۶. پروژههای عضویت */}
|
||||
<td className="py-2.5">
|
||||
{hasPanel ? (
|
||||
{isAdmin ? (
|
||||
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژهها</span>
|
||||
) : canAssignProjects ? (
|
||||
<div className="w-72">
|
||||
<ProjectMultiSelect
|
||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
@@ -412,6 +342,256 @@ export function OrganizationPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||
<p className="text-[11.5px] text-muted">
|
||||
مشاهده وضعیت پروژهها و سرپرستان یا اعضای تخصیصیافته به هر پروژه
|
||||
</p>
|
||||
</div>
|
||||
{loadingProjects && (
|
||||
<span className="text-[11px] text-muted animate-pulse">در حال بهروزرسانی پروژهها…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loadingProjects && !projects.length ? (
|
||||
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
|
||||
در حال دریافت اطلاعات پروژهها…
|
||||
</div>
|
||||
) : !projects.length ? (
|
||||
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
|
||||
<span>پروژهای برای نمایش یافت نشد یا در دسترس نیست.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchProjects()}
|
||||
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
🔄 تلاش مجدد برای دریافت پروژهها
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => {
|
||||
const sups = supervisorsOf(p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
|
||||
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
|
||||
{p.status || "پروژه فعال"}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-line/50">
|
||||
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
|
||||
{!sups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
|
||||
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{sups.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
|
||||
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
|
||||
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنلها ---------- */}
|
||||
<div className="rounded-xl border border-line bg-panel p-5">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-[15px] font-bold">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
|
||||
📖
|
||||
</span>
|
||||
توضیحات پنلها و راهنمای سطوح دسترسی
|
||||
</h3>
|
||||
<p className="mt-1 text-[12px] text-muted">
|
||||
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنلهای سامانه
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
مدل دسترسی ماژولار مبتنی بر پنل
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{/* کارت پنل تدارکات */}
|
||||
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
|
||||
🛒
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
|
||||
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
|
||||
خرید و بازرگانی
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>دسترسی کامل مدیریتی به تمامی بخشهای تدارکات</li>
|
||||
<li>ثبت و ویرایش پیشفاکتورها، شرایط پرداخت و فاکتورها</li>
|
||||
<li>مدیریت تأمینکنندگان، اقلام و تصمیمگیری خرید در همه پروژهها</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>فقط امکان ثبت درخواست استعلام قیمت و پیشفاکتور اولیه</li>
|
||||
<li>محدود منحصراً به پروژههایی که کاربر در آنها عضویت دارد</li>
|
||||
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمتها یا پروژههای غیرعضو</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کارت پنل انبار */}
|
||||
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
|
||||
📦
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
|
||||
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
||||
انبارداری و موجودی
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژهها</li>
|
||||
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارشگیری کلان</li>
|
||||
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>دسترسی فقط به انبارهای پروژههای عضویت کاربر</li>
|
||||
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
|
||||
<li>تفکیک دقیق موجودی و تراکنشهای انبار بر اساس پروژه</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کارت پنل مدیریت پروژه (PM) */}
|
||||
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
|
||||
📊
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
|
||||
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
|
||||
کنترل پروژه و اجرا
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>مدیریت جامع ساختار شکست کار (WBS)، زمانبندی و گانت چارت تعاملی</li>
|
||||
<li>تعریف و ویرایش فعالیتها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
|
||||
<li>پایش شاخصهای پیشرفت زمانی و مالی (EVM) و گزارشهای تحلیلی</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
|
||||
</div>
|
||||
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
|
||||
<li>مشاهده فعالیتها و تسکهای مربوط به پروژههای عضویت کاربر</li>
|
||||
<li>بهروزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسکها</li>
|
||||
<li>تکمیل چکلیستهای کنترل کیفیت (QC) و ثبت گزارشهای اجرایی روزانه</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||
<span className="text-[16px] leading-none">👑</span>
|
||||
<div>
|
||||
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
|
||||
<p className="mt-0.5 text-muted leading-relaxed">
|
||||
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنلها و پروژهها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنلها ندارد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
|
||||
<span className="text-[16px] leading-none">🎯</span>
|
||||
<div>
|
||||
<span className="font-bold text-foreground">کارکرد «پروژههای عضویت»:</span>
|
||||
<p className="mt-0.5 text-muted leading-relaxed">
|
||||
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه دادهها و اختیارات وی دقیقاً به پروژههای انتخابی در ستون «پروژههای عضویت» محدود خواهد شد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
@@ -213,7 +213,11 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
||||
/>
|
||||
))}
|
||||
{!projects.length && (
|
||||
<div className="dd-empty">هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
|
||||
<div className="dd-empty">
|
||||
{canAddProject
|
||||
? "هنوز پروژهای تعریف نشده است — از پایین یک پروژه اضافه کنید."
|
||||
: "هنوز پروژهای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
|
||||
</div>
|
||||
)}
|
||||
{/* افزودن پروژه بهصورت درجا — زیر لیست پروژهها (سطح limited: ممنوع) */}
|
||||
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { matchMaterial } from "../../lib/quoteUtils";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
|
||||
@@ -154,13 +155,19 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLimitedProcurement =
|
||||
user?.panels?.procurement === "limited" && user?.role !== "admin";
|
||||
const memberProjectNames = useMemo(
|
||||
() =>
|
||||
projects
|
||||
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
|
||||
.map((p) => p.name),
|
||||
[projects, user],
|
||||
);
|
||||
const memberProjectNames = useMemo(() => {
|
||||
const allowedIds = new Set([
|
||||
...(user?.member_project_ids ?? []),
|
||||
...(user?.projects ?? []).map((p) => p.id),
|
||||
...(user?.project_id ? [user.project_id] : []),
|
||||
]);
|
||||
const matched = projects
|
||||
.filter((p) => allowedIds.has(p.id))
|
||||
.map((p) => p.name);
|
||||
const directNames = (user?.projects ?? []).map((p) => p.name);
|
||||
if (user?.project_name) directNames.push(user.project_name);
|
||||
return [...new Set([...matched, ...directNames])];
|
||||
}, [projects, user]);
|
||||
const topics = useAppStore((s) => s.topics);
|
||||
const createQuote = useCreateQuote();
|
||||
const addSupplier = useAddSupplier();
|
||||
@@ -309,12 +316,12 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
|
||||
[suppliers, materials, topics, items],
|
||||
);
|
||||
|
||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||
// ---------- prefill از لینکها (?topic=&supplier=&project=) ----------
|
||||
useEffect(() => {
|
||||
const t = params.get("topic");
|
||||
if (!t) return;
|
||||
if (t) {
|
||||
const topic = topicByName(t, topics);
|
||||
if (!topic) return;
|
||||
if (topic) {
|
||||
setSelectedTopic(topic.name);
|
||||
const sid = params.get("supplier");
|
||||
if (sid) {
|
||||
@@ -324,7 +331,22 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
|
||||
setSupplierInvalid(false);
|
||||
}
|
||||
}
|
||||
}, [params, suppliers, topics]);
|
||||
}
|
||||
}
|
||||
|
||||
const proj = params.get("project");
|
||||
if (proj) {
|
||||
setAssignment((cur) => {
|
||||
const next = { ...cur };
|
||||
for (const it of items) {
|
||||
if (!next[it.tempId]?.length) {
|
||||
next[it.tempId] = [proj];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [params, suppliers, topics, items]);
|
||||
|
||||
// ---------- بارگذاری اولیه پیشنویس ربات بله (?token= یا ?draft=) ----------
|
||||
useEffect(() => {
|
||||
@@ -516,11 +538,10 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
|
||||
|
||||
// ---------- پروژهها ----------
|
||||
const projectNames = useMemo(() => {
|
||||
const names = new Set<string>(
|
||||
isLimitedProcurement
|
||||
? memberProjectNames
|
||||
: projects.map((p) => p.name),
|
||||
);
|
||||
if (isLimitedProcurement) {
|
||||
return memberProjectNames;
|
||||
}
|
||||
const names = new Set<string>(projects.map((p) => p.name));
|
||||
quotes.forEach((q) => {
|
||||
if (q.project) names.add(q.project);
|
||||
(q.projects || []).forEach((n) => names.add(n));
|
||||
@@ -528,6 +549,24 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
|
||||
return [...names];
|
||||
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
|
||||
|
||||
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص مییابند
|
||||
useEffect(() => {
|
||||
if (isLimitedProcurement && memberProjectNames.length === 1) {
|
||||
const sole = memberProjectNames[0];
|
||||
setAssignment((cur) => {
|
||||
let changed = false;
|
||||
const next = { ...cur };
|
||||
for (const it of items) {
|
||||
if (!next[it.tempId]?.length) {
|
||||
next[it.tempId] = [sole];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : cur;
|
||||
});
|
||||
}
|
||||
}, [isLimitedProcurement, memberProjectNames, items]);
|
||||
|
||||
const assign = (tempId: string, proj: string) => {
|
||||
setAssignment((a) => {
|
||||
const cur = a[tempId] || [];
|
||||
@@ -898,8 +937,9 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
backTo={isLimitedProcurement ? "/requester" : "/"}
|
||||
backLabel={isLimitedProcurement ? "بازگشت به استعلامها" : "بازگشت به دفتر"}
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
/>
|
||||
)}
|
||||
<PageLayout>
|
||||
|
||||
@@ -786,3 +786,12 @@ button:disabled {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* اسکرول افقی بدون نوار اسکرول (چیپهای لمسی موبایل) */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# PM — Construction Project Management Platform
|
||||
# Copy to .env and customize as needed (never commit .env)
|
||||
|
||||
# Database credentials
|
||||
PM_DB_PASSWORD=pm_secret_2026
|
||||
|
||||
# Session secret (random 32+ bytes hex string)
|
||||
PM_SESSION_SECRET=change-me-32-bytes-hex
|
||||
|
||||
# Initial admin account
|
||||
PM_ADMIN_USER=admin
|
||||
PM_ADMIN_PASSWORD=pm123456
|
||||
|
||||
# ERP service account (Casdoor org `inventory` - for materials/warehouse integration)
|
||||
PM_ERP_CASDOOR_CLIENT_ID=inventory-app-client
|
||||
PM_ERP_CASDOOR_CLIENT_SECRET=inventory-app-secret-2026
|
||||
PM_ERP_SERVICE_USERNAME=pm-service
|
||||
PM_ERP_SERVICE_PASSWORD=pm-service-2026
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Normalize line endings to LF (mirrors the ERP repo convention)
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.dump binary
|
||||
@@ -0,0 +1,23 @@
|
||||
# secrets & local state — never commit
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# node
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
|
||||
# editor / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# uploaded defect photos (runtime volume)
|
||||
files/
|
||||
@@ -0,0 +1,102 @@
|
||||
# PM API Contract (v3) — Standalone Native Platform — پایه: /api/v1
|
||||
|
||||
پلتفرم مدیریت پروژه ساختمانی بومی (Standalone)، مستقل و بدون وابستگی به هیچ سیستم خارجی.
|
||||
|
||||
## Auth
|
||||
- `POST /auth/login` `{username, password}` → `{data:{token, user:{id, username, display_name, role}}}`
|
||||
- `GET /auth/dev-roles` (در حالت توسعه) → لیست نقشهای آماده جهت ورود سریع
|
||||
- `POST /auth/dev-login` `{role}` (در حالت توسعه) → دریافت سریع توکن نقش انتخابی
|
||||
- همه endpointها (جز لاگین و فایلهای عمومی) هدر `Authorization: Bearer <token>` میخواهند.
|
||||
- خطاها: `{"error": {"code", "message", "field"?}}` — کدها: `VALIDATION_ERROR` (400)،
|
||||
`UNAUTHORIZED` (401)، `FORBIDDEN` (403)، `NOT_FOUND` (404)، `CONFLICT` (409)،
|
||||
`UPSTREAM_UNAVAILABLE` (503).
|
||||
|
||||
## تاریخ جلالی
|
||||
- **همه تاریخهای قابل مشاهده در UI شمسیاند.** API هم ISO میپذیرد/میدهد و هم شمسی.
|
||||
- هر تسک علاوه بر `start_date`/`due_date` (ISO) فیلدهای `start_date_jalali`/
|
||||
`due_date_jalali` را هم برمیگرداند («1405/06/23»).
|
||||
- `PATCH /tasks/{id}/dates` هر دو قالب را میپذیرد: `1405/06/23` یا `2026-09-14`
|
||||
(سال ≥ 1700 میلادی، کمتر از آن شمسی تفسیر میشود). روز نامعتبر → 400.
|
||||
- تبدیلها در `backend/app/utils/jalali.py` و `frontend/src/lib/jalali.ts` انجام میشوند.
|
||||
|
||||
## Dashboard
|
||||
- `GET /dashboard/today` → `{data:{summary:{problems,warnings,ok}, sections:{overdue,due_today,awaiting_qc,failed_qc,blocked,upcoming}}}`
|
||||
- `awaiting_qc` = وضعیتهای `pre_qc_pending|exec_qc_pending|ready`
|
||||
- `failed_qc` = وضعیتهای `pre_qc_failed|qc_failed_rework`
|
||||
|
||||
## Projects
|
||||
- `GET /projects` → `[...{id, name, identifier, description, active, erp_project_id, created_at, stats:{total, awaiting_qc, failed_qc, overdue}}]`
|
||||
- `POST /projects` → ساخت پروژه جدید
|
||||
- `GET /projects/{id}` → جزئیات پروژه به همراه آمارهای تسکها
|
||||
- `PUT /projects/{id}` | `DELETE /projects/{id}`
|
||||
- `GET /projects/{id}/gantt` → دادههای گانت چارت پروژه
|
||||
- `PATCH /projects/{id}/erp-mapping {erp_project_id}`
|
||||
|
||||
## Tasks
|
||||
- `GET /tasks?project_id=&search=&qc_status=&status=&page=&page_size=`
|
||||
- `POST /tasks` → ثبت فعالیت/تسک جدید
|
||||
- `GET /tasks/{id}` → تسک + `relations` + `children` + **`qc`** + **`qc_definitions`** + `defects` + `open_defects`
|
||||
- `PUT /tasks/{id}` → ویرایش اطلاعات تسک
|
||||
- `DELETE /tasks/{id}` → حذف تسک
|
||||
- `PATCH /tasks/{id}/progress {percentage: 0..100}`
|
||||
- **قاعده ۱:** اگر `percentage > 0` و بازرسی پیش از اجرا تأیید نشده باشد → 400
|
||||
«بازرسی پیش از اجرا تأیید نشده است (N سؤال باقیمانده)»
|
||||
- **قاعده ۲:** اگر `percentage >= 100` و هر سؤال الزامی تأییدنشده/مردود باشد → 400
|
||||
«تا زمانی که همه بازرسیهای الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (…)»
|
||||
- `PATCH /tasks/{id}/dates {start_date?, due_date?}` (ISO یا شمسی)
|
||||
- `POST /tasks/relations` | `DELETE /tasks/relations/{id}` → مدیریت روابط وابستگی تسکها (FS, SS, FF, SF)
|
||||
|
||||
### شکل بلوک `qc` در جزئیات تسک
|
||||
```json
|
||||
{"pre": {"total":3,"passed":3,"failed":0,"pending":0,"na":0,"required_unresolved":0,"status":"passed"},
|
||||
"exec": {"total":6,"passed":2,"failed":1,"pending":3,"na":0,"required_unresolved":4,"status":"pending"},
|
||||
"all": { /* مجموع */ },
|
||||
"status": "exec_qc_pending",
|
||||
"can_start": true, "can_complete": false,
|
||||
"block_reasons": ["تا زمانی که همه بازرسیهای الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (۴ سؤال باقیمانده، ۱ مورد مردود)"]}
|
||||
```
|
||||
|
||||
### وضعیتهای `qc_status` روی تسک
|
||||
`none` · `pre_qc_pending` · `pre_qc_failed` · `ready` · `exec_qc_pending` ·
|
||||
`in_progress` · `qc_failed_rework` · `final_passed`
|
||||
|
||||
## QC — تعریف (Definition)
|
||||
### قالبهای سراسری (قابل استفاده مجدد)
|
||||
- `GET /qc/templates` → `[{id,name,activity_type,description,questions:[{id,phase,text,sort_order,required}]}]`
|
||||
- `POST /qc/templates {name, activity_type?, description?, questions?:[…]}` → `{data:{id}}`
|
||||
- `PATCH /qc/templates/{id} {name?,activity_type?,description?}`
|
||||
- `DELETE /qc/templates/{id}`
|
||||
- `POST /qc/templates/{id}/questions {phase,text,required?,sort_order?}`
|
||||
- `PATCH /qc/questions/{qid} {text?,phase?,required?,sort_order?}`
|
||||
- `DELETE /qc/questions/{qid}`
|
||||
- `PUT /qc/templates/{id}/questions {questions:[…]}` — جایگزینی گروهی (برای ادیتور)
|
||||
|
||||
`phase` ∈ `"pre"` (پیش از اجرا) · `"during"` (هنگام اجرا) · `"after"` (پس از اجرا)
|
||||
|
||||
### تعریف مخصوص تسک (کپی مستقل — ویرایش آن قالب سراسری را تغییر نمیدهد)
|
||||
- `GET /qc/tasks/{task_id}` → `{data:{task, definitions:[{id,name,source_template_id,created_at,questions:[{…,result,history_count}],summary}], readiness}}`
|
||||
- `POST /qc/tasks/{task_id}/definition {name?, template_id?}` → **کپی عمیق** سؤالات قالب
|
||||
- `PATCH /qc/definitions/{id} {name}` | `DELETE /qc/definitions/{id}`
|
||||
- `POST /qc/definitions/{id}/questions {text,phase,required?,sort_order?}`
|
||||
- `PATCH /qc/task-questions/{qid} {text?,phase?,required?,sort_order?}`
|
||||
- `DELETE /qc/task-questions/{qid}`
|
||||
|
||||
## QC — اجرا (Execution)
|
||||
- `PUT /qc/task-questions/{qid}/result {answer:"pass"|"fail"|"na", comment?}`
|
||||
→ `{data:{question_id, answer, task_qc_status, readiness}}`
|
||||
- پاسخها **append-only** هستند (تاریخچه میماند؛ بازرسی مجدد پس از رفع عیب ممکن است)
|
||||
- تعریف و اجرا کاملاً جدا هستند: هنگام ساخت/کپی سؤال هیچ پاس/ردی ثبت نمیشود.
|
||||
- عیبها:
|
||||
- `POST /qc/defects {task_id, question_id?, title, description?, severity?, corrective_action?, photo_id?}`
|
||||
- `POST /qc/defects/{id}/resolve {resolution_notes?}`
|
||||
- عکس عیب: `POST /files` (multipart) → `{data:{id, url}}` سپس `photo_id`
|
||||
|
||||
## فایلها
|
||||
- `POST /files` multipart (jpg/png/webp تا ۱۰MB) → `{data:{id,url,filename}}`
|
||||
- `GET /files/{id}`
|
||||
|
||||
## Materials / Resources (یکپارچگی ERP)
|
||||
- `GET/POST /tasks/{task_id}/materials` · `PATCH/DELETE /materials/{req_id}`
|
||||
- `GET /erp/materials?search=` · `GET /erp/projects`
|
||||
- `POST /materials/{req_id}/reserve|purchase-request` · `GET /reservations` · `POST /reservations/{id}/release`
|
||||
- `GET/PATCH /purchase-requests` · `POST /projects/{project_id}/erp-mapping`
|
||||
@@ -0,0 +1,174 @@
|
||||
# راهنمای جامع معماری و مدل داده پایگاهداده سیستم مدیریت پروژه عمرانی (Construction PM)
|
||||
|
||||
> **دستورالعمل برای هوش مصنوعی (AI Prompt / Context):**
|
||||
> این سند، مشخصات دقیق معماری، موجودیتها (Entities)، فیلدها، روابط و قواعد تجاری یک سیستم بومی مدیریت پروژه عمرانی (Construction Project Management) است.
|
||||
> لطفاً این راهنما را مطالعه کرده و بر اساس آن، ساختار سیستم، نحوه ذخیرهسازی دادهها، جریان کار (Workflow) و روابط میان جداول را برای کاربر تحلیل و تشریح نما.
|
||||
|
||||
---
|
||||
|
||||
## ۱. نمای کلی سیستم و فناوریها (Architecture Overview)
|
||||
|
||||
- **ماهیت سامانه:** پلتفرم یکپارچه و بومی مدیریت پروژههای عمرانی و ساختمانی (Native Construction PM Platform).
|
||||
- **لایههای نرمافزاری:**
|
||||
- **فرانتاند:** React + TypeScript + TailwindCSS + TanStack Query (رابط کاربری مدرن با پشتیبانی تقویم شمسی و نمودار گانت تعاملی).
|
||||
- **بکاند:** Python 3.10+ با فریمورک FastAPI و SQLAlchemy 2.0 (Async ORM با درایور aiosqlite / asyncpg).
|
||||
- **پایگاهداده:** SQLite / PostgreSQL با ساختار کاملاً رابطهای (Relational) و فیلدهای ساختیافته JSON.
|
||||
- **شناسههای یکتا (Primary Keys):** تمامی جداول از کلیدهای اصلی ۱۰۰٪ بومی از نوع `UUID v4` استفاده میکنند. هیچگونه شناسه یا وابستگی به سیستمهای قدیمی (Legacy) وجود ندارد.
|
||||
- **منطق تاریخها:** تاریخها در پایگاهداده به صورت استاندارد میلادی (`Date` و `DateTime`) ذخیره میشوند و تبدیلهای شمسی بهصورت لایهای و لحظهای برای نمایش به کاربر انجام میگیرد.
|
||||
|
||||
---
|
||||
|
||||
## ۲. نمودار رابطه موجودیتها (ERD)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
PROJECTS ||--o{ TASKS : "شامل میشود (1:N)"
|
||||
PROJECTS ||--o{ PROJECT_MEMBERS : "تخصیص اعضا (1:N)"
|
||||
PM_USERS ||--o{ PROJECT_MEMBERS : "عضویت کاربر (1:N)"
|
||||
PM_USERS ||--o{ PROJECT_ASSIGNMENTS : "دسترسی پروژه (1:N)"
|
||||
PROJECTS ||--o{ PROJECT_ASSIGNMENTS : "محدوده دسترسی (1:N)"
|
||||
PM_USERS ||--o{ TASKS : "مسئول وظیفه (Assignee)"
|
||||
|
||||
TASKS ||--o{ TASKS : "ساختار درختی WBS (والد/فرزند)"
|
||||
TASKS ||--o{ TASK_RELATIONS : "وظیفه پیشنیاز (from_task)"
|
||||
TASKS ||--o{ TASK_RELATIONS : "وظیفه پسنیاز (to_task)"
|
||||
PROJECTS ||--o{ TASK_RELATIONS : "روابط گانت پروژه"
|
||||
|
||||
TASKS ||--o{ TASK_QC_DEFINITIONS : "چکلیستهای کنترلی"
|
||||
TASK_QC_DEFINITIONS ||--o{ TASK_QC_QUESTIONS : "آیتمهای کنترلی"
|
||||
TASK_QC_QUESTIONS ||--o{ TASK_QC_RESULTS : "تاریخچه بازرسیها"
|
||||
|
||||
TASKS ||--o{ DEFECTS : "عیوب و عدم انطباقها"
|
||||
TASK_QC_QUESTIONS ||--o{ DEFECTS : "علت ریشهای عیب (اختیاری)"
|
||||
|
||||
QC_TEMPLATES ||--o{ QC_QUESTIONS : "قالبهای عمومی بازرسی"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ۳. شرح تفصیلی جداول و موجودیتها (Data Dictionary)
|
||||
|
||||
### ۱. جدول پروژهها (`projects`)
|
||||
موجودیت ریشه سامانه که اطلاعات اصلی کارگاههای عمرانی را نگهداری میکند.
|
||||
|
||||
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | **PK** | شناسه یکتای پروژه |
|
||||
| `name` | `VARCHAR(255)` | Index | نام پروژه (مانند «پروژه برج بهارستان») |
|
||||
| `description` | `TEXT` | - | شرح پروژه، الزامات و مشخصات فنی |
|
||||
| `location` | `VARCHAR(255)` | - | موقعیت جغرافیایی / نشانی کارگاه |
|
||||
| `project_manager` | `VARCHAR(255)` | - | نام مدیر یا سرپرست کارگاه |
|
||||
| `status` | `VARCHAR(32)` | Index | وضعیت: `PLANNED`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELLED` |
|
||||
| `planned_start` | `DATE` | - | تاریخ شروع مصوب (محاسبه و تجمیع خودکار از کمترین تاریخ شروع تسکها) |
|
||||
| `planned_end` | `DATE` | - | تاریخ پایان مصوب (محاسبه و تجمیع خودکار از بیشترین تاریخ پایان تسکها) |
|
||||
| `actual_start` | `DATE` | - | تاریخ شروع واقعی (محاسبه خودکار از زمان آغاز اولین تسک) |
|
||||
| `actual_end` | `DATE` | - | تاریخ پایان واقعی (محاسبه خودکار با اتمام ۱۰۰٪ تمام تسکها) |
|
||||
| `progress` | `FLOAT` | - | درصد پیشرفت فیزیکی پروژه (میانگین وزنی درصد پیشرفت تسکها از ۰ تا ۱۰۰) |
|
||||
| `created_at` | `TIMESTAMP` | - | زمان ثبت رکورد |
|
||||
| `updated_at` | `TIMESTAMP` | - | زمان آخرین ویرایش رکورد |
|
||||
|
||||
---
|
||||
|
||||
### ۲. جدول وظایف و فعالیتها (`tasks`)
|
||||
اصلیترین موجودیت سامانه برای ساختار شکست کار (WBS)، گانت، تخصیص منابع و کنترل کیفی.
|
||||
|
||||
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | **PK** | شناسه یکتای وظیفه |
|
||||
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژهای که وظیفه به آن تعلق دارد (Cascade Delete) |
|
||||
| `parent_id` | `UUID` (Nullable) | **FK** -> `tasks.id` | والد در ساختار درختی WBS با عمق نامحدود (فاز ➔ فعالیت ➔ زیرتسک) |
|
||||
| `title` | `VARCHAR(512)` | - | عنوان وظیفه (مانند «بتنریزی سقف طبقه ۲») |
|
||||
| `description` | `TEXT` | - | مشخصات و الزامات کار |
|
||||
| `status` | `VARCHAR(32)` | Index | وضعیت: `TODO`, `IN_PROGRESS`, `BLOCKED`, `COMPLETED`, `CANCELLED` |
|
||||
| `priority` | `VARCHAR(32)` | - | اولویت: `low`, `medium`, `high`, `urgent` |
|
||||
| `assignee_id` | `UUID` (Nullable) | **FK** -> `pm_users.id` | کاربر سیستم مسئول اجرای وظیفه |
|
||||
| `assignee_name` | `VARCHAR(255)` | - | نام متنی کاربر جهت نمایش سریع |
|
||||
| `planned_start` | `DATE` | - | تاریخ شروع برنامهریزیشده |
|
||||
| `planned_end` | `DATE` | - | تاریخ پایان برنامهریزیشده |
|
||||
| `actual_start` | `DATE` (Nullable) | - | شروع واقعی (ثبت خودکار هنگام تغییر پیشرفت به بیشتر از صفر) |
|
||||
| `actual_end` | `DATE` (Nullable) | - | پایان واقعی (ثبت خودکار در زمان تکمیل ۱۰۰٪) |
|
||||
| `duration` | `INTEGER` | - | مدت زمان برآورد شده بر حسب روز کاری |
|
||||
| `progress` | `INTEGER` | - | درصد پیشرفت واقعی از ۰ تا ۱۰۰ |
|
||||
| `qc_status` | `VARCHAR(32)` | - | وضعیت کنترل کیفی: `none`, `pre_qc_pending`, `pre_qc_failed`, `ready`, `exec_qc_pending`, `in_progress`, `qc_failed_rework`, `final_passed` |
|
||||
| `human_resources` | `JSON` | - | لیست نقشها و نفرات: `[{"role": "آرماتوربند", "count": 4}]` |
|
||||
| `materials` | `JSON` | - | لیست مصالح و تجهیزات/ماشینآلات: `[{"name": "سیمان تیپ ۲", "unit": "تن", "specifications": ["عیار ۳۵۰"], "count": 10}, {"name": "تاور کرین", "unit": "دستگاه", "specifications": ["ظرفیت ۸ تن"], "count": 1}]` |
|
||||
| `created_at` | `TIMESTAMP` | - | تاریخ ایجاد رکورد |
|
||||
| `updated_at` | `TIMESTAMP` | - | تاریخ آخرین بهروزرسانی |
|
||||
|
||||
---
|
||||
|
||||
### ۳. جدول روابط وابستگی و گانت (`task_relations`)
|
||||
پایهگذار روش مسیر بحرانی (CPM Scheduling) و شبکه فعالیتهای گانت.
|
||||
|
||||
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|
||||
|---|---|---|---|
|
||||
| `id` | `UUID` | **PK** | شناسه رابطه |
|
||||
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه مربوطه |
|
||||
| `from_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت پیشنیاز (Predecessor) |
|
||||
| `to_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت وابسته / پسنیاز (Successor) |
|
||||
| `relation_type` | `VARCHAR(8)` | - | نوع وابستگی: `FS` (پایان به شروع)، `SS` (شروع به شروع)، `FF` (پایان به پایان)، `SF` (شروع به پایان) |
|
||||
| `lag_days` | `INTEGER` | - | فاصله زمانی مجاز (تاخیر یا تقدم) بر حسب روز |
|
||||
| `created_at` | `TIMESTAMP` | - | زمان ثبت رابطه |
|
||||
|
||||
*محدودیت (Constraint):* زوج `(from_task_id, to_task_id)` یکتا بوده و از ایجاد حلقه (Cycle) در الگوریتم CPM جلوگیری میشود.
|
||||
|
||||
---
|
||||
|
||||
### ۴. حوزه کنترل کیفیت و بازرسی فنی (QC Domain)
|
||||
|
||||
این بخش تضمین میکند کلیه اقدامات اجرایی کارگاه دارای تاییدیههای استاندارد باشند:
|
||||
|
||||
#### الف) قالبهای سراسری بازرسی (`qc_templates` و `qc_questions`)
|
||||
- **`qc_templates`:** کتابخانه فرمهای استاندارد کیفی (مثلاً قالب بازرسی بتنریزی، قالب سفتکاری).
|
||||
- **`qc_questions`:** سوالات و ضوابط کنترلی در سه فاز اجرایی:
|
||||
- `pre`: پیش از اجرا (آمادگی کارگاه، تایید نقشهها، مصالح ورودی).
|
||||
- `during`: حین اجرا (دمای بتن، ویبره مناسب، ایمنی).
|
||||
- `after`: پس از اجرا (عملآوری، مقاومت فشاری نمونهها).
|
||||
|
||||
#### ب) فرمهای کیفی اختصاصیافته به وظایف (`task_qc_definitions` و `task_qc_questions`)
|
||||
- با تخصیص چکلیست به یک تسک، یک نسخه مستقل از قالب بر روی وظیفه ایجاد میشود تا هرگونه تغییر روی قالب سراسری به تاریخچه بازرسیهای ثبتشده آسیب نزند.
|
||||
|
||||
#### ج) نتایج و تاریخچه بازرسی (`task_qc_results`)
|
||||
- سیستم ثبت پیوسته (Append-Only Log) بدون امکان حذف؛ هر ارزیابی بازرس شامل:
|
||||
- `question_id`: شناسه آیتم بازرسی.
|
||||
- `answer`: پاسخ بازرس (`pass` تایید، `fail` عدم انطباق، `na` عدم کاربرد).
|
||||
- `comment`: توضیحات فنی و نواقص مشاهدهشده.
|
||||
- `evaluated_by`: نام بازرس ناظر.
|
||||
- `evaluated_at`: زمان دقیق بازرسی.
|
||||
|
||||
#### د) جدول عیوب و عدم انطباقها (`defects`)
|
||||
- ثبت رخدادهای نامنطبق در جریان اجرای کار:
|
||||
- `id` (UUID - PK): شناسه عیب.
|
||||
- `task_id` (UUID - FK -> `tasks.id`): وظیفه مربوطه.
|
||||
- `question_id` (UUID - FK اختیاری): سوال بازرسی که منجر به رد شدن شده است.
|
||||
- `title`: عنوان عدم انطباق (مثلاً: «کرموشدگی بتن ستون جنوبی»).
|
||||
- `description`: شرح دقیق ایراد.
|
||||
- `severity`: درجه شدت عیب (`minor`, `medium`, `major`, `critical`).
|
||||
- `status`: وضعیت رفع (`open`, `in_progress`, `resolved`).
|
||||
- `corrective_action`: دستور اقدام اصلاحی صادرشده توسط مهندس ناظر.
|
||||
- `resolution_notes`: شرح نحوه رفع عیب.
|
||||
- `photo_path`: مسیر ذخیره تصویر مستند عیب در سرور.
|
||||
- `resolved_at`: زمان تایید رفع عیب.
|
||||
|
||||
---
|
||||
|
||||
### ۵. احراز هویت و دسترسیها (`pm_users`, `project_members`, `project_assignments`)
|
||||
|
||||
- **`pm_users`:** کاربران سازمانی سیستم (`username`, `password_hash`, `display_name`, `role`).
|
||||
- نقشهای سیستمی: `project_manager` (دسترسی نامحدود به تمام پروژهها)، `project_supervisor` (دسترسی محدود به پروژههای مشخص).
|
||||
- **`project_members`:** اعضای منتسب به هر پروژه با نقش کارگاهی:
|
||||
- `role`: `project_manager`, `site_engineer`, `qc_inspector`, `member`.
|
||||
- **`project_assignments`:** جدول دامنه دسترسی برای ناظران و سرپرستان دارای دسترسی محدود (Scoped Roles).
|
||||
|
||||
---
|
||||
|
||||
## ۴. قواعد و منطقهای تجاری نهفته در سیستم (Business Invariants)
|
||||
|
||||
1. **گیتینگ کنترل کیفی (QC Gating Rule):**
|
||||
اگر وظیفهای دارای چکلیست بازرسی فاز `pre` (پیش از اجرا) باشد، تا زمانی که تمامی این سوالات وضعیت `pass` یا `na` دریافت نکنند، سیستم اجازه شروع کار (`IN_PROGRESS`) و افزایش پیشرفت را نمیدهد.
|
||||
2. **محاسبات پیشبینی مسیر بحرانی (CPM Logic):**
|
||||
محاسبات زمانبندی با عبور رفت و برگشت (Forward & Backward Pass) تاریخهای زودهنگام و دیرهنگام را استخراج کرده و فعالیتهای با شناوری صفر (`Total Float = 0`) را به عنوان مسیر بحرانی مشخص میکند.
|
||||
3. **چرخه حیات خودکار تاریخهای واقعی:**
|
||||
با اولین تغییر در پیشرفت تسک (`progress > 0`)، فیلد `actual_start` به تاریخ جاری تنظیم میگردد. با رسیدن پیشرفت به `100%`، فیلد `actual_end` تنظیم و وضعیت وظیفه خودکار به `COMPLETED` تغییر مییابد.
|
||||
4. **تجمیع پروژه (Rollup Logic):**
|
||||
تاریخ شروع، پایان و درصد پیشرفت فیزیکی کل پروژه همواره حاصل تجمیع و میانگینگیری پویای وظایف موجود در پروژه است.
|
||||
@@ -0,0 +1,91 @@
|
||||
# PM — سامانه یکپارچه مدیریت پروژههای ساخت و کنترل کیفیت
|
||||
|
||||
اپلیکیشن موبایلمحور و تحت وب برای مدیریت جامع پروژههای عمرانی و ساخت: مدیریت سلسلهمراتب فعالیتها (WBS)، زمانبندی و گانت چارت، مسیر بحرانی (CPM)، سیستم پیشرفته کنترل کیفیت (QC)، مدیریت مصالح و انبار (با اتصال به ERP) و داشبورد عملیاتی روزانه.
|
||||
|
||||
---
|
||||
|
||||
## دسترسی سریع
|
||||
|
||||
| سامانه | آدرس شبکه محلی | آدرس محلی سیستم |
|
||||
|---|---|---|
|
||||
| **PM (وب + نمای موبایل)** | http://192.168.1.103:8083 | http://localhost:8083 |
|
||||
| **PM API Health** | http://192.168.1.103:8083/api/v1/health | http://localhost:8083/api/v1/health |
|
||||
|
||||
**ورود پیشفرض:** `admin` / `pm123456` (قابل تغییر از طریق فایل `.env`)
|
||||
|
||||
---
|
||||
|
||||
## معماری سامانه
|
||||
|
||||
```
|
||||
PM Frontend (React 19, Tailwind CSS, تقویم شمسی, نمای موبایل و دسکتاپ)
|
||||
│ /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 Platform** | مدیریت کامل در دیتابیس بومی PM |
|
||||
| چکلیستها و کنترل کیفیت (QC) | **PM Platform** | پیش از اجرا، حین اجرا و پس از اجرا |
|
||||
| ثبت عیوب و اقدامات اصلاحی | **PM Platform** | الصاق تصویر، شدت عیب، پیگیری رفع |
|
||||
| کاتالوگ مصالح و موجودی واقعی انبار | **ERP** | استعلام برخط موجودی از طریق API |
|
||||
| رزرو مصالح و درخواستهای خرید | **PM Platform** | رزرو نرم بدون دستکاری انبار تا زمان تأیید |
|
||||
|
||||
---
|
||||
|
||||
## قابلیتهای کلیدی
|
||||
|
||||
### ۱. مدیریت زمانبندی و گانت چارت (CPM Engine)
|
||||
- ایجاد پروژهها با کدهای اختصاصی و تیم پروژه
|
||||
- ساختار شکست کار (WBS) در عمقهای نامحدود (فاز، بخش، فعالیت، زیرفعالیت)
|
||||
- وابستگیهای چهارگانه بین فعالیتها (`FS`, `SS`, `FF`, `SF`) همراه با روزهای تأخیر (Lag)
|
||||
- محاسبه خودکار مسیر بحرانی (Critical Path) و شناوری کل فعالیتها
|
||||
- تعریف مایلستونها و نقاط عطف کلیدی پروژه
|
||||
|
||||
### ۲. چرخه سهمرحلهای کنترل کیفیت (QC)
|
||||
- **مراحل ارزیابی:** پیش از اجرا (`pre`) · حین اجرا (`during`) · پس از اجرا (`after`)
|
||||
- **قوانین دروازهای سرور:**
|
||||
- فعالیت تا زمان تأیید الزامات پیش از اجرا اجازه شروع (پیشرفت > ۰) ندارد.
|
||||
- فعالیت تا زمان پاس شدن همه الزامات الزامی اجازه رسیدن به ۱۰۰٪ ندارد.
|
||||
- ثبت عیوب همراه با تصاویر و الزام به بازرسی مجدد پس از اصلاح.
|
||||
|
||||
### ۳. مدیریت منابع و ارتباط با ERP
|
||||
- تخصیص نفرات، ماشینآلات و مصالح به تفکیک فعالیت
|
||||
- اتصال پروژه به پروژه متناظر در ERP جهت استعلام موجودی انبارهای کارگاه
|
||||
- رزرو نرم مصالح جهت جلوگیری از تداخل مصرف در بخشهای مختلف پروژه
|
||||
|
||||
### ۴. تقویم و تاریخهای شمسی
|
||||
- تقویم بومی جلالی بدون خطا و با رعایت روزهای کاری
|
||||
- ثبت و نمایش روان تاریخها در رابط کاربری به زبان فارسی
|
||||
|
||||
---
|
||||
|
||||
## راهاندازی و اجرا
|
||||
|
||||
اجرای آسان با اسکریپت پاورشل یا داکر کامپوز:
|
||||
|
||||
```powershell
|
||||
# اجرای خودکار از پوشه PM:
|
||||
powershell -ExecutionPolicy Bypass -File .\start.ps1
|
||||
```
|
||||
|
||||
یا مستقیماً با داکر:
|
||||
```powershell
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## قرارداد API
|
||||
مستندات کامل اندپوینتها: [`API_CONTRACT.md`](API_CONTRACT.md)
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /srv/pm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt .
|
||||
# PIP_INDEX_URL overridable like the ERP api/bot images (mirror for restricted networks).
|
||||
ARG PIP_INDEX_URL=https://pypi.org/simple
|
||||
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY tools ./tools
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1 @@
|
||||
"""PM application package."""
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Central authorization (RBAC) — roles, permissions and per-project scoping.
|
||||
|
||||
Everything authorization-related lives in this module so that adding a role is
|
||||
one entry in `ROLE_PERMISSIONS` (plus a nav entry on the frontend), not a hunt
|
||||
through the routers.
|
||||
|
||||
Two independent questions, answered separately:
|
||||
|
||||
* **Role → permissions** (`Perm`, `ROLE_PERMISSIONS`) decides *which operations*
|
||||
a user may call at all.
|
||||
* **Project scope** decides *which projects* those operations may touch. A role
|
||||
listed in `SCOPED_ROLES` is limited to the rows in `project_assignments`
|
||||
(an empty set means "no project at all"); every other role is unrestricted.
|
||||
|
||||
Routers must go through the dependencies and helpers below — hiding a button in
|
||||
the UI is a convenience, never the enforcement.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import Select, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.errors import ForbiddenError, NotFoundError
|
||||
from app.models import PMUser, ProjectAssignment, Task
|
||||
from app.security import get_current_user
|
||||
|
||||
logger = logging.getLogger("pm.authz")
|
||||
|
||||
|
||||
# --- roles --------------------------------------------------------------------
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
project_manager = "project_manager"
|
||||
project_supervisor = "project_supervisor"
|
||||
|
||||
|
||||
ROLE_LABELS: dict[str, str] = {
|
||||
Role.project_manager.value: "مدیر پروژه",
|
||||
Role.project_supervisor.value: "سرپرست پروژه",
|
||||
}
|
||||
|
||||
# Users created before RBAC existed keep their previous (full) access.
|
||||
DEFAULT_ROLE = Role.project_manager
|
||||
|
||||
# Roles whose access is limited to their assigned projects.
|
||||
SCOPED_ROLES: frozenset[Role] = frozenset({Role.project_supervisor})
|
||||
|
||||
|
||||
# --- permissions --------------------------------------------------------------
|
||||
|
||||
|
||||
class Perm(str, enum.Enum):
|
||||
"""One value per authorizable operation — keep it coarse and stable."""
|
||||
|
||||
dashboard_view = "dashboard.view"
|
||||
projects_view = "projects.view"
|
||||
projects_create = "projects.create"
|
||||
projects_update = "projects.update"
|
||||
tasks_view = "tasks.view"
|
||||
tasks_create = "tasks.create"
|
||||
tasks_update = "tasks.update"
|
||||
tasks_delete = "tasks.delete"
|
||||
tasks_progress = "tasks.progress"
|
||||
tasks_dates = "tasks.dates"
|
||||
qc_view = "qc.view"
|
||||
# executing QC: recording answers and raising/resolving defects
|
||||
qc_execute = "qc.execute"
|
||||
# QC structure: global templates + per-task definitions
|
||||
qc_define = "qc.define"
|
||||
materials_view = "materials.view"
|
||||
materials_manage = "materials.manage"
|
||||
users_manage = "users.manage"
|
||||
files_upload = "files.upload"
|
||||
|
||||
|
||||
# Project manager: full access to everything, across every project.
|
||||
_MANAGER_PERMISSIONS: frozenset[str] = frozenset(p.value for p in Perm)
|
||||
|
||||
# Project supervisor: executes the assigned projects — reads the plan, records
|
||||
# progress and QC results, needs the material list; never edits the QC
|
||||
# structure, templates, procurement or integration settings.
|
||||
_SUPERVISOR_PERMISSIONS: frozenset[str] = frozenset(
|
||||
{
|
||||
Perm.dashboard_view.value,
|
||||
Perm.projects_view.value,
|
||||
Perm.tasks_view.value,
|
||||
Perm.tasks_progress.value,
|
||||
Perm.qc_view.value,
|
||||
Perm.qc_execute.value,
|
||||
Perm.materials_view.value,
|
||||
Perm.files_upload.value,
|
||||
}
|
||||
)
|
||||
|
||||
ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
|
||||
Role.project_manager: _MANAGER_PERMISSIONS,
|
||||
Role.project_supervisor: _SUPERVISOR_PERMISSIONS,
|
||||
}
|
||||
|
||||
|
||||
def permissions_for(role: Role) -> frozenset[str]:
|
||||
return ROLE_PERMISSIONS.get(role, frozenset())
|
||||
|
||||
|
||||
def role_of(user: PMUser) -> Role:
|
||||
try:
|
||||
return Role(user.role)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"unknown role %r on user %s — falling back to %s", user.role, user.username, DEFAULT_ROLE.value
|
||||
)
|
||||
return DEFAULT_ROLE
|
||||
|
||||
|
||||
# --- request context ----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
"""Who is calling, what they may do, and which projects they may touch.
|
||||
|
||||
`project_uuids` — native projects (`projects.id`).
|
||||
None for an unscoped role (project manager). For a scoped role a set — possibly empty — means "only these".
|
||||
"""
|
||||
|
||||
user: PMUser
|
||||
role: Role
|
||||
project_uuids: frozenset[uuid.UUID] | None = None
|
||||
|
||||
@property
|
||||
def permissions(self) -> frozenset[str]:
|
||||
return permissions_for(self.role)
|
||||
|
||||
@property
|
||||
def role_label(self) -> str:
|
||||
return ROLE_LABELS.get(self.role.value, self.role.value)
|
||||
|
||||
@property
|
||||
def all_projects(self) -> bool:
|
||||
return self.project_uuids is None
|
||||
|
||||
def can(self, permission: Perm | str) -> bool:
|
||||
value = permission.value if isinstance(permission, Perm) else permission
|
||||
return value in self.permissions
|
||||
|
||||
def require(self, permission: Perm | str) -> None:
|
||||
if not self.can(permission):
|
||||
raise ForbiddenError(f"نقش «{self.role_label}» به این عملیات دسترسی ندارد")
|
||||
|
||||
def allows_project(self, project_id) -> bool:
|
||||
"""Check whether the user is allowed to access the specified project."""
|
||||
if self.project_uuids is None:
|
||||
return True
|
||||
try:
|
||||
pid = uuid.UUID(str(project_id))
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
return pid in self.project_uuids
|
||||
|
||||
def ensure_project(self, project_id) -> None:
|
||||
"""Raise 403 when the project is outside this user's scope."""
|
||||
if not self.allows_project(project_id):
|
||||
raise ForbiddenError("این پروژه به شما اختصاص داده نشده است")
|
||||
|
||||
allows_native_project = allows_project
|
||||
ensure_native_project = ensure_project
|
||||
|
||||
def scope(self, stmt: Select, column) -> Select:
|
||||
"""Restrict a query over project UUIDs."""
|
||||
if self.project_uuids is None:
|
||||
return stmt
|
||||
return stmt.where(column.in_(self.project_uuids))
|
||||
|
||||
scope_native = scope
|
||||
|
||||
@property
|
||||
def scoped_project_count(self) -> int:
|
||||
"""How many projects a scoped user may see (0 when nothing is assigned)."""
|
||||
return len(self.project_uuids or ())
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"role": self.role.value,
|
||||
"role_label": self.role_label,
|
||||
"permissions": sorted(self.permissions),
|
||||
"scope": {
|
||||
"all_projects": self.all_projects,
|
||||
"project_count": None if self.all_projects else self.scoped_project_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def assigned_scope(db: AsyncSession, user_id) -> frozenset[uuid.UUID]:
|
||||
"""Load assigned project UUIDs for a scoped user."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ProjectAssignment.project_id).where(
|
||||
ProjectAssignment.user_id == user_id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assigned = set(r for r in rows if r is not None)
|
||||
|
||||
# Also resolve projects assigned via projects.pm_user_id or user_project_members in the shared DB
|
||||
pm_user = await db.get(PMUser, user_id)
|
||||
if pm_user and pm_user.username:
|
||||
erp_rows = (
|
||||
await db.execute(
|
||||
text("""
|
||||
SELECT p.id FROM projects p JOIN users u ON p.pm_user_id = u.id WHERE u.username = :username
|
||||
UNION
|
||||
SELECT upm.project_id FROM user_project_members upm JOIN users u ON upm.user_id = u.id WHERE u.username = :username
|
||||
"""),
|
||||
{"username": pm_user.username},
|
||||
)
|
||||
).scalars().all()
|
||||
for r in erp_rows:
|
||||
if r:
|
||||
try:
|
||||
assigned.add(uuid.UUID(str(r)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return frozenset(assigned)
|
||||
|
||||
|
||||
async def build_context(db: AsyncSession, user: PMUser) -> AuthContext:
|
||||
"""Resolve role + project scope for a user."""
|
||||
role = role_of(user)
|
||||
if role not in SCOPED_ROLES:
|
||||
return AuthContext(user=user, role=role)
|
||||
native = await assigned_scope(db, user.id)
|
||||
return AuthContext(user=user, role=role, project_uuids=native)
|
||||
|
||||
|
||||
async def get_auth_context(
|
||||
user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
) -> AuthContext:
|
||||
return await build_context(db, user)
|
||||
|
||||
|
||||
def require_permission(permission: Perm) -> Callable[..., Awaitable[AuthContext]]:
|
||||
"""Dependency: authenticated **and** allowed to perform `permission`."""
|
||||
|
||||
async def _guard(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
|
||||
ctx.require(permission)
|
||||
return ctx
|
||||
|
||||
return _guard
|
||||
|
||||
|
||||
# --- data-access guards -------------------------------------------------------
|
||||
|
||||
|
||||
async def resolve_task(db: AsyncSession, task_ref: str | uuid.UUID) -> Task | None:
|
||||
"""Find a native task by UUID."""
|
||||
task_ref = task_ref.strip() if isinstance(task_ref, str) else task_ref
|
||||
try:
|
||||
task_uuid = uuid.UUID(str(task_ref))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
return await db.scalar(select(Task).where(Task.id == task_uuid))
|
||||
|
||||
|
||||
async def scoped_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> Task:
|
||||
"""Load a native task: 404 unknown, 403 out of scope."""
|
||||
task = await resolve_task(db, task_ref)
|
||||
if task is None:
|
||||
raise NotFoundError("تسک یافت نشد")
|
||||
ctx.ensure_project(task.project_id)
|
||||
return task
|
||||
|
||||
|
||||
scoped_native_task = scoped_task
|
||||
resolve_native_task = resolve_task
|
||||
|
||||
|
||||
# --- development role switching ----------------------------------------------
|
||||
|
||||
def dev_switch_enabled() -> bool:
|
||||
return bool(settings.DEV_ROLE_SWITCH)
|
||||
|
||||
|
||||
def dev_login_usernames() -> list[str]:
|
||||
"""Demo accounts the dev switcher may log in as (never arbitrary users)."""
|
||||
return [name for name in settings.dev_demo_usernames() if name]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Casdoor SSO for PM — one identity source for the whole suite.
|
||||
|
||||
The Casdoor JWT issued by the ERP login flow is accepted on every PM endpoint:
|
||||
|
||||
- The token is verified against Casdoor's JWKS (same as the ERP backend):
|
||||
signature (RS256 family), issuer = ``PM_CASDOOR_ISSUER`` (the browser-facing
|
||||
origin Casdoor advertises — ``casdoor.origin`` in the compose env) and
|
||||
audience = ``PM_CASDOOR_AUDIENCE`` (the inventory-app client).
|
||||
- The caller's system-wide role lives in the ERP (``users.role``) and is
|
||||
resolved by calling ``GET {ERP_BASE_URL}/api/v1/users/me`` with the SAME
|
||||
Bearer token (the ERP verifies the identical JWT). Result is cached briefly
|
||||
per casdoor id — one upstream call per role change window, not per request.
|
||||
- Mapping to the PM world (single source of truth stays the ERP):
|
||||
admin -> project_manager (full PM access)
|
||||
pm (مدیر پروژه) -> project_manager (full PM access)
|
||||
supervisor -> project_supervisor (project-scoped, as today)
|
||||
procurement_manager -> 403 — مدیر تدارکات به پنل PM دسترسی ندارد
|
||||
- The PMUser row is auto-provisioned / role-synced by username on first use,
|
||||
so تخصیص سرپرستها به پروژهها (ProjectAssignment) بلافاصله بعد از اولین
|
||||
ورود قابل انجام است.
|
||||
|
||||
Legacy HMAC session tokens (POST /auth/login, dev role switcher, standalone
|
||||
:8083 deployments) keep working: ``get_current_user`` dispatches on token
|
||||
shape (two dot-separated segments = legacy, three = Casdoor JWT).
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import AuthError, ForbiddenError, UpstreamError
|
||||
from app.models import PMUser
|
||||
|
||||
# نقشهای PM بهصورت متن (PMUser.role ستون متنی است) — import از authz اینجا
|
||||
# چرخهی import میسازد (authz → security → casdoor_auth → authz)
|
||||
_PM_PROJECT_MANAGER = "project_manager"
|
||||
_PM_PROJECT_SUPERVISOR = "project_supervisor"
|
||||
|
||||
logger = logging.getLogger("pm.casdoor")
|
||||
|
||||
_JWKS_CACHE: dict | None = None
|
||||
# casdoor_id -> (erp_role, expiry monotonic seconds)
|
||||
_ROLE_CACHE: dict[str, tuple[str, float]] = {}
|
||||
_ROLE_CACHE_TTL_SECONDS = 30
|
||||
|
||||
|
||||
def _jwks_url() -> str:
|
||||
return f"{settings.ERP_CASDOOR_ENDPOINT.rstrip('/')}/.well-known/jwks"
|
||||
|
||||
|
||||
async def _get_jwks() -> dict:
|
||||
global _JWKS_CACHE
|
||||
if _JWKS_CACHE is not None:
|
||||
return _JWKS_CACHE
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(_jwks_url())
|
||||
resp.raise_for_status()
|
||||
_JWKS_CACHE = resp.json()
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("Casdoor JWKS fetch failed (%s): %s", _jwks_url(), exc)
|
||||
raise UpstreamError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc
|
||||
return _JWKS_CACHE
|
||||
|
||||
|
||||
def _key_from_jwk(jwk: dict):
|
||||
kty = jwk.get("kty")
|
||||
if kty == "RSA":
|
||||
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
|
||||
if kty == "EC":
|
||||
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
|
||||
return jwk
|
||||
|
||||
|
||||
async def verify_casdoor_token(token: str) -> dict:
|
||||
"""Validate the Casdoor JWT and return its claims (AuthError on failure)."""
|
||||
try:
|
||||
kid = jwt.get_unverified_header(token).get("kid")
|
||||
except jwt.PyJWTError as exc:
|
||||
logger.warning("JWT unreadable: %s", exc)
|
||||
raise AuthError("توکن نامعتبر است") from exc
|
||||
jwks = await _get_jwks()
|
||||
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
|
||||
if entry is None:
|
||||
logger.warning("JWT kid %r not found in JWKS", kid)
|
||||
raise AuthError("امضای توکن نامعتبر است")
|
||||
key = _key_from_jwk(entry)
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
|
||||
issuer=settings.CASDOOR_ISSUER,
|
||||
audience=settings.CASDOOR_AUDIENCE,
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
logger.warning("JWT verification failed: %s", exc)
|
||||
raise AuthError("توکن نامعتبر یا منقضی شده است") from exc
|
||||
|
||||
|
||||
async def _erp_identity_for_token(token: str, casdoor_id: str) -> dict:
|
||||
"""نقش + پنلهای کاربر از ERP (users/me) — کش کوتاه."""
|
||||
cached = _ROLE_CACHE.get(casdoor_id)
|
||||
if cached and cached[1] > time.monotonic():
|
||||
return cached[0]
|
||||
url = f"{settings.ERP_BASE_URL.rstrip('/')}/api/v1/users/me"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("ERP /users/me unreachable (%s): %s", url, exc)
|
||||
raise UpstreamError("سامانه تدارکات (ERP) برای تعیین دسترسی در دسترس نیست") from exc
|
||||
if resp.status_code == 401:
|
||||
raise AuthError("توکن نامعتبر یا منقضی شده است")
|
||||
if resp.status_code != 200:
|
||||
logger.error("ERP /users/me failed (%s): HTTP %s", url, resp.status_code)
|
||||
raise UpstreamError("تعیین دسترسی کاربر از ERP ناموفق بود")
|
||||
data = resp.json().get("data") or {}
|
||||
identity = {"role": data.get("role"), "panels": data.get("panels") or {}}
|
||||
_ROLE_CACHE[casdoor_id] = (identity, time.monotonic() + _ROLE_CACHE_TTL_SECONDS)
|
||||
return identity
|
||||
|
||||
|
||||
# سطح پنل PM -> نقش PM: full = همه بخشها با اختیار کامل تغییر؛
|
||||
# limited = پاسخ QC + درصد پیشرفت + مشاهده جزئیات (محدود به پروژههای تخصیصی).
|
||||
# مدیر تدارکات به PM دسترسی ندارد.
|
||||
|
||||
|
||||
async def casdoor_user_from_token(token: str, db: AsyncSession) -> PMUser:
|
||||
"""Verify the JWT, resolve the system role from ERP and return the
|
||||
provisioned PMUser (role synced). 403 for roles without PM access."""
|
||||
claims = await verify_casdoor_token(token)
|
||||
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
|
||||
username = str(claims.get("name") or claims.get("preferred_username") or "")
|
||||
if not casdoor_id or not username:
|
||||
raise AuthError("توکن فاقد شناسه کاربر است")
|
||||
|
||||
identity = await _erp_identity_for_token(token, casdoor_id)
|
||||
panels = identity.get("panels") or {}
|
||||
erp_role = identity.get("role")
|
||||
pm_level = panels.get("pm")
|
||||
if erp_role == "admin":
|
||||
pm_role = _PM_PROJECT_MANAGER
|
||||
elif pm_level == "full":
|
||||
pm_role = _PM_PROJECT_MANAGER
|
||||
elif pm_level == "limited":
|
||||
pm_role = _PM_PROJECT_SUPERVISOR
|
||||
elif not panels and erp_role in ("admin", "pm"): # میراث — دوره مهاجرت فقط در صورت عدم تعریف پنلها
|
||||
pm_role = _PM_PROJECT_MANAGER
|
||||
elif not panels and erp_role == "supervisor": # میراث — فقط در صورت عدم تعریف پنلها
|
||||
pm_role = _PM_PROJECT_SUPERVISOR
|
||||
else:
|
||||
raise ForbiddenError("به پنل مدیریت پروژه دسترسی ندارید")
|
||||
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == username))
|
||||
if user is None:
|
||||
display_name = str(
|
||||
claims.get("displayName") or claims.get("firstName") or username
|
||||
)
|
||||
user = PMUser(
|
||||
username=username,
|
||||
password_hash="", # SSO identities never authenticate locally
|
||||
display_name=display_name,
|
||||
role=pm_role,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
elif user.role != pm_role:
|
||||
# نقش سراسری عوض شده — با PM همگام میشود
|
||||
user.role = pm_role
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def is_casdoor_token(token: str) -> bool:
|
||||
"""Legacy session tokens are ``body.signature`` (one dot); Casdoor JWTs
|
||||
have the standard three header/payload/signature segments."""
|
||||
return token.count(".") == 2
|
||||
@@ -0,0 +1,49 @@
|
||||
"""PM settings — env prefix PM_ (mirrors the ERP PROCUREMENT_ pattern)."""
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="PM_", env_file=".env", extra="ignore")
|
||||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://pm:pm@localhost:5432/pm"
|
||||
SESSION_SECRET: str = "change-me"
|
||||
SESSION_TTL_HOURS: int = 24 * 14
|
||||
|
||||
ADMIN_USER: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin"
|
||||
|
||||
# Development role switching — the dashboard shows a role picker so the two
|
||||
# roles can be tried without separate logins. Set PM_DEV_ROLE_SWITCH=false
|
||||
# (production) to disable the dev endpoints entirely.
|
||||
DEV_ROLE_SWITCH: bool = False
|
||||
DEV_SUPERVISOR_USER: str = "supervisor"
|
||||
DEV_SUPERVISOR_PASSWORD: str = "pm123456"
|
||||
DEV_SUPERVISOR2_USER: str = "supervisor2"
|
||||
DEV_SUPERVISOR2_PASSWORD: str = "pm123456"
|
||||
|
||||
def dev_demo_usernames(self) -> list[str]:
|
||||
"""Accounts the dev role switcher may log in as (manager + supervisors)."""
|
||||
return [self.ADMIN_USER, self.DEV_SUPERVISOR_USER, self.DEV_SUPERVISOR2_USER]
|
||||
|
||||
FILES_DIR: Path = Path("/data/files")
|
||||
|
||||
# ERP (procurement/inventory) integration — read-only service account
|
||||
ERP_BASE_URL: str = "http://procurement-api:8000"
|
||||
ERP_CASDOOR_ENDPOINT: str = "http://casdoor:8000"
|
||||
ERP_CASDOOR_CLIENT_ID: str = "inventory-app-client"
|
||||
ERP_CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
|
||||
ERP_CASDOOR_ORGANIZATION: str = "inventory"
|
||||
ERP_SERVICE_USERNAME: str = "pm-service"
|
||||
ERP_SERVICE_PASSWORD: str = ""
|
||||
ERP_CACHE_TTL_SECONDS: int = 120
|
||||
|
||||
# Casdoor SSO — PM accepts the ERP-issued JWT as its own session.
|
||||
# ISSUER = the browser-facing origin Casdoor advertises (casdoor `origin`),
|
||||
# not the in-network endpoint; AUDIENCE = the inventory-app client id.
|
||||
CASDOOR_ISSUER: str = "http://localhost:8080"
|
||||
CASDOOR_AUDIENCE: str = "inventory-app-client"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Async SQLAlchemy engine/session for PM database."""
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
try:
|
||||
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_size=5, max_overflow=5)
|
||||
except Exception:
|
||||
# Fallback for testing environments or when asyncpg driver is not installed on host
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
|
||||
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||
async with SessionFactory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Development-only demo accounts for the role switcher on the dashboard.
|
||||
|
||||
While the two roles are being built, the «امروز» dashboard shows a role picker
|
||||
so nobody has to juggle logins. This module creates those demo users and gives
|
||||
the demo supervisors a project scope, so the picker is immediately useful —
|
||||
the enforcement itself is the real thing (see app/authz.py).
|
||||
|
||||
Everything here is inert unless `PM_DEV_ROLE_SWITCH` is enabled; the endpoints
|
||||
in routers/auth.py return 404 in that case. For production set the flag to
|
||||
false and manage users through `PUT /users/{id}/projects`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import Role, dev_switch_enabled
|
||||
from app.config import settings
|
||||
from app.models import PMUser, Project, ProjectAssignment
|
||||
from app.security import hash_password
|
||||
|
||||
logger = logging.getLogger("pm.dev")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemoAccount:
|
||||
username: str
|
||||
password: str
|
||||
display_name: str
|
||||
role: Role
|
||||
share: tuple[int, int] | None = None
|
||||
|
||||
|
||||
def demo_accounts() -> list[DemoAccount]:
|
||||
total = len(settings.dev_demo_usernames())
|
||||
return [
|
||||
DemoAccount(settings.ADMIN_USER, settings.ADMIN_PASSWORD, "مدیر سیستم", Role.project_manager),
|
||||
DemoAccount(
|
||||
settings.DEV_SUPERVISOR_USER,
|
||||
settings.DEV_SUPERVISOR_PASSWORD,
|
||||
"سرپرست پروژه الف",
|
||||
Role.project_supervisor,
|
||||
share=(1, 2),
|
||||
),
|
||||
DemoAccount(
|
||||
settings.DEV_SUPERVISOR2_USER,
|
||||
settings.DEV_SUPERVISOR2_PASSWORD,
|
||||
"سرپرست پروژه ب",
|
||||
Role.project_supervisor,
|
||||
share=(2, 2),
|
||||
),
|
||||
][:total]
|
||||
|
||||
|
||||
def _share_of(items: list, share: tuple[int, int]) -> list:
|
||||
"""Deterministic, non-overlapping slice: part N of M (1-based)."""
|
||||
part, of = share
|
||||
size = -(-len(items) // of)
|
||||
start = (part - 1) * size
|
||||
return items[start : start + size]
|
||||
|
||||
|
||||
def demo_account(username: str) -> DemoAccount | None:
|
||||
for account in demo_accounts():
|
||||
if account.username == username:
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_demo_accounts(db: AsyncSession) -> None:
|
||||
if not dev_switch_enabled():
|
||||
# در پروداکشن، حسابهای نمایشی توسعه نباید فعال باشند
|
||||
demo_names = [settings.DEV_SUPERVISOR_USER, settings.DEV_SUPERVISOR2_USER]
|
||||
demo_users = (
|
||||
await db.execute(select(PMUser).where(PMUser.username.in_(demo_names)))
|
||||
).scalars().all()
|
||||
for u in demo_users:
|
||||
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == u.id))
|
||||
await db.delete(u)
|
||||
logger.info("cleaned up demo account %s for production", u.username)
|
||||
admin_u = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
|
||||
if admin_u is None:
|
||||
db.add(
|
||||
PMUser(
|
||||
username=settings.ADMIN_USER,
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
display_name="مدیر سیستم",
|
||||
role=Role.project_manager.value,
|
||||
)
|
||||
)
|
||||
logger.info("seeded admin account in production mode")
|
||||
else:
|
||||
# همیشه رمز عبور را از تنظیمات بهروز کن تا لاگین همیشه کار کند
|
||||
admin_u.password_hash = hash_password(settings.ADMIN_PASSWORD)
|
||||
admin_u.role = Role.project_manager.value
|
||||
logger.info("refreshed admin password hash in production mode")
|
||||
await db.commit()
|
||||
return
|
||||
for account in demo_accounts():
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
|
||||
if user is None:
|
||||
db.add(
|
||||
PMUser(
|
||||
username=account.username,
|
||||
password_hash=hash_password(account.password),
|
||||
display_name=account.display_name,
|
||||
role=account.role.value,
|
||||
)
|
||||
)
|
||||
logger.info("seeded %s account %s", account.role.value, account.username)
|
||||
else:
|
||||
user.role = account.role.value
|
||||
user.display_name = account.display_name
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def ensure_demo_assignments(db: AsyncSession) -> None:
|
||||
if not dev_switch_enabled():
|
||||
return
|
||||
|
||||
native_ids = [p for p in (await db.execute(select(Project.id).order_by(Project.name))).scalars().all()]
|
||||
if not native_ids:
|
||||
return
|
||||
|
||||
for account in demo_accounts():
|
||||
if account.share is None:
|
||||
continue
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
|
||||
if user is None:
|
||||
continue
|
||||
existing = await db.scalar(
|
||||
select(func.count()).where(ProjectAssignment.user_id == user.id)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
mine = _share_of(native_ids, account.share)
|
||||
for project_uuid in mine:
|
||||
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
|
||||
logger.info(
|
||||
"assigned %d demo projects to %s",
|
||||
len(mine),
|
||||
account.username,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_assignments(db: AsyncSession, user_id) -> None:
|
||||
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == user_id))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""PM error envelope — same contract as the ERP: {"error": {code, message, field}}."""
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
status_code = 400
|
||||
code = "BAD_REQUEST"
|
||||
|
||||
def __init__(self, message: str, field: str | None = None, code: str | None = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.field = field
|
||||
if code:
|
||||
self.code = code
|
||||
|
||||
@property
|
||||
def body(self) -> dict:
|
||||
error = {"code": self.code, "message": self.message}
|
||||
if self.field is not None:
|
||||
error["field"] = self.field
|
||||
return {"error": error}
|
||||
|
||||
|
||||
class AuthError(AppError):
|
||||
status_code, code = 401, "UNAUTHORIZED"
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code, code = 400, "VALIDATION_ERROR"
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
status_code, code = 403, "FORBIDDEN"
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code, code = 404, "NOT_FOUND"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code, code = 409, "CONFLICT"
|
||||
|
||||
|
||||
class UpstreamError(AppError):
|
||||
"""Upstream service unreachable / errored."""
|
||||
status_code, code = 503, "UPSTREAM_UNAVAILABLE"
|
||||
|
||||
|
||||
def install_error_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.body)
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
|
||||
@@ -0,0 +1,160 @@
|
||||
"""ERP (procurement/inventory) read-only connector.
|
||||
|
||||
Auth: Casdoor password grant with a dedicated service account (no changes to
|
||||
the ERP codebase). The token is cached until expiry. ERP stays the source of
|
||||
truth for physical stock; PM only READS it (reservations/purchase requests are
|
||||
PM-owned operational overlays).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.errors import UpstreamError
|
||||
|
||||
logger = logging.getLogger("pm.erp")
|
||||
|
||||
|
||||
class ERPClient:
|
||||
def __init__(self):
|
||||
self._token: str | None = None
|
||||
self._token_exp: float = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure_token(self) -> str:
|
||||
async with self._lock:
|
||||
if self._token and time.time() < self._token_exp - 30:
|
||||
return self._token
|
||||
usernames = [
|
||||
settings.ERP_SERVICE_USERNAME,
|
||||
f"{settings.ERP_CASDOOR_ORGANIZATION}/{settings.ERP_SERVICE_USERNAME}",
|
||||
]
|
||||
last_error: Exception | None = None
|
||||
async with httpx.AsyncClient(timeout=12) as client:
|
||||
for username in usernames:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
|
||||
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
|
||||
"username": username,
|
||||
"password": settings.ERP_SERVICE_PASSWORD,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
if token:
|
||||
self._token = token
|
||||
self._token_exp = time.time() + int(data.get("expires_in") or 3600)
|
||||
return token
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
logger.warning("ERP token grant failed for %s: %s", username, exc)
|
||||
raise UpstreamError(f"احراز هویت سرویس با ERP/Casdoor ناموفق بود: {last_error}")
|
||||
|
||||
async def get(self, path: str, params: dict | None = None) -> Any:
|
||||
token = await self._ensure_token()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{settings.ERP_BASE_URL}{path}",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpstreamError(f"ERP در دسترس نیست: {exc.__class__.__name__}") from exc
|
||||
if resp.status_code == 401:
|
||||
self._token = None
|
||||
raise UpstreamError("توکن سرویس ERP نامعتبر شد")
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("ERP GET %s → %s %s", path, resp.status_code, resp.text[:200])
|
||||
raise UpstreamError(f"خطای ERP ({resp.status_code})")
|
||||
if not resp.content:
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
# --- domain reads (normalize to PM-friendly dicts) -----------------------
|
||||
|
||||
async def materials(self) -> list[dict]:
|
||||
"""Global material catalog: [{id, name, unit_id, unit_name}]"""
|
||||
materials: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
data = await self.get(f"/api/v1/materials?page_size=100&page={page}")
|
||||
elements = data.get("data", []) if isinstance(data, dict) else []
|
||||
materials.extend(elements)
|
||||
meta = data.get("meta", {}) if isinstance(data, dict) else {}
|
||||
if not elements or page * 100 >= int(meta.get("total") or len(materials)):
|
||||
break
|
||||
page += 1
|
||||
|
||||
unit_names: dict[int, str] = {}
|
||||
try:
|
||||
units = await self.get("/api/v1/units?page_size=100")
|
||||
for u in units.get("data", []):
|
||||
unit_names[u.get("id")] = u.get("name_fa") or u.get("name") or u.get("code") or ""
|
||||
except Exception: # noqa: BLE001 — unit names are cosmetic
|
||||
pass
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.get("id"),
|
||||
"name": m.get("name") or "",
|
||||
"unit_id": m.get("canonical_unit_id"),
|
||||
"unit_name": unit_names.get(m.get("canonical_unit_id"), ""),
|
||||
}
|
||||
for m in materials
|
||||
if m.get("id") and (m.get("status") or "active") == "active"
|
||||
]
|
||||
|
||||
async def projects(self) -> list[dict]:
|
||||
"""ERP projects for the mapping picker: [{id, name}]"""
|
||||
data = await self.get("/api/v1/projects?page_size=100")
|
||||
elements = data.get("data", []) if isinstance(data, dict) else []
|
||||
return [{"id": p.get("id"), "name": p.get("name") or ""} for p in elements if p.get("id")]
|
||||
|
||||
async def warehouses(self, erp_project_id: str) -> list[dict]:
|
||||
data = await self.get("/api/v1/inventory/warehouses")
|
||||
elements = data.get("data", data) if isinstance(data, dict) else data
|
||||
if isinstance(elements, dict):
|
||||
elements = elements.get("data", [])
|
||||
return [w for w in (elements or []) if w.get("project_id") == erp_project_id]
|
||||
|
||||
async def inventory_rows(self) -> list[dict]:
|
||||
"""Normalized stock rows: [{warehouse_id, material_id, quantity}].
|
||||
|
||||
ERP returns nested objects (warehouse: {...}, material: {...}) plus a
|
||||
numeric `quantity_number` — flatten to the three fields PM needs.
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
page = 1
|
||||
while True:
|
||||
data = await self.get(f"/api/v1/inventory?page_size=100&page={page}")
|
||||
elements = data.get("data", []) if isinstance(data, dict) else []
|
||||
for r in elements:
|
||||
warehouse = r.get("warehouse") or {}
|
||||
material = r.get("material") or {}
|
||||
qty = r.get("quantity_number")
|
||||
if qty is None:
|
||||
qty = r.get("quantity")
|
||||
rows.append(
|
||||
{
|
||||
"warehouse_id": warehouse.get("id"),
|
||||
"material_id": material.get("id"),
|
||||
"quantity": float(qty or 0),
|
||||
}
|
||||
)
|
||||
meta = data.get("meta", {}) if isinstance(data, dict) else {}
|
||||
if not elements or page * 100 >= int(meta.get("total") or len(rows)):
|
||||
break
|
||||
page += 1
|
||||
return rows
|
||||
|
||||
|
||||
erp_client = ERPClient()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""PM — Project Management operational layer. FastAPI entrypoint."""
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionFactory, engine
|
||||
from app.dev_accounts import ensure_demo_accounts, ensure_demo_assignments
|
||||
from app.errors import install_error_handlers
|
||||
from app.models import Base, PMUser
|
||||
from app.routers import auth, dashboard, files, projects, qc, tasks, users
|
||||
from app.security import hash_password
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
async def _ensure_admin() -> None:
|
||||
"""Seed the local admin user (role: project_manager) from env on first boot."""
|
||||
async with SessionFactory() as db:
|
||||
existing = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
|
||||
if existing is None:
|
||||
db.add(
|
||||
PMUser(
|
||||
username=settings.ADMIN_USER,
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
display_name="مدیر سیستم",
|
||||
role="project_manager",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logging.getLogger("pm.boot").info("seeded admin user %s", settings.ADMIN_USER)
|
||||
# dev role switcher: demo accounts for the dashboard picker
|
||||
await ensure_demo_accounts(db)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
if settings.DEV_ROLE_SWITCH:
|
||||
logging.getLogger("pm.boot").warning(
|
||||
"PM_DEV_ROLE_SWITCH is ON — /auth/dev-login issues tokens without a password. "
|
||||
"Set PM_DEV_ROLE_SWITCH=false for production."
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
from app.schema_sync import drop_legacy, ensure_schema
|
||||
|
||||
await drop_legacy(conn)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await ensure_schema(conn)
|
||||
await _ensure_admin()
|
||||
asyncio.create_task(_init_demo_assignments())
|
||||
yield
|
||||
|
||||
|
||||
async def _init_demo_assignments() -> None:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
async with SessionFactory() as db:
|
||||
await ensure_demo_assignments(db)
|
||||
except Exception: # noqa: BLE001
|
||||
logging.getLogger("pm.boot").exception("demo assignment failed")
|
||||
|
||||
|
||||
app = FastAPI(title="PM — Construction Project Management Platform", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
API_V1_PREFIX = "/api/v1"
|
||||
for module in (auth, users, dashboard, projects, tasks, qc, files):
|
||||
app.include_router(module.router, prefix=API_V1_PREFIX)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "service": "pm-api"}
|
||||
|
||||
|
||||
install_error_handlers(app)
|
||||
@@ -0,0 +1,446 @@
|
||||
"""PM data model — Native Construction Project Management Platform.
|
||||
|
||||
- Project and Task entities are owned directly by PM with hierarchical WBS,
|
||||
CPM scheduling, dependencies, and milestones.
|
||||
- QC domain (templates, definitions, inspections, results, defects) is PM-owned.
|
||||
- Material requirements and reservations integrate directly with ERP stock.
|
||||
"""
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
# --- Native PM Platform Models -----------------------------------------------
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
PLANNED = "PLANNED"
|
||||
ACTIVE = "ACTIVE"
|
||||
ON_HOLD = "ON_HOLD"
|
||||
COMPLETED = "COMPLETED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
TODO = "TODO"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
BLOCKED = "BLOCKED"
|
||||
COMPLETED = "COMPLETED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
LOW = "low"
|
||||
MEDIUM = "medium"
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class TaskRelationType(str, enum.Enum):
|
||||
FS = "FS" # Finish-to-Start (پایان به شروع)
|
||||
SS = "SS" # Start-to-Start (شروع به شروع)
|
||||
FF = "FF" # Finish-to-Finish (پایان به پایان)
|
||||
SF = "SF" # Start-to-Finish (شروع به پایان)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""Native Construction Project entity owned directly by PM."""
|
||||
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
location: Mapped[str] = mapped_column(String(255), default="")
|
||||
project_manager: Mapped[str] = mapped_column(String(255), default="")
|
||||
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=ProjectStatus.ACTIVE.value,
|
||||
server_default=ProjectStatus.ACTIVE.value,
|
||||
index=True,
|
||||
)
|
||||
progress: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
members: Mapped[list["ProjectMember"]] = relationship(
|
||||
back_populates="project", cascade="all, delete-orphan"
|
||||
)
|
||||
tasks: Mapped[list["Task"]] = relationship(
|
||||
back_populates="project", cascade="all, delete-orphan"
|
||||
)
|
||||
relations: Mapped[list["TaskRelation"]] = relationship(
|
||||
back_populates="project", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""Team members assigned to a project with designated roles."""
|
||||
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32), default="site_engineer")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="members")
|
||||
user: Mapped["PMUser"] = relationship(lazy="selectin")
|
||||
|
||||
|
||||
class Task(Base):
|
||||
"""Hierarchical Construction Task / Activity entity owned directly by PM.
|
||||
|
||||
Supports arbitrary depth: Project -> Phase -> Activity -> Subtask through self-referencing `parent_id`.
|
||||
"""
|
||||
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (
|
||||
Index("ix_tasks_project", "project_id"),
|
||||
Index("ix_tasks_parent", "parent_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=TaskStatus.TODO.value,
|
||||
server_default=TaskStatus.TODO.value,
|
||||
index=True,
|
||||
)
|
||||
priority: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default=TaskPriority.MEDIUM.value,
|
||||
server_default=TaskPriority.MEDIUM.value,
|
||||
)
|
||||
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
assignee_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
duration: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
qc_status: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default="none",
|
||||
server_default="none",
|
||||
comment="none|pre_qc_pending|pre_qc_failed|ready|exec_qc_pending|in_progress|qc_failed_rework|final_passed",
|
||||
)
|
||||
human_resources: Mapped[list[dict]] = mapped_column(
|
||||
JSON, default=list, server_default="[]"
|
||||
)
|
||||
materials: Mapped[list[dict]] = mapped_column(
|
||||
JSON, default=list, server_default="[]"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="tasks", lazy="selectin")
|
||||
parent: Mapped["Task | None"] = relationship(
|
||||
back_populates="children", remote_side=[id], lazy="selectin"
|
||||
)
|
||||
children: Mapped[list["Task"]] = relationship(
|
||||
back_populates="parent", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
assignee: Mapped["PMUser | None"] = relationship(lazy="selectin")
|
||||
outgoing_relations: Mapped[list["TaskRelation"]] = relationship(
|
||||
foreign_keys="[TaskRelation.from_task_id]",
|
||||
back_populates="from_task",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
incoming_relations: Mapped[list["TaskRelation"]] = relationship(
|
||||
foreign_keys="[TaskRelation.to_task_id]",
|
||||
back_populates="to_task",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
progress_logs: Mapped[list["TaskProgressLog"]] = relationship(
|
||||
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class TaskProgressLog(Base):
|
||||
"""ثبت گزارش پیشرفت مقطعی/روزانه وظیفه به همراه مصرف واقعی نیروی انسانی و مصالح."""
|
||||
|
||||
__tablename__ = "task_progress_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_task_progress_logs_task", "task_id"),
|
||||
Index("ix_task_progress_logs_created", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
progress_before: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
progress_after: Mapped[int] = mapped_column(Integer)
|
||||
human_resources_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
|
||||
materials_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
|
||||
description: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||
logged_by: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
task: Mapped["Task"] = relationship(back_populates="progress_logs")
|
||||
|
||||
|
||||
class TaskRelation(Base):
|
||||
"""Dependency / precedence between two tasks in a project (FS, SS, FF, SF + lag)."""
|
||||
|
||||
__tablename__ = "task_relations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("from_task_id", "to_task_id", name="uq_task_relations_pair"),
|
||||
Index("ix_task_relations_project", "project_id"),
|
||||
Index("ix_task_relations_from", "from_task_id"),
|
||||
Index("ix_task_relations_to", "to_task_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
from_task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
to_task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
relation_type: Mapped[str] = mapped_column(
|
||||
String(8), default=TaskRelationType.FS.value, server_default=TaskRelationType.FS.value
|
||||
)
|
||||
lag_days: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
project: Mapped["Project"] = relationship(back_populates="relations")
|
||||
from_task: Mapped["Task"] = relationship(
|
||||
foreign_keys=[from_task_id], back_populates="outgoing_relations"
|
||||
)
|
||||
to_task: Mapped["Task"] = relationship(
|
||||
foreign_keys=[to_task_id], back_populates="incoming_relations"
|
||||
)
|
||||
|
||||
|
||||
# --- PM-owned QC domain ------------------------------------------------------
|
||||
|
||||
|
||||
class QCQuestionPhase(str, enum.Enum):
|
||||
"""مرحلهای که یک سوال QC به آن تعلق دارد.
|
||||
|
||||
pre = پیش از اجرا (آمادگی شروع کار)
|
||||
during = هنگام اجرا
|
||||
after = پس از اجرا
|
||||
"""
|
||||
|
||||
pre = "pre"
|
||||
during = "during"
|
||||
after = "after"
|
||||
|
||||
|
||||
class QCAnswer(str, enum.Enum):
|
||||
pass_ = "pass"
|
||||
fail = "fail"
|
||||
na = "na"
|
||||
|
||||
|
||||
class QCTemplate(Base):
|
||||
__tablename__ = "qc_templates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
activity_type: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
questions: Mapped[list["QCQuestion"]] = relationship(
|
||||
back_populates="template", cascade="all, delete-orphan", order_by="QCQuestion.sort_order"
|
||||
)
|
||||
|
||||
|
||||
class QCQuestion(Base):
|
||||
__tablename__ = "qc_questions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
template_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("qc_templates.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
|
||||
text: Mapped[str] = mapped_column(String(512))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
required: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
template: Mapped[QCTemplate] = relationship(back_populates="questions")
|
||||
|
||||
|
||||
class TaskQCDefinition(Base):
|
||||
__tablename__ = "task_qc_definitions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), default="")
|
||||
# provenance only — deleting/editing the template never affects this copy
|
||||
source_template_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
questions: Mapped[list["TaskQCQuestion"]] = relationship(
|
||||
back_populates="definition",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="TaskQCQuestion.sort_order",
|
||||
)
|
||||
|
||||
|
||||
class TaskQCQuestion(Base):
|
||||
__tablename__ = "task_qc_questions"
|
||||
__table_args__ = (Index("ix_task_qc_q_def", "definition_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
definition_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("task_qc_definitions.id", ondelete="CASCADE")
|
||||
)
|
||||
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
|
||||
text: Mapped[str] = mapped_column(String(512))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
required: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
definition: Mapped[TaskQCDefinition] = relationship(back_populates="questions")
|
||||
results: Mapped[list["TaskQCResult"]] = relationship(
|
||||
back_populates="question", cascade="all, delete-orphan", order_by="TaskQCResult.evaluated_at"
|
||||
)
|
||||
|
||||
|
||||
class TaskQCResult(Base):
|
||||
"""Evaluation of ONE task-specific question — append-only history.
|
||||
|
||||
The current answer is the latest row for the question; keeping history is
|
||||
what makes rework → re-inspection → pass traceable.
|
||||
"""
|
||||
|
||||
__tablename__ = "task_qc_results"
|
||||
__table_args__ = (Index("ix_task_qc_res_q", "question_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
question_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="CASCADE")
|
||||
)
|
||||
answer: Mapped[QCAnswer] = mapped_column(Enum(QCAnswer, name="qc_answer"))
|
||||
comment: Mapped[str] = mapped_column(Text, default="")
|
||||
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
evaluated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
evaluated_by: Mapped[str] = mapped_column(String(255), default="")
|
||||
question: Mapped[TaskQCQuestion] = relationship(back_populates="results")
|
||||
|
||||
|
||||
class Defect(Base):
|
||||
"""عیب ثبتشده روی یک تسک (اختیاری متصل به سؤال QC مردود)."""
|
||||
|
||||
__tablename__ = "defects"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
question_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(512))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
severity: Mapped[str] = mapped_column(String(16), default="medium", server_default="medium")
|
||||
status: Mapped[str] = mapped_column(String(16), default="open", server_default="open")
|
||||
corrective_action: Mapped[str] = mapped_column(Text, default="")
|
||||
resolution_notes: Mapped[str] = mapped_column(Text, default="")
|
||||
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
# --- Auth & access control ------------------------------------------------------
|
||||
|
||||
|
||||
class PMUser(Base):
|
||||
__tablename__ = "pm_users"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
display_name: Mapped[str] = mapped_column(String(255), default="")
|
||||
# Values live in app/authz.py (Role); stored as text so new roles need no
|
||||
# Postgres enum migration.
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
default="project_manager",
|
||||
server_default="project_manager",
|
||||
comment="project_manager|project_supervisor",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class ProjectAssignment(Base):
|
||||
"""Projects a scoped role may access (e.g. a supervisor's assigned projects).
|
||||
|
||||
Unscoped roles (project_manager) see every project and need no rows here;
|
||||
scoped roles are limited to exactly the rows present for them. Source of
|
||||
truth for the scoping done in app/authz.py.
|
||||
"""
|
||||
|
||||
__tablename__ = "project_assignments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
project_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,276 @@
|
||||
"""QC domain service — the single source of truth for the QC business rules.
|
||||
|
||||
Concepts (kept strictly separate):
|
||||
* QCTemplate / QCQuestion → global, reusable definition (a starting point)
|
||||
* TaskQCDefinition/…Question → task-specific COPY the user customizes freely
|
||||
* TaskQCResult → the evaluation (append-only history) done during execution
|
||||
|
||||
Rules enforced here (and reused by routers/tasks.py + the dashboard):
|
||||
* A task cannot start (progress > 0) while required PRE-EXECUTION QC is unresolved/failed.
|
||||
* A task cannot reach 100% while ANY required QC question (pre/during/after) is unresolved.
|
||||
* `na` ("مطرح نیست") counts as satisfied for gating purposes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models import (
|
||||
QCAnswer,
|
||||
QCQuestionPhase,
|
||||
Task,
|
||||
TaskQCDefinition,
|
||||
TaskQCQuestion,
|
||||
TaskQCResult,
|
||||
)
|
||||
|
||||
PRE = QCQuestionPhase.pre.value
|
||||
SATISFIED = {QCAnswer.pass_, QCAnswer.na}
|
||||
|
||||
|
||||
# --- status vocabulary --------------------------------------------------------
|
||||
|
||||
QC_STATUS_NONE = "none"
|
||||
QC_STATUS_PRE_PENDING = "pre_qc_pending"
|
||||
QC_STATUS_PRE_FAILED = "pre_qc_failed"
|
||||
QC_STATUS_READY = "ready"
|
||||
QC_STATUS_EXEC_PENDING = "exec_qc_pending"
|
||||
QC_STATUS_IN_PROGRESS = "in_progress"
|
||||
QC_STATUS_REWORK = "qc_failed_rework"
|
||||
QC_STATUS_PASSED = "final_passed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseSummary:
|
||||
phase: str
|
||||
total: int = 0
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
pending: int = 0
|
||||
na: int = 0
|
||||
required_total: int = 0
|
||||
required_unresolved: int = 0
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self.total == 0:
|
||||
return "none"
|
||||
if self.failed:
|
||||
return "failed"
|
||||
if self.pending:
|
||||
return "pending"
|
||||
return "passed"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"total": self.total,
|
||||
"passed": self.passed,
|
||||
"failed": self.failed,
|
||||
"pending": self.pending,
|
||||
"na": self.na,
|
||||
"required_total": self.required_total,
|
||||
"required_unresolved": self.required_unresolved,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class QCEvaluation:
|
||||
"""Latest answer of one task QC question."""
|
||||
|
||||
question: TaskQCQuestion
|
||||
latest: TaskQCResult | None
|
||||
|
||||
@property
|
||||
def answer(self) -> str | None:
|
||||
return self.latest.answer.value if self.latest else None
|
||||
|
||||
@property
|
||||
def satisfied(self) -> bool:
|
||||
return self.latest is not None and self.latest.answer in SATISFIED
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskQCState:
|
||||
items: list[QCEvaluation] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def pre(self) -> PhaseSummary:
|
||||
return _summarize([i for i in self.items if i.question.phase == QCQuestionPhase.pre], "pre")
|
||||
|
||||
@property
|
||||
def exec_(self) -> PhaseSummary:
|
||||
return _summarize([i for i in self.items if i.question.phase != QCQuestionPhase.pre], "exec")
|
||||
|
||||
@property
|
||||
def all_(self) -> PhaseSummary:
|
||||
return _summarize(self.items, "all")
|
||||
|
||||
@property
|
||||
def can_start(self) -> bool:
|
||||
return self.pre.required_unresolved == 0 and self.pre.failed == 0
|
||||
|
||||
@property
|
||||
def can_complete(self) -> bool:
|
||||
return self.all_.required_unresolved == 0 and self.all_.failed == 0
|
||||
|
||||
def block_reasons(self, requested_percentage: int | None = None) -> list[str]:
|
||||
reasons: list[str] = []
|
||||
if requested_percentage is not None and requested_percentage > 0 and not self.can_start:
|
||||
if self.pre.failed:
|
||||
reasons.append("کنترل کیفیت پیش از اجرا مردود شده است (ایرادات را برطرف کنید)")
|
||||
elif self.pre.required_unresolved:
|
||||
reasons.append(
|
||||
f"{self.pre.required_unresolved} سؤال کنترل کیفیت پیش از اجرا پاسخ داده نشده است"
|
||||
)
|
||||
if requested_percentage == 100 and not self.can_complete:
|
||||
if self.all_.failed:
|
||||
reasons.append("تسک دارای کنترل کیفیت مردود است و قابل بستن نیست")
|
||||
elif self.all_.required_unresolved:
|
||||
reasons.append(
|
||||
f"{self.all_.required_unresolved} سؤال کنترل کیفیت الزامی هنوز ارزیابی نشدهاند"
|
||||
)
|
||||
return reasons
|
||||
|
||||
def status(self, progress: int) -> str:
|
||||
if not self.items:
|
||||
return QC_STATUS_NONE
|
||||
if self.pre.failed:
|
||||
return QC_STATUS_PRE_FAILED
|
||||
if self.pre.total > 0 and not self.can_start:
|
||||
return QC_STATUS_PRE_PENDING
|
||||
if progress == 0:
|
||||
return QC_STATUS_READY
|
||||
if self.exec_.failed:
|
||||
return QC_STATUS_REWORK
|
||||
if progress >= 100:
|
||||
return QC_STATUS_PASSED if self.can_complete else QC_STATUS_EXEC_PENDING
|
||||
return QC_STATUS_IN_PROGRESS
|
||||
|
||||
|
||||
def _summarize(items: list[QCEvaluation], phase_name: str) -> PhaseSummary:
|
||||
summary = PhaseSummary(phase=phase_name, total=len(items))
|
||||
for item in items:
|
||||
# تمام سؤالات QC نیازمند تعیین تکلیف هستند (قبول، مردود یا مطرح نیست)
|
||||
summary.required_total += 1
|
||||
if not item.satisfied:
|
||||
summary.required_unresolved += 1
|
||||
ans = item.answer
|
||||
if ans == QCAnswer.pass_.value:
|
||||
summary.passed += 1
|
||||
elif ans == QCAnswer.fail.value:
|
||||
summary.failed += 1
|
||||
elif ans is None:
|
||||
summary.pending += 1
|
||||
else:
|
||||
summary.na += 1
|
||||
return summary
|
||||
|
||||
|
||||
async def load_definitions(db: AsyncSession, task_id: Any) -> list[TaskQCDefinition]:
|
||||
"""Definitions with questions + their full result history eagerly loaded."""
|
||||
try:
|
||||
u = uuid.UUID(str(task_id))
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(TaskQCDefinition)
|
||||
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
|
||||
.where(TaskQCDefinition.task_id == u)
|
||||
.order_by(TaskQCDefinition.created_at)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
def _latest(results: list[TaskQCResult]) -> TaskQCResult | None:
|
||||
if not results:
|
||||
return None
|
||||
return max(results, key=lambda r: (r.evaluated_at, r.id))
|
||||
|
||||
|
||||
def build_state(definitions: list[TaskQCDefinition]) -> TaskQCState:
|
||||
evaluations: list[QCEvaluation] = []
|
||||
for d in definitions:
|
||||
for q in d.questions:
|
||||
evaluations.append(QCEvaluation(question=q, latest=_latest(q.results)))
|
||||
return TaskQCState(items=evaluations)
|
||||
|
||||
|
||||
async def task_qc_state(db: AsyncSession, task_id: Any) -> TaskQCState:
|
||||
return build_state(await load_definitions(db, task_id))
|
||||
|
||||
|
||||
def question_dict(evaluation: QCEvaluation) -> dict:
|
||||
question = evaluation.question
|
||||
latest = evaluation.latest
|
||||
return {
|
||||
"id": str(question.id),
|
||||
"definition_id": str(question.definition_id),
|
||||
"text": question.text,
|
||||
"phase": question.phase.value,
|
||||
"sort_order": question.sort_order,
|
||||
"required": question.required,
|
||||
"result": (
|
||||
{
|
||||
"answer": latest.answer.value,
|
||||
"comment": latest.comment,
|
||||
"photo_url": f"/api/v1/files/{latest.photo_path}" if latest.photo_path else None,
|
||||
"photo_id": latest.photo_path,
|
||||
"evaluated_at": latest.evaluated_at.isoformat() if latest.evaluated_at else None,
|
||||
"evaluated_by": latest.evaluated_by,
|
||||
}
|
||||
if latest
|
||||
else None
|
||||
),
|
||||
"history_count": len(question.results),
|
||||
}
|
||||
|
||||
|
||||
def definition_dict(definition: TaskQCDefinition, state: TaskQCState) -> dict:
|
||||
mine = [i for i in state.items if i.question.definition_id == definition.id]
|
||||
summary = _summarize(mine, "definition")
|
||||
return {
|
||||
"id": str(definition.id),
|
||||
"task_id": str(definition.task_id),
|
||||
"name": definition.name,
|
||||
"source_template_id": str(definition.source_template_id) if definition.source_template_id else None,
|
||||
"created_at": definition.created_at.isoformat() if definition.created_at else None,
|
||||
"questions": [question_dict(i) for i in mine],
|
||||
"summary": summary.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def state_dict(state: TaskQCState, progress: int, requested_percentage: int | None = None) -> dict:
|
||||
return {
|
||||
"pre": state.pre.as_dict(),
|
||||
"exec": state.exec_.as_dict(),
|
||||
"all": state.all_.as_dict(),
|
||||
"status": state.status(progress),
|
||||
"can_start": state.can_start,
|
||||
"can_complete": state.can_complete,
|
||||
"block_reasons": state.block_reasons(requested_percentage),
|
||||
}
|
||||
|
||||
|
||||
async def recompute_task_qc_status(db: AsyncSession, task_id: Any) -> str | None:
|
||||
"""Store the derived QC status on the task (dashboard/analytics read it)."""
|
||||
db.expire_all()
|
||||
state = await task_qc_state(db, task_id)
|
||||
|
||||
try:
|
||||
native_id = uuid.UUID(str(task_id))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
task = await db.scalar(select(Task).where(Task.id == native_id))
|
||||
if task is None:
|
||||
return None
|
||||
task.qc_status = state.status(task.progress or 0)
|
||||
return task.qc_status
|
||||
@@ -0,0 +1 @@
|
||||
"""PM API routers."""
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Local auth + role/scope introspection.
|
||||
|
||||
`POST /auth/login` is the real entry point; `GET /auth/me` re-reads the caller's
|
||||
role/permissions/scope (used after a role or assignment change, and by the
|
||||
frontend to self-heal a session stored before RBAC existed). The `/auth/dev-*`
|
||||
endpoints implement the dashboard role switcher and only exist while
|
||||
`PM_DEV_ROLE_SWITCH` is enabled.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import build_context, dev_login_usernames, dev_switch_enabled
|
||||
from app.db import get_db
|
||||
from app.dev_accounts import demo_account, ensure_demo_assignments
|
||||
from app.errors import AuthError, NotFoundError
|
||||
from app.models import PMUser, ProjectAssignment
|
||||
from app.security import authenticate, create_token, get_current_user
|
||||
from app.serializers import user_payload
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(payload: LoginIn, db: AsyncSession = Depends(get_db)):
|
||||
user = await authenticate(db, payload.username, payload.password)
|
||||
if user is None:
|
||||
raise AuthError("نام کاربری یا رمز عبور اشتباه است")
|
||||
ctx = await build_context(db, user)
|
||||
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return {"data": user_payload(await build_context(db, user))}
|
||||
|
||||
|
||||
# --- development role switcher -------------------------------------------------
|
||||
|
||||
|
||||
def _require_dev_switch() -> None:
|
||||
if not dev_switch_enabled():
|
||||
raise NotFoundError("تغییر نقش فقط در فاز توسعه فعال است")
|
||||
|
||||
|
||||
@router.get("/dev-roles")
|
||||
async def dev_roles(db: AsyncSession = Depends(get_db)):
|
||||
"""Demo identities offered by the dashboard role picker (dev only)."""
|
||||
_require_dev_switch()
|
||||
# projects only exist after the first pull — self-heal the demo scope here
|
||||
await ensure_demo_assignments(db)
|
||||
data = []
|
||||
for username in dev_login_usernames():
|
||||
account = demo_account(username)
|
||||
if account is None:
|
||||
continue
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
|
||||
if user is None:
|
||||
continue
|
||||
projects = await db.scalar(
|
||||
select(func.count()).where(ProjectAssignment.user_id == user.id)
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"role": account.role.value,
|
||||
"project_count": projects or 0,
|
||||
"all_projects": account.share is None,
|
||||
}
|
||||
)
|
||||
return {"data": data}
|
||||
|
||||
|
||||
class DevLoginIn(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/dev-login")
|
||||
async def dev_login(payload: DevLoginIn, db: AsyncSession = Depends(get_db)):
|
||||
"""Issue a token for a demo account — dev role switching without a password.
|
||||
|
||||
Restricted to the configured demo usernames, never arbitrary users.
|
||||
"""
|
||||
_require_dev_switch()
|
||||
if payload.username not in dev_login_usernames():
|
||||
raise AuthError("این حساب در فهرست حسابهای توسعه نیست")
|
||||
user = await db.scalar(select(PMUser).where(PMUser.username == payload.username))
|
||||
if user is None:
|
||||
raise AuthError("حساب نمایشی ساخته نشده است؛ سرور را دوباره راهاندازی کنید")
|
||||
ctx = await build_context(db, user)
|
||||
return {"data": {"token": create_token(user.id), "user": user_payload(ctx)}}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Today dashboard.
|
||||
|
||||
Both roles land here, but they need different things from it: the project
|
||||
manager gets a management overview (every project, with its counters), the
|
||||
supervisor gets an operational view of the activities in the projects assigned
|
||||
to them. The data is the same query either way — only scoped by role and
|
||||
presented differently (see frontend TodayPage).
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.db import get_db
|
||||
from app.models import Project, Task
|
||||
from app.serializers import _iso, native_task_dict
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
view_dashboard = require_permission(Perm.dashboard_view)
|
||||
|
||||
|
||||
async def _native_project_overview(db: AsyncSession, ctx: AuthContext) -> list[dict]:
|
||||
"""کاشیهای مدیریتی پروژههای بومی برای داشبورد مدیر پروژه."""
|
||||
proj_stmt = ctx.scope_native(select(Project).order_by(Project.name), Project.id)
|
||||
projects = (await db.execute(proj_stmt)).scalars().all()
|
||||
|
||||
tasks = (await db.execute(ctx.scope_native(select(Task), Task.project_id))).scalars().all()
|
||||
today_date = date.today()
|
||||
by_project: dict = {}
|
||||
for t in tasks:
|
||||
by_project.setdefault(t.project_id, []).append(t)
|
||||
|
||||
rows: list[dict] = []
|
||||
for p in projects:
|
||||
mine = by_project.get(p.id, [])
|
||||
open_tasks = [t for t in mine if t.status not in ("COMPLETED", "CANCELLED")]
|
||||
stats = {
|
||||
"total": len(mine),
|
||||
"active": len(open_tasks),
|
||||
"awaiting_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")),
|
||||
"failed_qc": sum(1 for t in mine if t.qc_status in ("pre_qc_failed", "qc_failed_rework")),
|
||||
"overdue": sum(1 for t in open_tasks if t.planned_end and t.planned_end < today_date),
|
||||
}
|
||||
rows.append(
|
||||
{
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"identifier": str(p.id),
|
||||
"active": p.status == "ACTIVE",
|
||||
"stats": stats,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/today")
|
||||
async def today(ctx: AuthContext = Depends(view_dashboard), db: AsyncSession = Depends(get_db)):
|
||||
native_stmt = ctx.scope_native(
|
||||
select(Task).options(selectinload(Task.project)), Task.project_id
|
||||
)
|
||||
native_tasks = (await db.execute(native_stmt)).scalars().all()
|
||||
|
||||
today_date = date.today()
|
||||
week_end = today_date + timedelta(days=7)
|
||||
|
||||
def is_task_open(t: Task) -> bool:
|
||||
return t.status != "COMPLETED" and t.status != "CANCELLED"
|
||||
|
||||
overdue = [t for t in native_tasks if is_task_open(t) and t.planned_end and t.planned_end < today_date]
|
||||
due_today = [t for t in native_tasks if is_task_open(t) and t.planned_end == today_date]
|
||||
upcoming = [
|
||||
t for t in native_tasks if is_task_open(t) and t.planned_end and today_date < t.planned_end <= week_end
|
||||
]
|
||||
awaiting_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_pending", "exec_qc_pending", "ready")]
|
||||
failed_qc = [t for t in native_tasks if t.qc_status in ("pre_qc_failed", "qc_failed_rework")]
|
||||
blocked = [t for t in native_tasks if is_task_open(t) and t.status == "BLOCKED"]
|
||||
|
||||
def _native_row(t: Task) -> dict:
|
||||
p_name = t.project.name if t.project else ""
|
||||
d = native_task_dict(t, project_name=p_name)
|
||||
d["subject"] = t.title
|
||||
d["percentage_done"] = t.progress
|
||||
d["start_date"] = _iso(t.planned_start)
|
||||
d["due_date"] = _iso(t.planned_end)
|
||||
d["is_closed"] = t.status == "COMPLETED"
|
||||
return d
|
||||
|
||||
problems = len(overdue) + len(failed_qc)
|
||||
warnings = len(awaiting_qc) + len(blocked)
|
||||
ok = max(len(native_tasks) - problems - warnings, 0)
|
||||
|
||||
data = {
|
||||
"role": ctx.role.value,
|
||||
"role_label": ctx.role_label,
|
||||
"scope": ctx.as_dict()["scope"],
|
||||
"summary": {"problems": problems, "warnings": warnings, "ok": ok},
|
||||
"sections": {
|
||||
"overdue": [_native_row(t) for t in sorted(overdue, key=lambda x: x.planned_end or date.max)],
|
||||
"due_today": [_native_row(t) for t in due_today],
|
||||
"awaiting_qc": [_native_row(t) for t in awaiting_qc],
|
||||
"failed_qc": [_native_row(t) for t in failed_qc],
|
||||
"blocked": [_native_row(t) for t in blocked],
|
||||
"upcoming": [_native_row(t) for t in sorted(upcoming, key=lambda x: x.planned_end or date.max)[:10]],
|
||||
},
|
||||
}
|
||||
# نمای مدیریتی پروژهها فقط برای نقش بدون محدودیت (مدیر پروژه)
|
||||
if ctx.all_projects:
|
||||
data["projects"] = await _native_project_overview(db, ctx)
|
||||
return {"data": data}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Defect photos — upload to PM volume, serve (LAN-internal, unauthenticated)."""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.errors import ValidationError
|
||||
|
||||
router = APIRouter(prefix="/files", tags=["files"])
|
||||
|
||||
ALLOWED = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
# photos are attached to QC defects, so both roles may upload
|
||||
upload_file = require_permission(Perm.files_upload)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def upload(
|
||||
file: UploadFile,
|
||||
_: AuthContext = Depends(upload_file),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_ = db
|
||||
ext = ALLOWED.get(file.content_type or "")
|
||||
if ext is None:
|
||||
raise ValidationError("فقط تصویر jpg/png/webp پذیرفته میشود", field="file")
|
||||
data = await file.read()
|
||||
if len(data) > MAX_SIZE:
|
||||
raise ValidationError("حجم فایل بیش از حد مجاز است (۱۰ مگابایت)", field="file")
|
||||
settings.FILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_id = uuid.uuid4().hex
|
||||
path = settings.FILES_DIR / f"{file_id}{ext}"
|
||||
path.write_bytes(data)
|
||||
return {"data": {"id": file_id, "url": f"/api/v1/files/{file_id}", "filename": file.filename}}
|
||||
|
||||
|
||||
@router.get("/{file_id}")
|
||||
async def serve(file_id: str, db: AsyncSession = Depends(get_db)):
|
||||
_ = db
|
||||
if not file_id.isalnum():
|
||||
raise ValidationError("شناسه فایل نامعتبر است")
|
||||
for ext in ALLOWED.values():
|
||||
path = settings.FILES_DIR / f"{file_id}{ext}"
|
||||
if path.exists():
|
||||
media = next(k for k, v in ALLOWED.items() if v == ext)
|
||||
return FileResponse(path, media_type=media)
|
||||
from app.errors import NotFoundError
|
||||
|
||||
raise NotFoundError("فایل یافت نشد")
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Projects (Native PM Construction Platform Projects).
|
||||
|
||||
Supports:
|
||||
- Project list with real-time statistics and scoping.
|
||||
- Creation, editing, archiving of native projects.
|
||||
- Project team members management.
|
||||
- Unique project code and UUID resolution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import AuthContext, Perm, require_permission
|
||||
from app.db import get_db
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
ProjectStatus,
|
||||
Task,
|
||||
TaskRelation,
|
||||
TaskRelationType,
|
||||
)
|
||||
from app.scheduling_service import (
|
||||
calculate_schedule,
|
||||
check_for_cycle,
|
||||
rollup_parent_tasks,
|
||||
sync_project_lifecycle_dates,
|
||||
)
|
||||
from app.serializers import _iso, project_dict, project_member_dict, task_relation_dict
|
||||
from app.utils.date_service import date_service
|
||||
from app.utils.jalali import to_jalali_str
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
ACTIVE_QC = ("pre_qc_pending", "exec_qc_pending", "ready")
|
||||
FAILED_QC = ("pre_qc_failed", "qc_failed_rework")
|
||||
|
||||
view_projects = require_permission(Perm.projects_view)
|
||||
manage_projects = require_permission(Perm.projects_create)
|
||||
|
||||
|
||||
class ProjectCreateSchema(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
location: str = ""
|
||||
project_manager: str = ""
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
status: str = ProjectStatus.ACTIVE.value
|
||||
|
||||
|
||||
class ProjectUpdateSchema(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
project_manager: str | None = None
|
||||
planned_start: str | None = None
|
||||
planned_end: str | None = None
|
||||
actual_start: str | None = None
|
||||
actual_end: str | None = None
|
||||
status: str | None = None
|
||||
progress: float | None = None
|
||||
|
||||
|
||||
class ProjectMemberCreateSchema(BaseModel):
|
||||
user_id: str
|
||||
role: str = "site_engineer"
|
||||
|
||||
|
||||
class ErpMappingSchema(BaseModel):
|
||||
erp_project_id: str | None = None
|
||||
|
||||
|
||||
class TaskRelationCreateSchema(BaseModel):
|
||||
from_task_id: str
|
||||
to_task_id: str
|
||||
relation_type: str = "FS"
|
||||
lag_days: int = 0
|
||||
|
||||
|
||||
class TaskRelationUpdateSchema(BaseModel):
|
||||
"""Edit an existing dependency. The endpoints (from/to tasks) are immutable —
|
||||
changing them is a different relation, so it must be deleted and recreated."""
|
||||
|
||||
relation_type: str | None = None
|
||||
lag_days: int | None = None
|
||||
|
||||
|
||||
async def _resolve_project(db: AsyncSession, ctx: AuthContext, project_id_or_op: str | int) -> Project:
|
||||
"""Find a project by UUID and enforce scope."""
|
||||
query = select(Project).options(
|
||||
selectinload(Project.members).selectinload(ProjectMember.user)
|
||||
)
|
||||
|
||||
try:
|
||||
val_uuid = uuid.UUID(str(project_id_or_op))
|
||||
stmt = query.where(Project.id == val_uuid)
|
||||
except ValueError:
|
||||
raise NotFoundError("شناسه پروژه نامعتبر است")
|
||||
|
||||
project = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not project:
|
||||
raise NotFoundError("پروژه مورد نظر یافت نشد")
|
||||
ctx.ensure_native_project(project.id)
|
||||
return project
|
||||
|
||||
|
||||
async def native_project_stats(db: AsyncSession, project_id: uuid.UUID) -> dict:
|
||||
"""Task counters of one native project."""
|
||||
today = date_service.today()
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Task.qc_status, Task.planned_end, Task.status, func.count())
|
||||
.where(Task.project_id == project_id)
|
||||
.group_by(Task.qc_status, Task.planned_end, Task.status)
|
||||
)
|
||||
).all()
|
||||
|
||||
total = sum(r[3] for r in rows)
|
||||
completed = sum(r[3] for r in rows if r[2] == "COMPLETED")
|
||||
in_progress = sum(r[3] for r in rows if r[2] == "IN_PROGRESS")
|
||||
blocked = sum(r[3] for r in rows if r[2] == "BLOCKED")
|
||||
awaiting = sum(r[3] for r in rows if r[0] in ACTIVE_QC)
|
||||
failed = sum(r[3] for r in rows if r[0] in FAILED_QC)
|
||||
overdue = sum(r[3] for r in rows if r[2] != "COMPLETED" and r[1] and r[1] < today)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"in_progress": in_progress,
|
||||
"blocked": blocked,
|
||||
"awaiting_qc": awaiting,
|
||||
"failed_qc": failed,
|
||||
"overdue": overdue,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all projects visible to the user, with stats and team counts."""
|
||||
stmt = select(Project).options(selectinload(Project.members)).order_by(Project.created_at.desc())
|
||||
|
||||
# Apply project scoping for supervisor / scoped roles (native projects = UUID)
|
||||
stmt = ctx.scope_native(stmt, Project.id)
|
||||
|
||||
projects = (await db.execute(stmt)).scalars().all()
|
||||
results = []
|
||||
for p in projects:
|
||||
stats = await native_project_stats(db, p.id)
|
||||
results.append(project_dict(p, member_count=len(p.members), task_stats=stats))
|
||||
|
||||
return {"data": results}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(
|
||||
payload: ProjectCreateSchema,
|
||||
ctx: AuthContext = Depends(manage_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new native project."""
|
||||
p_start = date_service.parse(payload.planned_start) if payload.planned_start else None
|
||||
p_end = date_service.parse(payload.planned_end) if payload.planned_end else None
|
||||
if p_start and p_end and p_end < p_start:
|
||||
raise ValidationError("تاریخ پایان نمیتواند قبل از تاریخ شروع باشد", field="planned_end")
|
||||
|
||||
project = Project(
|
||||
name=payload.name.strip(),
|
||||
description=payload.description.strip(),
|
||||
location=payload.location.strip() if payload.location else "",
|
||||
project_manager=payload.project_manager.strip() if payload.project_manager else ctx.user.display_name,
|
||||
planned_start=p_start,
|
||||
planned_end=p_end,
|
||||
status=payload.status.upper(),
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
|
||||
# Automatically add the creator as a project manager member
|
||||
creator_member = ProjectMember(
|
||||
project_id=project.id,
|
||||
user_id=ctx.user.id,
|
||||
role="project_manager",
|
||||
)
|
||||
db.add(creator_member)
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
|
||||
stats = {"total": 0, "completed": 0, "in_progress": 0, "blocked": 0, "overdue": 0}
|
||||
res = project_dict(project, member_count=1, task_stats=stats)
|
||||
res["identifier"] = str(project.id)
|
||||
return {"data": res}
|
||||
|
||||
|
||||
@router.get("/{id}")
|
||||
async def get_project(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get project details, member list, and overall stats."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
stats = await native_project_stats(db, project.id)
|
||||
|
||||
data = project_dict(project, member_count=len(project.members), task_stats=stats)
|
||||
data["identifier"] = str(project.id)
|
||||
data["members"] = [project_member_dict(m) for m in project.members]
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.patch("/{id}")
|
||||
async def update_project(
|
||||
id: str,
|
||||
payload: ProjectUpdateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update project details, status, or dates."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
|
||||
if payload.name is not None:
|
||||
project.name = payload.name.strip()
|
||||
if payload.description is not None:
|
||||
project.description = payload.description.strip()
|
||||
if payload.location is not None:
|
||||
project.location = payload.location.strip()
|
||||
if payload.project_manager is not None:
|
||||
project.project_manager = payload.project_manager.strip()
|
||||
if payload.status is not None:
|
||||
project.status = payload.status.upper()
|
||||
if payload.progress is not None:
|
||||
project.progress = max(0.0, min(100.0, payload.progress))
|
||||
|
||||
if payload.planned_start is not None:
|
||||
project.planned_start = date_service.parse(payload.planned_start)
|
||||
if payload.planned_end is not None:
|
||||
project.planned_end = date_service.parse(payload.planned_end)
|
||||
if payload.actual_start is not None:
|
||||
project.actual_start = date_service.parse(payload.actual_start)
|
||||
if payload.actual_end is not None:
|
||||
project.actual_end = date_service.parse(payload.actual_end)
|
||||
|
||||
if project.planned_start and project.planned_end and project.planned_end < project.planned_start:
|
||||
raise ValidationError("تاریخ پایان نمیتواند قبل از تاریخ شروع باشد", field="planned_end")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(project)
|
||||
stats = await native_project_stats(db, project.id)
|
||||
return {"data": project_dict(project, member_count=len(project.members), task_stats=stats)}
|
||||
|
||||
|
||||
@router.post("/{id}/members")
|
||||
async def add_project_member(
|
||||
id: str,
|
||||
payload: ProjectMemberCreateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a team member to the project."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
target_user_id = uuid.UUID(payload.user_id)
|
||||
|
||||
# Check if already a member
|
||||
existing = await db.scalar(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
existing.role = payload.role
|
||||
else:
|
||||
member = ProjectMember(
|
||||
project_id=project.id,
|
||||
user_id=target_user_id,
|
||||
role=payload.role,
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
await db.commit()
|
||||
# Return updated members list
|
||||
await db.refresh(project)
|
||||
project = await _resolve_project(db, ctx, project.id)
|
||||
return {"data": [project_member_dict(m) for m in project.members]}
|
||||
|
||||
|
||||
@router.delete("/{id}/members/{user_id}")
|
||||
async def remove_project_member(
|
||||
id: str,
|
||||
user_id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a team member from the project."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
target_user_id = uuid.UUID(user_id)
|
||||
|
||||
member = await db.scalar(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project.id, ProjectMember.user_id == target_user_id
|
||||
)
|
||||
)
|
||||
if member:
|
||||
await db.delete(member)
|
||||
await db.commit()
|
||||
|
||||
project = await _resolve_project(db, ctx, project.id)
|
||||
return {"data": [project_member_dict(m) for m in project.members]}
|
||||
|
||||
|
||||
@router.patch("/{id}/erp-mapping")
|
||||
async def map_erp_project(
|
||||
id: str,
|
||||
payload: ErpMappingSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
return {"data": {"id": str(project.id)}}
|
||||
|
||||
|
||||
# --- Relations & Dependencies ---
|
||||
|
||||
@router.get("/{id}/relations")
|
||||
async def list_project_relations(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all task dependencies in a project."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
stmt = (
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(TaskRelation.project_id == project.id)
|
||||
)
|
||||
relations = (await db.execute(stmt)).scalars().all()
|
||||
return {"data": [task_relation_dict(r) for r in relations]}
|
||||
|
||||
|
||||
@router.post("/{id}/relations")
|
||||
async def create_task_relation(
|
||||
id: str,
|
||||
payload: TaskRelationCreateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new task dependency (FS, SS, FF, SF + lag). Enforces cycle check."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
from_id = uuid.UUID(payload.from_task_id)
|
||||
to_id = uuid.UUID(payload.to_task_id)
|
||||
|
||||
if from_id == to_id:
|
||||
raise ValidationError("یک وظیفه نمیتواند پیشنیاز خودش باشد")
|
||||
|
||||
# Verify tasks belong to this project
|
||||
from_task = await db.get(Task, from_id)
|
||||
to_task = await db.get(Task, to_id)
|
||||
if not from_task or from_task.project_id != project.id:
|
||||
raise ValidationError("وظیفه پیشنیاز در این پروژه یافت نشد")
|
||||
if not to_task or to_task.project_id != project.id:
|
||||
raise ValidationError("وظیفه وابسته در این پروژه یافت نشد")
|
||||
|
||||
# Load existing relations to verify DAG
|
||||
existing_rels = (
|
||||
await db.execute(select(TaskRelation.from_task_id, TaskRelation.to_task_id).where(TaskRelation.project_id == project.id))
|
||||
).all()
|
||||
existing_pairs = [(r[0], r[1]) for r in existing_rels]
|
||||
|
||||
# Check for duplicate
|
||||
if (from_id, to_id) in existing_pairs:
|
||||
raise ValidationError("این وابستگی قبلاً ثبت شده است")
|
||||
|
||||
# Cycle check
|
||||
if check_for_cycle(existing_pairs, from_id, to_id):
|
||||
raise ValidationError("این وابستگی باعث ایجاد چرخه (حلقه تکرار) در ساختار زمانبندی میشود")
|
||||
|
||||
rtype = payload.relation_type.upper()
|
||||
if rtype not in ("FS", "SS", "FF", "SF"):
|
||||
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
|
||||
|
||||
relation = TaskRelation(
|
||||
project_id=project.id,
|
||||
from_task_id=from_id,
|
||||
to_task_id=to_id,
|
||||
relation_type=rtype,
|
||||
lag_days=payload.lag_days,
|
||||
)
|
||||
db.add(relation)
|
||||
await db.commit()
|
||||
|
||||
# Recalculate schedule cascade automatically
|
||||
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||||
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
|
||||
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
|
||||
|
||||
# Update tasks with new early dates if changed
|
||||
for t in all_tasks:
|
||||
if t.id in sched_result["tasks_schedule"]:
|
||||
info = sched_result["tasks_schedule"][t.id]
|
||||
t.planned_start = info["planned_start"]
|
||||
t.planned_end = info["planned_end"]
|
||||
t.duration = info["duration"]
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(relation)
|
||||
return {"data": task_relation_dict(relation, from_task.title, to_task.title)}
|
||||
|
||||
|
||||
@router.patch("/{id}/relations/{relation_id}")
|
||||
async def update_task_relation(
|
||||
id: str,
|
||||
relation_id: str,
|
||||
payload: TaskRelationUpdateSchema,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_create)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Edit a dependency's type and/or lag, then re-run the schedule cascade."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
rel = await db.scalar(
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(TaskRelation.id == uuid.UUID(relation_id), TaskRelation.project_id == project.id)
|
||||
)
|
||||
if rel is None:
|
||||
raise NotFoundError("وابستگی یافت نشد")
|
||||
|
||||
if payload.relation_type is not None:
|
||||
rtype = payload.relation_type.upper()
|
||||
if rtype not in ("FS", "SS", "FF", "SF"):
|
||||
raise ValidationError("نوع وابستگی باید یکی از مقادیر FS، SS، FF، SF باشد")
|
||||
rel.relation_type = rtype
|
||||
if payload.lag_days is not None:
|
||||
rel.lag_days = payload.lag_days
|
||||
await db.commit()
|
||||
|
||||
# Recalculate the schedule so the edit takes effect immediately
|
||||
all_tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||||
all_relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
|
||||
sched_result = calculate_schedule(all_tasks, all_relations, project.planned_start)
|
||||
for t in all_tasks:
|
||||
if t.id in sched_result["tasks_schedule"]:
|
||||
info = sched_result["tasks_schedule"][t.id]
|
||||
t.planned_start = info["planned_start"]
|
||||
t.planned_end = info["planned_end"]
|
||||
t.duration = info["duration"]
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(rel)
|
||||
return {"data": task_relation_dict(rel, rel.from_task.title, rel.to_task.title)}
|
||||
|
||||
|
||||
@router.delete("/{id}/relations/{relation_id}")
|
||||
async def delete_task_relation(
|
||||
id: str,
|
||||
relation_id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.tasks_delete)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a task dependency."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
rel_uuid = uuid.UUID(relation_id)
|
||||
rel = await db.scalar(
|
||||
select(TaskRelation).where(TaskRelation.id == rel_uuid, TaskRelation.project_id == project.id)
|
||||
)
|
||||
if rel:
|
||||
await db.delete(rel)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# --- Interactive Gantt & Topological Scheduling ---
|
||||
|
||||
@router.get("/{id}/gantt")
|
||||
async def get_project_gantt(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(view_projects),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return full Gantt chart dataset with CPM calculations, relations, and critical path."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
|
||||
# 1. Fetch tasks, relations, milestones
|
||||
tasks = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.options(selectinload(Task.children))
|
||||
.where(Task.project_id == project.id)
|
||||
.order_by(Task.planned_start.asc().nulls_last(), Task.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
relations = (
|
||||
await db.execute(
|
||||
select(TaskRelation)
|
||||
.options(selectinload(TaskRelation.from_task), selectinload(TaskRelation.to_task))
|
||||
.where(TaskRelation.project_id == project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# 2. Run CPM scheduling analysis
|
||||
sched = calculate_schedule(tasks, relations, project.planned_start)
|
||||
sched_info = sched["tasks_schedule"]
|
||||
crit_set = set(sched["critical_path"])
|
||||
|
||||
# 3. Format tasks
|
||||
formatted_tasks = []
|
||||
for t in tasks:
|
||||
t_sched = sched_info.get(t.id, {})
|
||||
start_dt = t_sched.get("planned_start") or t.planned_start
|
||||
end_dt = t_sched.get("planned_end") or t.planned_end
|
||||
formatted_tasks.append({
|
||||
"id": str(t.id),
|
||||
"title": t.title,
|
||||
"subject": t.title,
|
||||
"parent_id": str(t.parent_id) if t.parent_id else None,
|
||||
"status": t.status,
|
||||
"priority": t.priority,
|
||||
"planned_start": _iso(start_dt),
|
||||
"planned_end": _iso(end_dt),
|
||||
"planned_start_jalali": to_jalali_str(start_dt),
|
||||
"planned_end_jalali": to_jalali_str(end_dt),
|
||||
"duration": t_sched.get("duration") or t.duration or 1,
|
||||
"progress": t.progress or 0,
|
||||
"is_critical": t.id in crit_set,
|
||||
"float_days": t_sched.get("float_days", 0),
|
||||
"qc_status": t.qc_status or "none",
|
||||
"assignee_name": t.assignee_name or (t.assignee.display_name if getattr(t, "assignee", None) else None),
|
||||
})
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"project": {
|
||||
"id": str(project.id),
|
||||
"identifier": str(project.id),
|
||||
"name": project.name,
|
||||
"planned_start": _iso(project.planned_start),
|
||||
"planned_end": _iso(project.planned_end),
|
||||
"planned_start_jalali": to_jalali_str(project.planned_start),
|
||||
"planned_end_jalali": to_jalali_str(project.planned_end),
|
||||
"progress": project.progress,
|
||||
},
|
||||
"tasks": formatted_tasks,
|
||||
"relations": [task_relation_dict(r) for r in relations],
|
||||
"critical_path": [str(tid) for tid in sched["critical_path"]],
|
||||
"project_finish": _iso(sched.get("project_finish")),
|
||||
"project_finish_jalali": to_jalali_str(sched.get("project_finish")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{id}/reschedule")
|
||||
async def reschedule_project(
|
||||
id: str,
|
||||
ctx: AuthContext = Depends(require_permission(Perm.projects_update)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger full topological forward-pass recalculation and persist cascaded dates."""
|
||||
project = await _resolve_project(db, ctx, id)
|
||||
|
||||
tasks = (await db.execute(select(Task).where(Task.project_id == project.id))).scalars().all()
|
||||
relations = (await db.execute(select(TaskRelation).where(TaskRelation.project_id == project.id))).scalars().all()
|
||||
|
||||
sched = calculate_schedule(tasks, relations, project.planned_start)
|
||||
sched_info = sched["tasks_schedule"]
|
||||
|
||||
updated_count = 0
|
||||
for t in tasks:
|
||||
if t.id in sched_info:
|
||||
info = sched_info[t.id]
|
||||
t.planned_start = info["planned_start"]
|
||||
t.planned_end = info["planned_end"]
|
||||
t.duration = info["duration"]
|
||||
updated_count += 1
|
||||
|
||||
# Roll up summary tasks
|
||||
rollups = rollup_parent_tasks(tasks)
|
||||
for pid, rdata in rollups.items():
|
||||
parent_task = await db.get(Task, pid)
|
||||
if parent_task:
|
||||
if rdata["planned_start"]:
|
||||
parent_task.planned_start = rdata["planned_start"]
|
||||
if rdata["planned_end"]:
|
||||
parent_task.planned_end = rdata["planned_end"]
|
||||
if rdata.get("duration"):
|
||||
parent_task.duration = rdata["duration"]
|
||||
|
||||
await sync_project_lifecycle_dates(db, project.id)
|
||||
await db.commit()
|
||||
return {"data": {"recalculated": True, "tasks_updated": updated_count}}
|
||||
@@ -0,0 +1,729 @@
|
||||
"""QC module.
|
||||
|
||||
Two clearly separated halves:
|
||||
|
||||
**تعریف (Definition)** — what must eventually be checked
|
||||
* `QCTemplate` + `QCQuestion` : global, reusable, editable
|
||||
* `TaskQCDefinition`/`TaskQCQuestion`: a task-specific COPY the user customizes
|
||||
|
||||
**اجرا (Execution)** — the actual evaluation during the project
|
||||
* `TaskQCResult` : append-only answers (pass / fail / na) + comment
|
||||
* `Defect` : problems raised on a task, with corrective action
|
||||
|
||||
Applying a template to a task deep-copies its questions, so customizing a task
|
||||
never mutates the global template.
|
||||
|
||||
Access split (app/authz.py):
|
||||
* `qc.define` — templates + the per-task QC structure: project manager only
|
||||
* `qc.execute` — recording answers and defects, on assigned projects
|
||||
* `qc.view` — reading status/readiness, on assigned projects
|
||||
"""
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.authz import (
|
||||
AuthContext,
|
||||
Perm,
|
||||
require_permission,
|
||||
scoped_task,
|
||||
)
|
||||
from app.db import get_db
|
||||
from app.errors import NotFoundError, ValidationError
|
||||
from app.models import (
|
||||
Defect,
|
||||
QCAnswer,
|
||||
QCQuestion,
|
||||
QCQuestionPhase,
|
||||
QCTemplate,
|
||||
Task,
|
||||
TaskQCDefinition,
|
||||
TaskQCQuestion,
|
||||
TaskQCResult,
|
||||
)
|
||||
from app.qc_service import (
|
||||
PRE,
|
||||
build_state,
|
||||
definition_dict,
|
||||
load_definitions,
|
||||
question_dict,
|
||||
recompute_task_qc_status,
|
||||
state_dict,
|
||||
task_qc_state,
|
||||
)
|
||||
from app.serializers import _iso
|
||||
|
||||
router = APIRouter(prefix="/qc", tags=["qc"])
|
||||
|
||||
define_qc = require_permission(Perm.qc_define)
|
||||
execute_qc = require_permission(Perm.qc_execute)
|
||||
view_qc = require_permission(Perm.qc_view)
|
||||
|
||||
PHASE_LABELS = {PRE: "پیش از اجرا", "during": "هنگام اجرا", "after": "پس از اجرا"}
|
||||
|
||||
|
||||
def _phase(value: str) -> QCQuestionPhase:
|
||||
try:
|
||||
return QCQuestionPhase(value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("مرحله باید pre، during یا after باشد", field="phase") from exc
|
||||
|
||||
|
||||
def _answer(value: str) -> QCAnswer:
|
||||
try:
|
||||
return QCAnswer(value)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("پاسخ باید pass، fail یا na باشد", field="answer") from exc
|
||||
|
||||
|
||||
@dataclass
|
||||
class QCTaskRef:
|
||||
"""A task as the QC module sees it."""
|
||||
|
||||
id: uuid.UUID
|
||||
subject: str
|
||||
progress: int
|
||||
status: str
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return self.id
|
||||
|
||||
|
||||
async def _qc_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> QCTaskRef:
|
||||
"""Resolve the task a QC screen is about, enforcing the caller's scope."""
|
||||
native = await scoped_task(db, ctx, task_ref)
|
||||
return QCTaskRef(
|
||||
id=native.id,
|
||||
subject=native.title or "",
|
||||
progress=native.progress or 0,
|
||||
status=native.status or "",
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_definition_scope(db: AsyncSession, ctx: AuthContext, definition: TaskQCDefinition) -> None:
|
||||
native = await db.get(Task, definition.task_id)
|
||||
if native is not None:
|
||||
ctx.ensure_project(native.project_id)
|
||||
return
|
||||
raise NotFoundError("تعریف QC یافت نشد")
|
||||
|
||||
|
||||
async def _definition(db: AsyncSession, definition_id: uuid.UUID) -> TaskQCDefinition:
|
||||
row = await db.scalar(
|
||||
select(TaskQCDefinition)
|
||||
.options(selectinload(TaskQCDefinition.questions).selectinload(TaskQCQuestion.results))
|
||||
.where(TaskQCDefinition.id == definition_id)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("تعریف QC یافت نشد")
|
||||
return row
|
||||
|
||||
|
||||
async def _scoped_definition(db: AsyncSession, ctx: AuthContext, definition_id: uuid.UUID) -> TaskQCDefinition:
|
||||
definition = await _definition(db, definition_id)
|
||||
await _ensure_definition_scope(db, ctx, definition)
|
||||
return definition
|
||||
|
||||
|
||||
async def _question(db: AsyncSession, question_id: uuid.UUID) -> TaskQCQuestion:
|
||||
row = await db.scalar(
|
||||
select(TaskQCQuestion)
|
||||
.options(selectinload(TaskQCQuestion.results))
|
||||
.where(TaskQCQuestion.id == question_id)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("سؤال QC یافت نشد")
|
||||
return row
|
||||
|
||||
|
||||
async def _scoped_definition_question(
|
||||
db: AsyncSession, ctx: AuthContext, question_id: uuid.UUID
|
||||
) -> tuple[TaskQCQuestion, TaskQCDefinition]:
|
||||
question = await _question(db, question_id)
|
||||
definition = await db.get(TaskQCDefinition, question.definition_id)
|
||||
if definition is None:
|
||||
raise NotFoundError("تعریف QC یافت نشد")
|
||||
await _ensure_definition_scope(db, ctx, definition)
|
||||
return question, definition
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Global QC templates
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _template_dict(template: QCTemplate) -> dict:
|
||||
return {
|
||||
"id": str(template.id),
|
||||
"name": template.name,
|
||||
"activity_type": template.activity_type,
|
||||
"description": template.description,
|
||||
"questions": [
|
||||
{
|
||||
"id": str(q.id),
|
||||
"phase": q.phase.value,
|
||||
"text": q.text,
|
||||
"sort_order": q.sort_order,
|
||||
"required": q.required,
|
||||
}
|
||||
for q in template.questions
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _template(db: AsyncSession, template_id: uuid.UUID) -> QCTemplate:
|
||||
row = await db.scalar(
|
||||
select(QCTemplate)
|
||||
.options(selectinload(QCTemplate.questions))
|
||||
.where(QCTemplate.id == template_id)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("قالب QC یافت نشد")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(activity_type: str | None = None, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
|
||||
stmt = select(QCTemplate).options(selectinload(QCTemplate.questions)).order_by(QCTemplate.name)
|
||||
if activity_type:
|
||||
stmt = stmt.where(QCTemplate.activity_type == activity_type)
|
||||
templates = (await db.execute(stmt)).scalars().all()
|
||||
return {"data": [_template_dict(t) for t in templates]}
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def get_template(template_id: uuid.UUID, _: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)):
|
||||
return {"data": _template_dict(await _template(db, template_id))}
|
||||
|
||||
|
||||
class QuestionIn(BaseModel):
|
||||
phase: str
|
||||
text: str
|
||||
required: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class TemplateIn(BaseModel):
|
||||
name: str
|
||||
activity_type: str = ""
|
||||
description: str = ""
|
||||
questions: list[QuestionIn] = []
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _name_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("نام قالب الزامی است")
|
||||
return v.strip()
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(payload: TemplateIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
|
||||
template = QCTemplate(
|
||||
name=payload.name,
|
||||
activity_type=payload.activity_type,
|
||||
description=payload.description,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush()
|
||||
|
||||
for index, q in enumerate(payload.questions):
|
||||
db.add(
|
||||
QCQuestion(
|
||||
template_id=template.id,
|
||||
phase=_phase(q.phase),
|
||||
text=q.text.strip(),
|
||||
sort_order=q.sort_order if q.sort_order else index,
|
||||
required=q.required,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {"data": _template_dict(await _template(db, template.id))}
|
||||
|
||||
|
||||
class TemplatePatch(BaseModel):
|
||||
name: str | None = None
|
||||
activity_type: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
async def patch_template(
|
||||
template_id: uuid.UUID, payload: TemplatePatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
template = await _template(db, template_id)
|
||||
if payload.name is not None:
|
||||
if not payload.name.strip():
|
||||
raise ValidationError("نام قالب نمیتواند خالی باشد", field="name")
|
||||
template.name = payload.name.strip()
|
||||
if payload.activity_type is not None:
|
||||
template.activity_type = payload.activity_type
|
||||
if payload.description is not None:
|
||||
template.description = payload.description
|
||||
await db.commit()
|
||||
return {"data": _template_dict(template)}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
async def delete_template(template_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
|
||||
template = await _template(db, template_id)
|
||||
await db.delete(template)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/questions")
|
||||
async def add_template_question(
|
||||
template_id: uuid.UUID, payload: QuestionIn, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
template = await _template(db, template_id)
|
||||
question = QCQuestion(
|
||||
template_id=template.id,
|
||||
phase=_phase(payload.phase),
|
||||
text=payload.text.strip(),
|
||||
sort_order=payload.sort_order,
|
||||
required=payload.required,
|
||||
)
|
||||
db.add(question)
|
||||
await db.commit()
|
||||
return {"data": _template_dict(await _template(db, template_id))}
|
||||
|
||||
|
||||
class QuestionPatch(BaseModel):
|
||||
text: str | None = None
|
||||
phase: str | None = None
|
||||
required: bool | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
@router.patch("/questions/{question_id}")
|
||||
async def patch_question(
|
||||
question_id: uuid.UUID, payload: QuestionPatch, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
question = await db.get(QCQuestion, question_id)
|
||||
if question is None:
|
||||
raise NotFoundError("سؤال QC یافت نشد")
|
||||
if payload.text is not None:
|
||||
if not payload.text.strip():
|
||||
raise ValidationError("متن سؤال نمیتواند خالی باشد", field="text")
|
||||
question.text = payload.text.strip()
|
||||
if payload.phase is not None:
|
||||
question.phase = _phase(payload.phase)
|
||||
if payload.required is not None:
|
||||
question.required = payload.required
|
||||
if payload.sort_order is not None:
|
||||
question.sort_order = payload.sort_order
|
||||
await db.commit()
|
||||
return {"data": {"id": str(question.id)}}
|
||||
|
||||
|
||||
@router.delete("/questions/{question_id}")
|
||||
async def delete_question(question_id: uuid.UUID, _: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)):
|
||||
question = await db.get(QCQuestion, question_id)
|
||||
if question is None:
|
||||
raise NotFoundError("سؤال QC یافت نشد")
|
||||
await db.delete(question)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Task QC Definitions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class DefinitionIn(BaseModel):
|
||||
name: str = ""
|
||||
template_id: str | None = None
|
||||
|
||||
|
||||
@router.get("/tasks/{task_ref}")
|
||||
async def task_qc(
|
||||
task_ref: str, ctx: AuthContext = Depends(view_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Everything the QC screen needs: definitions + questions + results + readiness."""
|
||||
task = await _qc_task(db, ctx, task_ref)
|
||||
definitions = await load_definitions(db, task.key)
|
||||
state = build_state(definitions)
|
||||
return {
|
||||
"data": {
|
||||
"task": {
|
||||
"id": str(task.id),
|
||||
"subject": task.subject,
|
||||
"percentage_done": task.progress,
|
||||
"status": task.status,
|
||||
},
|
||||
"definitions": [definition_dict(d, state) for d in definitions],
|
||||
"readiness": state_dict(state, progress=task.progress),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_ref}/definition")
|
||||
async def create_definition(
|
||||
task_ref: str,
|
||||
payload: DefinitionIn,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = await _qc_task(db, ctx, task_ref)
|
||||
template: QCTemplate | None = None
|
||||
if payload.template_id:
|
||||
try:
|
||||
template_uuid = uuid.UUID(payload.template_id)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("شناسه قالب نامعتبر است", field="template_id") from exc
|
||||
template = await _template(db, template_uuid)
|
||||
|
||||
definition = TaskQCDefinition(
|
||||
task_id=task.id,
|
||||
name=payload.name.strip() or (template.name if template else "QC تسک"),
|
||||
source_template_id=template.id if template else None,
|
||||
)
|
||||
db.add(definition)
|
||||
await db.flush()
|
||||
|
||||
if template:
|
||||
for index, question in enumerate(template.questions):
|
||||
db.add(
|
||||
TaskQCQuestion(
|
||||
definition_id=definition.id,
|
||||
phase=question.phase,
|
||||
text=question.text,
|
||||
sort_order=index,
|
||||
required=question.required,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, task.key)
|
||||
await db.commit()
|
||||
return {"data": {"id": str(definition.id)}}
|
||||
|
||||
|
||||
class DefinitionPatch(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.patch("/definitions/{definition_id}")
|
||||
async def patch_definition(
|
||||
definition_id: uuid.UUID,
|
||||
payload: DefinitionPatch,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
definition = await _scoped_definition(db, ctx, definition_id)
|
||||
if not payload.name.strip():
|
||||
raise ValidationError("نام تعریف الزامی است", field="name")
|
||||
definition.name = payload.name.strip()
|
||||
await db.commit()
|
||||
return {"data": {"id": str(definition.id), "name": definition.name}}
|
||||
|
||||
|
||||
@router.delete("/definitions/{definition_id}")
|
||||
async def delete_definition(
|
||||
definition_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
definition = await _scoped_definition(db, ctx, definition_id)
|
||||
task_key = definition.task_id
|
||||
await db.delete(definition)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, task_key)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
class TaskQuestionIn(BaseModel):
|
||||
text: str
|
||||
phase: str
|
||||
required: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
@router.post("/definitions/{definition_id}/questions")
|
||||
async def add_task_question(
|
||||
definition_id: uuid.UUID,
|
||||
payload: TaskQuestionIn,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
definition = await _scoped_definition(db, ctx, definition_id)
|
||||
question = TaskQCQuestion(
|
||||
definition_id=definition.id,
|
||||
phase=_phase(payload.phase),
|
||||
text=payload.text.strip(),
|
||||
sort_order=payload.sort_order,
|
||||
required=payload.required,
|
||||
)
|
||||
db.add(question)
|
||||
definition.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
return {"data": {"id": str(question.id)}}
|
||||
|
||||
|
||||
@router.patch("/task-questions/{question_id}")
|
||||
async def patch_task_question(
|
||||
question_id: uuid.UUID,
|
||||
payload: QuestionPatch,
|
||||
ctx: AuthContext = Depends(define_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
if "text" in values:
|
||||
if not values["text"].strip():
|
||||
raise ValidationError("متن سؤال نمیتواند خالی باشد", field="text")
|
||||
question.text = values["text"].strip()
|
||||
values.pop("text")
|
||||
if "phase" in values:
|
||||
question.phase = _phase(values.pop("phase"))
|
||||
for key, value in values.items():
|
||||
setattr(question, key, value)
|
||||
definition.updated_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
return {"data": {"id": str(question.id)}}
|
||||
|
||||
|
||||
@router.delete("/task-questions/{question_id}")
|
||||
async def delete_task_question(
|
||||
question_id: uuid.UUID, ctx: AuthContext = Depends(define_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
task_key = definition.task_id
|
||||
await db.delete(question)
|
||||
await db.commit()
|
||||
await recompute_task_qc_status(db, task_key)
|
||||
await db.commit()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Execution / evaluation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ResultIn(BaseModel):
|
||||
answer: str | None = None
|
||||
comment: str = ""
|
||||
photo_id: str | None = None
|
||||
|
||||
|
||||
@router.put("/task-questions/{question_id}/result")
|
||||
async def evaluate_question(
|
||||
question_id: uuid.UUID,
|
||||
payload: ResultIn,
|
||||
ctx: AuthContext = Depends(execute_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
|
||||
# اگر پاسخ خالی یا null باشد، انتخاب قبلی لغو شده و سؤال به حالت بدون پاسخ برمیگردد
|
||||
if not payload.answer or payload.answer.strip().lower() in ("none", "null", "cancel", "clear", "pending"):
|
||||
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
|
||||
await db.commit()
|
||||
status = await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
state = await task_qc_state(db, definition.task_id)
|
||||
return {
|
||||
"data": {
|
||||
"question_id": str(question.id),
|
||||
"answer": None,
|
||||
"task_qc_status": status,
|
||||
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
|
||||
}
|
||||
}
|
||||
|
||||
user = ctx.user
|
||||
answer = _answer(payload.answer)
|
||||
|
||||
existing_latest = await db.scalar(
|
||||
select(TaskQCResult)
|
||||
.where(TaskQCResult.question_id == question.id)
|
||||
.order_by(TaskQCResult.evaluated_at.desc(), TaskQCResult.id.desc())
|
||||
)
|
||||
photo_path = payload.photo_id
|
||||
if photo_path is None and existing_latest and existing_latest.answer == answer:
|
||||
photo_path = existing_latest.photo_path
|
||||
elif photo_path == "":
|
||||
photo_path = None
|
||||
|
||||
db.add(
|
||||
TaskQCResult(
|
||||
question_id=question.id,
|
||||
answer=answer,
|
||||
comment=payload.comment.strip(),
|
||||
photo_path=photo_path,
|
||||
evaluated_by=user.display_name or user.username,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
status = await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
state = await task_qc_state(db, definition.task_id)
|
||||
return {
|
||||
"data": {
|
||||
"question_id": str(question.id),
|
||||
"answer": answer.value,
|
||||
"photo_url": f"/api/v1/files/{photo_path}" if photo_path else None,
|
||||
"task_qc_status": status,
|
||||
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/task-questions/{question_id}/result")
|
||||
async def delete_question_result(
|
||||
question_id: uuid.UUID,
|
||||
ctx: AuthContext = Depends(execute_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
question, definition = await _scoped_definition_question(db, ctx, question_id)
|
||||
await db.execute(delete(TaskQCResult).where(TaskQCResult.question_id == question.id))
|
||||
await db.commit()
|
||||
status = await recompute_task_qc_status(db, definition.task_id)
|
||||
await db.commit()
|
||||
state = await task_qc_state(db, definition.task_id)
|
||||
return {
|
||||
"data": {
|
||||
"question_id": str(question.id),
|
||||
"answer": None,
|
||||
"task_qc_status": status,
|
||||
"readiness": state_dict(state, progress=await _progress(db, definition.task_id)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _progress(db: AsyncSession, task_id: uuid.UUID) -> int:
|
||||
task = await db.scalar(select(Task).where(Task.id == task_id))
|
||||
return (task.progress or 0) if task else 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Defects
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _defect_dict(defect: Defect) -> dict:
|
||||
return {
|
||||
"id": str(defect.id),
|
||||
"task_id": str(defect.task_id),
|
||||
"question_id": str(defect.question_id) if defect.question_id else None,
|
||||
"title": defect.title,
|
||||
"description": defect.description,
|
||||
"severity": defect.severity,
|
||||
"status": defect.status,
|
||||
"corrective_action": defect.corrective_action,
|
||||
"resolution_notes": defect.resolution_notes,
|
||||
"photo_url": f"/api/v1/files/{defect.photo_path}" if defect.photo_path else None,
|
||||
"created_at": _iso(defect.created_at),
|
||||
"resolved_at": _iso(defect.resolved_at),
|
||||
}
|
||||
|
||||
|
||||
class DefectIn(BaseModel):
|
||||
task_ref: str
|
||||
question_id: str | None = None
|
||||
title: str
|
||||
description: str = ""
|
||||
severity: str = "medium"
|
||||
corrective_action: str = ""
|
||||
photo_id: str | None = None
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def _title(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("عنوان عیب الزامی است")
|
||||
return v.strip()
|
||||
|
||||
|
||||
@router.post("/defects")
|
||||
async def create_defect(
|
||||
payload: DefectIn, ctx: AuthContext = Depends(execute_qc), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
task = await _qc_task(db, ctx, payload.task_ref)
|
||||
question_id = None
|
||||
if payload.question_id:
|
||||
try:
|
||||
question_id = uuid.UUID(payload.question_id)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("شناسه سؤال نامعتبر است", field="question_id") from exc
|
||||
defect = Defect(
|
||||
task_id=task.id,
|
||||
question_id=question_id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
severity=payload.severity if payload.severity in ("low", "medium", "high") else "medium",
|
||||
corrective_action=payload.corrective_action,
|
||||
photo_path=payload.photo_id,
|
||||
)
|
||||
db.add(defect)
|
||||
await db.commit()
|
||||
return {"data": _defect_dict(defect)}
|
||||
|
||||
|
||||
class ResolveIn(BaseModel):
|
||||
resolution_notes: str = ""
|
||||
|
||||
|
||||
@router.post("/defects/{defect_id}/resolve")
|
||||
async def resolve_defect(
|
||||
defect_id: uuid.UUID,
|
||||
payload: ResolveIn,
|
||||
ctx: AuthContext = Depends(execute_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
defect = await db.get(Defect, defect_id)
|
||||
if defect is None:
|
||||
raise NotFoundError("عیب یافت نشد")
|
||||
await scoped_task(db, ctx, defect.task_id)
|
||||
if defect.status == "resolved":
|
||||
raise ValidationError("این عیب قبلاً رفع شده است")
|
||||
defect.status = "resolved"
|
||||
defect.resolution_notes = payload.resolution_notes.strip()
|
||||
defect.resolved_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"data": _defect_dict(defect)}
|
||||
|
||||
|
||||
@router.get("/defects")
|
||||
async def list_defects(
|
||||
task_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
ctx: AuthContext = Depends(view_qc),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(Defect).join(Task, Task.id == Defect.task_id)
|
||||
if task_id:
|
||||
query = query.where(Defect.task_id == task_id)
|
||||
if status:
|
||||
query = query.where(Defect.status == status)
|
||||
query = ctx.scope(query, Task.project_id)
|
||||
query = query.order_by(Defect.created_at.desc()).limit(150)
|
||||
rows = (await db.execute(query)).scalars().all()
|
||||
|
||||
items = []
|
||||
for d in rows:
|
||||
task = await db.get(Task, d.task_id)
|
||||
di = _defect_dict(d)
|
||||
di["task_title"] = task.title if task else ""
|
||||
items.append(di)
|
||||
return {"data": items}
|
||||
|
||||
|
||||
async def defects_for_task(db: AsyncSession, task_id: uuid.UUID) -> list[Defect]:
|
||||
rows = (
|
||||
await db.execute(select(Defect).where(Defect.task_id == task_id).order_by(Defect.created_at.desc()))
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user