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

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

Verified: 41 renames staged with zero secret/cache files; voice/ and bot/.env still ignored.
2026-10-02 12:52:25 +03:30
Milad df3ff4ea14 feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform 2026-10-02 03:47:33 +03:30
162 changed files with 26672 additions and 1143 deletions
-1
View File
@@ -30,7 +30,6 @@ frontend/.env
scratch/
v1.2 - Copy/
inv dash/
bot/
voice/
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
+24 -15
View File
@@ -145,18 +145,6 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
user.username = username
await db.flush()
project_ids: list = []
if user.role in ("supervisor", "pm"):
projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
project_ids = [p.id for p in projects]
project_id = project_ids[0] if project_ids else None
# لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
panels: dict = {}
panel_rows = (
@@ -166,6 +154,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
@@ -177,6 +166,19 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
).scalars().all()
]
pm_projects = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
pm_project_ids_list = [p.id for p in pm_projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(
user=user,
@@ -190,15 +192,22 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM/supervisor user (oldest first)."""
rows = (
"""Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
pm_rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
member_rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
+3
View File
@@ -420,6 +420,9 @@ async def ensure_rbac_schema(engine: AsyncEngine) -> None:
volume — the RBAC model expects these and login 500s without them."""
stmts = (
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
# The live user_role enum predates 'supervisor' — CREATE TYPE no-ops on
# an existing type, so without this ALTER, assigning the role 500s.
"ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'supervisor'",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
+2 -1
View File
@@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user)
if ctx.role == "supervisor" and not projects:
has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track(
"login_success",
+12 -2
View File
@@ -12,6 +12,7 @@ import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
@@ -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)
+6 -3
View File
@@ -169,11 +169,14 @@ async def create_quote(
):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
member = set(ctx.member_project_ids or [])
wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= member:
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
+25 -11
View File
@@ -17,7 +17,7 @@ import uuid
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy import delete, select, update, text
from sqlalchemy import delete, select, update, text, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
@@ -100,7 +100,9 @@ async def user_to_dict(user: User, projects: list[Project] | None = None, db: As
}
if db is not None:
out["panels"] = await _panels_of(db, user.id)
out["member_project_ids"] = await _member_project_ids_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
@@ -112,13 +114,23 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project assigned to the user (supervisor or PM) (oldest first)."""
"""Every project assigned to the user (supervisor or PM) (oldest first).
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"):
return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
@@ -246,12 +258,12 @@ async def create_user(
async def update_user_role(
user_id,
payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")),
ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not
assign or elevate roles. Revoking (or switching away from «pm») also
unassigns the user from their project."""
"""Set/clear a role — organization management (admin, procurement manager, PM)."""
if payload.role == "admin" and ctx.role != "admin":
raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
@@ -386,13 +398,14 @@ async def assign_user_project(
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین."""
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
@@ -409,10 +422,11 @@ async def set_user_panels(
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(require_role("admin")),
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
+150
View File
@@ -0,0 +1,150 @@
# گزارش ادغام ایجنت هوش مصنوعی با ریپوی ERP
> این سند وضعیت نهایی یکپارچه‌سازی بات تلگرام/بله (procurement AI agent) با پروژه‌ی ERP را
> توضیح می‌دهد: مشکلی که پیش آمده بود، چه چیزی اصلاح شد، وضعیت فعلی گیت و سرور،
> نکات امنیتی و نحوه‌ی استفاده. تاریخ آخرین به‌روزرسانی: برنچ `prod-v7`.
---
## ۱. مشکل چی بود؟
سرویس `bot` در `docker-compose.yml` این‌طور تعریف شده بود:
```yaml
bot:
build:
context: ./bot # ← پوشه‌ای که در گیت وجود نداشت!
volumes:
- ./bot/app:/srv/bot/app
```
اما:
1. **پوشه‌ی `bot/` هیچ‌وقت کامیت نشده بود** — نه روی `prod-v7` و نه روی هیچ برنچ دیگری.
2. کد ایجنت در پوشه‌ای به نام `ai procurement/` (با فاصله در نام) زندگی می‌کرد که روی `prod-v7` کامیت شده بود، ولی compose به آن اشاره نمی‌کرد.
3. روی دستگاه reza.g یک **Windows Junction** (میان‌بر سطح NTFS) به نام `bot` وجود داشت که به `ai procurement` اشاره می‌کرد — به همین دلیل `docker compose build bot` فقط روی ماشین او جواب می‌داد.
4. خود برنچ `prod-v7` هم `bot/` را در `.gitignore` (خط ۳۳) داشت.
**نتیجه:** روی هر کلون تمیزی از مخزن، `docker compose build bot` شکست می‌خورد —
همان چیزی که تیم گزارش کرد: «bot/ was never committed — prod-v7's bot is un-buildable from the repo.»
## ۲. راه‌حل اعمال‌شده (راه‌حل شماره ۲)
1. Junction ویندوزی `bot` حذف شد (فقط میان‌بر؛ فایلی از دست نرفت).
2. پوشه‌ی `ai procurement/` به **`bot/`** تغییر نام یافت — با `git mv`، یعنی
**تاریخچه‌ی گیت هر ۴۱ فایل به‌صورت rename خالص حفظ شده** (`R` در گیت، نه حذف/افزودن).
3. خط `bot/` از `.gitignore` ریشه حذف شد تا پوشه‌ی بات دیگر ignore نشود.
(`voice/` سر جایش ماند — نت‌های صوتی رuntimes هستند و نباید کامیت شوند.)
4. اکنون `docker-compose.yml` **بدون هیچ تغییری** درست کار می‌کند:
`context: ./bot` و `./bot/app` دقیقاً به فایل‌های کامیت‌شده اشاره می‌کنند.
5. همه‌چیز روی برنچ **`prod-v7`** کامیت و پوش شده تا تیم به فایل‌ها دسترسی داشته باشد.
### قواعد ignore فعلی (عمدی)
| مسیر | وضعیت | دلیل |
|---|---|---|
| `bot/.env` | ignore (توسط `bot/.gitignore`) | کلیدها و توکن‌ها — هرگز کامیت نمی‌شوند |
| `bot/__pycache__/` و `*.pyc` | ignore | خروجی runtime پایتون |
| `voice/` | ignore (ریشه) | فایل‌های صوتی دریافتی از بله در زمان اجرا |
| `procurement_ai_agent.bundle` و `git_history_backup.zip` | **کامیت‌شده** | بکاپ تاریخچه‌ی اصلی ایجنت (~۲۰۰KB) |
## ۳. وضعیت فعلی گیت (تاریخ این سند)
- برنچ فعال: **`prod-v7`** (محلی و `origin/prod-v7` هم‌SHA).
- درخت `prod-v7` = کل اپلیکیشن ERP (backend/frontend/…) **به‌علاوه‌ی** `bot/`
(کد ایجنت + Dockerfile + docs + integration) + `backend/app/routers/drafts.py`
(اندپوینت‌های همگام‌سازی draft بات ↔ ویزارد) + اسکیمای `bot_drafts`.
- تاریخچه: از production (d69ec7f) یازده کامیت جلوتر — از «feat(drafts): bot_drafts schema»
تا «feat(bot,wizard): smart message filtering…».
- بقیه‌ی برنچ‌های تیم (`prod-v8`، `prod-v9`، `prod_v8`، `production`) **فایل‌های بات را ندارند** —
ادغام این برنچ با کار جدید تیم، قدم بعدی جداگانه است (با تأیید تیم).
## ۴. ⚠️ نکته‌ی امنیتی (مهم — اقدام لازم)
در همان `docker-compose.yml`، مقادیر **واقعی** به‌عنوان پیش‌فرض env کامیت و پوش شده‌اند:
```yaml
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
```
یعنی توکن بات بله و کلید DeepSeek الان در تاریخچه‌ی مخزن مشترک است و **هر کسی که به ریپو
دسترسی دارد می‌تواند آن‌ها را ببیند** (`.env` کامیت نشده، ولی این پیش‌فرض‌ها عملاً همان لو رفتن راز است).
اقدامات توصیه‌شده (هنوز انجام نشده — نیاز به تصمیم مالک توکن‌ها):
1. **توکن بات بله را از @BotFather بله باطل و دوباره بسازید** و کلید DeepSeek را rotate کنید.
2. مقادیر پیش‌فرض را از compose حذف کنید و به‌جای `${VAR:-secret}` از `${VAR:?set VAR}` استفاده کنید
(مثل `TRAEFIK_ACME_EMAIL` در compose پروडاکشن) و مقادیر واقعی فقط در `.env` محلی/سرور بمانند.
3. فایل `bot/.env` را از روی `bot/.env.example` بسازید (نمونه در همان پوشه هست).
## ۵. نحوه‌ی استفاده
### ۵.۱ اجرای کامل استک (شامل بات) — روی هر ماشین
```powershell
git clone https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP.git
cd ERP
git checkout prod-v7
# ۱) متغیرهای محیطی بات را بسازید (بدون این، بات توکن ندارد):
copy bot\.env.example bot\.env # و مقادیر واقعی را داخلش بگذارید
# BALE_BOT_TOKEN=... توکن بات از بله
# AI_API_KEY=... کلید سرویس LLM
# BALE_ADMIN_CHAT_IDS=... چت‌آیدی مدیران
# ۲) کل استک + بات:
docker compose up -d --build
```
سرویس `bot` (کانتینر `procurement-bot`) به‌صورت long-polling به `tapi.bale.ai` وصل می‌شود،
به `api:8000/api/v1` (همان ERP) و پایگاه‌داده‌ی `procurement` وصل است و
draft های گفتگو را در جدول `bot_drafts` می‌نویسد.
### ۵.۲ اجرای فقط بات (وقتی استک از قبل بالا است)
```powershell
docker compose build bot
docker compose up -d bot
docker compose logs -f bot
```
### ۵.۳ جریان کاری کاربر نهایی (بات ↔ ERP)
1. کاربر در بله به بات پیام می‌دهد (متن یا وویس — وویس با STT به متن تبدیل می‌شود).
2. بات با NLU موجودی/تأمین‌کننده/کالا را استخراج می‌کند و یک **draft** می‌سازد
(وضعیت `active` در جدول `bot_drafts`) و کارت پیش‌نمایش با فیلدهای خالی می‌فرستد.
3. کاربر با دکمه‌ی ✅ تأیید می‌کند؛ بات draft را به ERP کامیت می‌کند
(از طریق REST: `POST /api/v1/drafts/{id}/commit` با توکن سرویس).
4. ویزارد فرانت‌اند همان draft را از `GET /api/v1/drafts` می‌خواند و پیش‌پر می‌کند —
همگام‌سازی دوطرفه با ذخیره‌ی خودکار (debounced) بین مرورگر و بات.
5. ویرایشِ پاسخِ کاربر به کارت قبلی بات هم پشتیبانی می‌شود (reply-based edit، بدون کارت تکراری).
### ۵.۴ تست سریع سلامت
```powershell
docker compose ps bot # باید Up باشد
docker compose logs bot | Select-Object -Last 20
# داخل کانتینر:
docker compose exec bot python -m tools.smoke_test # اگر توکن ست باشد
```
### ۵.۵ مسیرهای مهم
| مسیر | محتوا |
|---|---|
| `bot/app/` | کد بات (handlers برای quote/material/supplier/decisions، bale client، NLU، STT، sync) |
| `bot/Dockerfile` | ایمیج `python:3.13-slim` + ffmpeg (برای وویس) |
| `bot/docs/SETUP.md` | راه‌اندازی جزئی‌تر ایجنت |
| `bot/integration/` | طرح ادغام: نمونه سرویس compose + پچ `X-Actor-Id` |
| `backend/app/routers/drafts.py` | REST drafts (سمت ERP) |
| `bot/procurement_ai_agent.bundle` و `bot/git_history_backup.zip` | بکاپ تاریخچه‌ی مستقل قبلی ایجنت |
## ۶. نکات باقی‌مانده برای تیم
- **ادغام با برنچ‌های جدید تیم:** فایل‌های بات فقط روی `prod-v7` هستند؛ `prod-v8`/`prod-v9`
آن‌ها را ندارند. برای رسیدن به استقرار نهایی، این برنچ باید با کار تیم merge شود.
- **سرویس bot در `docker-compose.prod.yml` و `docker-compose.coolify.yml` نیست** —
برای استقرار سرور باید به overlay پروڈ اضافه شود (یا از همان base compose استفاده شود).
- نام قدیمی `ai procurement` در برخی مستندات (`bot/docs/SETUP.md`، `bot/integration/`)
ممکن است هنوز دیده شود — محتوایی است و عملکردی تحت تأثیر نیست.
+73
View File
@@ -350,6 +350,78 @@ services:
restart: unless-stopped
mem_limit: 512m
# ---------------------------------------------------------------------------
# PM platform (vendored from Aramesh/PM under ./pm) — serves the /panel/pm
# iframe at /pm/ on this domain. Shares the ERP database (its tables are
# created additively at startup) and reads ERP users/user_panels for SSO.
# ---------------------------------------------------------------------------
pm-api:
image: v15-pm-api:latest
build:
context: ./pm/backend
dockerfile: Dockerfile
container_name: pm-api
volumes:
- pm_files:/data/files
- ./pm/backend/app:/srv/pm/app
environment:
# Same database as the ERP — PM tables are additive, created at startup.
PM_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
PM_FILES_DIR: /data/files
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
# ERP integration — in-network service URLs.
PM_ERP_BASE_URL: http://api:8000
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
PM_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
pm-frontend:
image: v15-pm-frontend:latest
build:
context: ./pm/frontend
dockerfile: Dockerfile
container_name: pm-frontend
depends_on:
- pm-api
# PM platform under /pm on the same domain and the same Traefik router.
labels:
- traefik.enable=true
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
- traefik.http.routers.pm.entrypoints=web
# Higher priority than the ERP frontend Host router — otherwise /pm/api
# would reach the ERP SPA (POST → 405).
- traefik.http.routers.pm.priority=100
- traefik.http.services.pm.loadbalancer.server.port=80
networks:
- traefik_network
- default
restart: unless-stopped
mem_limit: 128m
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
@@ -366,3 +438,4 @@ volumes:
postgres_data:
minio_data:
traefik_certs:
pm_files:
+94
View File
@@ -0,0 +1,94 @@
# گزارش گپ‌های دفتر موجودی (Inventory Ledger Gaps)
بررسی کامل منطق تراکنش‌های انبار (بک‌اند + فرانت‌اند) در جریان بازطراحی صفحه «تغییر موجودی».
**این گزارش فقط فهرست گپ‌هاست برای تصمیم‌گیری معماری — به‌جز مورد ۱، هیچ‌کدام در این نسخه اصلاح نشده‌اند.**
وضعیت‌ها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
---
## ۱. ✅ اصلاح‌شده در همین نسخه (پیش‌نیاز فیچر برگشت)
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
(`backend/app/routers/inventory.py` — list_transactions)
ستون `transaction_date` (timestamptz) با رشته‌ی خام مقایسه می‌شد. درایور asyncpg تایپ سخت‌گیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش می‌کرد. هیچ‌جای UI قدیمی این فیلترها را صدا نمی‌زد، برای همین مخفی مانده بود.
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل می‌شوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکته‌ی مرتبط: مورد ۱۲.
---
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
بک‌اند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) می‌پذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API می‌توان موجودی یک انبار را بی‌نهایت تورم داد.
UI جدید این را کنترل می‌کند (تگ `[REF:<tx-id>]` در note + محاسبه‌ی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمی‌شود.**
**پیشنهاد برای تصمیم‌گیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته می‌شود
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته می‌شود (سرویس آن را افزایش عمومی حساب می‌کند). یعنی از مسیر API می‌توان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساخته‌شده توسط خود سرویس `transfer_pair_id` دارد.
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفت‌سازی داخلی سرویس ثبت شود).
## ۴. 🟡 create انتقال بین‌کارگاهی «ردیف انبار» نمی‌گیرد
`POST /inventory/project-transfers` به‌جای `inventory_row_id` فقط material/brand/unit/specs می‌گیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر می‌کند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
## ۵. 🟡 Audit Trail ناقص است
فقط `POST /inventory/transactions` رکورد audit می‌نویسد (`inventory.py:491-500`). این‌ها audit ندارند:
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بین‌کارگاهی
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
- CRUD انبارها و تغییرات کاربران
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمی‌کند).
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال می‌شوند
- `delivered_to` (تحویل‌گیرنده برداشت) در UI اجباری است؛ API بدون آن 201 می‌دهد.
- ظرفیت‌سنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
در بخش‌های لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیف‌های ۱..k-1 **موفق ثبت‌شده** در جدول باقی می‌مانند → retry کاربر = ثبت تکراری. (در انتقال بین‌کارگاهی و برگشتِ این نسخه، ردیف‌های موفق از لیست حذف می‌شوند — الگوی درست.)
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
## ۸. 🟡 برگشت‌های قدیمی بدون REF قابل انتساب دقیق نیستند
برگشت‌های ثبت‌شده قبل از این نسخه هیچ اشاره‌ای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشت‌های هم‌ردیف (متریال+برند+مشخصات+واحد) جبران می‌کند، ولی این حدس است و در دو حالت خطا دارد:
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمی‌شود.
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحه‌های بعد را جابه‌جا کند.
**پیشنهاد (داده):** اسکریپت یک‌باره‌ی backfill — تطبیق برگشت‌های قدیمی با برداشت‌های همان ردیف (قدیمی‌ترین برداشتِ باقیمانده) و درج REF در note.
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC می‌برد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC می‌افتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich می‌کند). UI فعلی از آن استفاده نمی‌کند ولی برای دیباگ/گزارش‌ها گمراه‌کننده است.
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشی‌های لیست
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی می‌شوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمی‌شود (محدودیت قدیمی، در لیست‌های جدید این صفحه هم از همان دیتا می‌خواند). لیست برگشت و مفقودی‌ها صفحه‌بندی/واکشی کامل دارند.
## ۱۲. 🔵 لیست انتقال‌های بین‌کارگاهی بدون صفحه‌بندی
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همه‌ی ردیف‌ها را بدون pagination برمی‌گردانند؛ با رشد داده بار پاسخ زیاد می‌شود.
---
## چیزهایی که بررسی شد و درست است ✅
- **Immutable Ledger در سطح API:** برای تراکنش‌ها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بین‌کارگاهی است که تراکنش خروجی خودش را بازمی‌گرداند و موجودی را ترمیم می‌کند.
- **سقف FOUND در بک‌اند اعمال می‌شود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامت‌دار صحیح.
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمی‌شود.
- **دریافت انتقال بین‌کارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتب‌شده‌ی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
<title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head>
<body>
<div id="root"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "دفترچه تأمین — سامانه تدارکات و انبار",
"short_name": "دفترچه تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی",
"name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "مدیریت پروژه و تأمین",
"description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl",
"lang": "fa",
"start_url": "/",
+157 -31
View File
@@ -59,6 +59,7 @@ function Loading() {
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const location = useLocation();
const pathname = location.pathname;
// در صورت داشتن شناسه پیش‌نویس (از ربات بله)، دسترسی مستقیم به ویزارد بدون لاگین مجاز است
const searchParams = new URLSearchParams(location.search);
@@ -73,7 +74,10 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
}
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
if (!user.projects || user.projects.length === 0) {
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
@@ -96,43 +100,137 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
</div>
);
}
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
if (!location.pathname.startsWith("/inventory") && location.pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
}
}
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const panels = user.panels ?? {};
const isAdmin = user.role === "admin";
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند.
function HomeRoute() {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (panels.inventory) return <Navigate to="/inventory" replace />;
if (panels.procurement === "limited") return <RequesterHome />;
// فقط پنل PM: مستقیم به پنل مدیریت پروژه
if (panels.pm && !panels.inventory && !panels.procurement) {
return <Navigate to="/panel/pm" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
return <DashboardPage />;
}
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />;
// ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full
// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester)
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") {
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>;
}
if (panels.procurement === "full") return <>{children}</>;
if (panels.procurement === "limited") return <Navigate to="/requester" replace />;
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
@@ -146,25 +244,52 @@ function PmPanelPage() {
);
}
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های میراث pm/supervisor —
// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
const allowed =
user?.role === "admin" ||
Boolean(panels.pm) ||
user?.role === "pm" ||
user?.role === "supervisor";
if (!allowed) return <Navigate to="/" replace />;
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const isAdmin = user?.role === "admin";
const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>;
}
@@ -217,6 +342,7 @@ export default function App() {
</RequireAuth>
}
/>
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route
path="/suppliers"
element={
+42 -30
View File
@@ -17,49 +17,61 @@ export function AppSwitcherMenu({
}) {
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const isSupervisor = user?.role === "supervisor";
const panels = user?.panels ?? {};
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود
if (isSupervisor) {
const supervisorItems: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
{
label: "مدیریت پروژه",
to: "/panel/pm",
},
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
];
return <PanelMenu title="پنل‌ها" items={supervisorItems} />;
}
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const items: MenuItem[] = [
{
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند
const canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = [];
if (hasInventory) {
items.push({
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
];
});
}
if (canSwitchToProcurement) {
if (hasProcurement && canSwitchToProcurement) {
items.push({
label: "تدارکات",
to: "/",
to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement",
});
}
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
if (hasPm) {
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
}
if (canManage) {
if (canManageOrg) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
+16 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, type RefObject } from "react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
@@ -17,6 +17,7 @@ export interface ComboboxProps {
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از +
placeholder?: string;
className?: string;
@@ -25,6 +26,8 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
@@ -44,6 +47,7 @@ export function Combobox({
onChange,
items,
allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید",
placeholder,
className,
@@ -52,6 +56,7 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
inputRef,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
@@ -125,6 +130,7 @@ export function Combobox({
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
ref={inputRef}
className={cn(
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
inputClassName,
@@ -182,9 +188,15 @@ export function Combobox({
onMouseEnter={() => setHighlight(filtered.length)}
>
<span className="font-bold">+</span>
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
{addPrefix ? (
<span>
{addPrefix} «<b>{text.trim()}</b>»
</span>
) : (
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
)}
</button>
)}
</div>
+48 -51
View File
@@ -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
View File
@@ -263,6 +263,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
}
return res as T;
}
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
const res = await request<any>(path, { method: "DELETE" });
if (res && typeof res === "object" && "data" in res) {
@@ -1806,9 +1810,9 @@ export const apiV26 = {
patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panels`, { panels }),
putData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
@@ -1856,11 +1860,18 @@ export const apiV26 = {
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
return data.map(rawInventory);
},
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
},
// همان transactions با meta.total برای صفحه‌بندی «نمایش بیشتر»
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
},
createTransaction: async (body: {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null;
+36 -8
View File
@@ -79,7 +79,10 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return;
}
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (user.role === "supervisor" && !hasProjects) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
@@ -92,10 +95,24 @@ export function LoginPage() {
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیر پروژه → پنل انبار؛ بقیه → دفتر
if (user.role === "supervisor") navigate("/panel/pm");
else navigate(user.role === "pm" ? "/inventory" : "/");
} (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
// هدایت پس از ورود بر اساس پنل‌ها یا نقش:
const panels = user.panels ?? {};
if (panels.procurement === "full") {
navigate("/");
} else if (panels.procurement === "limited") {
navigate("/requester");
} else if (panels.inventory) {
navigate("/inventory");
} else if (panels.pm) {
navigate("/panel/pm");
} else if (user.role === "supervisor") {
navigate("/panel/pm");
} else if (user.role === "pm") {
navigate("/inventory");
} else {
navigate("/");
}
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally {
@@ -285,10 +302,21 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} />
</span>
<div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
<p className="mt-1 text-[12.5px] leading-6 text-muted">
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
<h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
<p className="mt-1.5 text-[12.5px] leading-6 text-muted">
درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
</p>
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
مدیریت پروژه
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
تدارکات
</span>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
انبارداری
</span>
</div>
</div>
</div>
+55 -11
View File
@@ -1,28 +1,68 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { FilePlus2, Info } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { useAuthStore } from "../../store/auth";
import { useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای
// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود.
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژه‌های تخصیص‌یافته
export function RequesterHome() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const memberIds = new Set(user?.member_project_ids ?? []);
const myProjects = memberIds.size
? projects.filter((p) => memberIds.has(p.id))
: [];
// پروژه‌های کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژه‌های عمومی)
const myProjects = useMemo(() => {
const list: { id: string; name: string }[] = [];
const seen = new Set<string>();
// ۱. پروژه‌های موجود در آرایه user.projects
for (const p of user?.projects ?? []) {
if (p.id && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۲. پروژه‌ی اختصاصی فعال (project_id)
if (user?.project_id && !seen.has(user.project_id)) {
seen.add(user.project_id);
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیص‌یافته" });
}
// ۳. پروژه‌های عضویت member_project_ids تطبیق‌یافته با لیست پروژه‌ها
const memberIdSet = new Set(user?.member_project_ids ?? []);
for (const p of projects) {
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
if (list.length === 0 && user?.project_name) {
const match = projects.find((p) => p.name === user.project_name);
if (match && !seen.has(match.id)) {
list.push({ id: match.id, name: match.name });
}
}
return list;
}, [user, projects]);
return (
<div className="min-h-screen">
<Header title="افزودن استعلام" brand />
<Header
title="افزودن استعلام"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
شما مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
@@ -36,8 +76,8 @@ export function RequesterHome() {
{myProjects.map((p) => (
<Link
key={p.id}
to="/wizard"
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
to={`/wizard?project=${encodeURIComponent(p.name)}`}
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
@@ -50,7 +90,10 @@ export function RequesterHome() {
))}
</div>
<Link to="/wizard" className="btn-line-file no-underline">
<Link
to="/wizard"
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
>
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
@@ -60,3 +103,4 @@ export function RequesterHome() {
</div>
);
}
@@ -268,8 +268,6 @@ export function ArrivalsPage() {
}
};
if (!isAdmin && !isPM) {
if (!canAccess) {
return (
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
// ============================================================
// جدول «پیدا شده» — مفقودی‌های باقی‌مانده‌ی انبار (LOSS − FOUND)
// با انتخاب انبار همه‌ی مفقودی‌ها می‌آیند و نام متریال فقط فیلتر است.
// ============================================================
import type { InventoryTransaction } from "../../../lib/types";
import { faNum } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine, type RowDraft } from "./StockTable";
export interface LossItem { tx: InventoryTransaction; remaining: number; }
interface FoundTableProps {
items: LossItem[];
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
loading?: boolean;
}
function parseLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
if (loading) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودی‌ها…</div>;
}
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[620px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2.5 px-3">متریال</th>
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">مانده مفقودی</th>
<th className="py-2.5 px-3">تعداد پیداشده</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{items.map(({ tx, remaining }, i) => {
const over = parseLocal(rows[i]?.qty || "") > remaining;
return (
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-2 px-3">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
</td>
<td className="py-2 px-3">
<input
type="text"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{items.map(({ tx, remaining }, i) => {
const over = parseLocal(rows[i]?.qty || "") > remaining;
return (
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
</div>
{tx.specs && Object.keys(tx.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">مانده مفقودی</div>
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="تعداد پیداشده"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,132 @@
// ============================================================
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
// انتخاب انبار مبدأ → لیست همه‌ی اقلام موجود → ورودی تعداد هر ردیف.
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب می‌شوند.
// ردیف‌های ناموفق هنگام ثبت متوالی، با پیام خطا در لیست می‌مانند و
// ردیف‌های موفق حذف می‌شوند (ورودی‌ها کلید-محورند تا جابه‌جا نشوند).
// ============================================================
import { faNum } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine, type StockRecord } from "./StockTable";
interface ProjectTransferListProps {
records: StockRecord[];
qtyOf: (id: string) => string;
onQty: (id: string, v: string) => void;
unitNameFn: (id: number | null | undefined) => string;
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
errors: Record<string, string>;
emptyMsg: string;
}
function parseNum(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[600px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2.5 px-3">متریال</th>
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">تعداد انتقال</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r) => {
const err = errors[r.id];
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
return (
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="px-3 py-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={qtyOf(r.id)}
onChange={(e) => onQty(r.id, e.target.value)}
/>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد می‌شود.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{records.map((r) => {
const err = errors[r.id];
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
return (
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
</div>
{r.specs && Object.keys(r.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">موجودی</div>
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
</div>
</div>
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="تعداد انتقال"
value={qtyOf(r.id)}
onChange={(e) => onQty(r.id, e.target.value)}
/>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,88 @@
// ============================================================
// جدول «اقلام ثبت‌شده در این نشست» برای موجودی اولیه
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظه‌ی همان صفحه.
// ============================================================
import { faNum } from "../../../lib/formatters";
import { specsLine } from "./StockTable";
export interface PurchaseSessionEntry {
material: string;
brand: string;
specs: Record<string, string>;
qty: number;
unitLabel: string;
time: string; // HH:MM آماده‌ی نمایش
whName: string;
}
interface PurchaseSessionLogProps {
entries: PurchaseSessionEntry[];
onClear: () => void;
}
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
if (!entries.length) return null;
return (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
<h3 className="text-[13px] font-bold">
🧾 اقلام ثبت‌شده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
</h3>
<button
type="button"
onClick={onClear}
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
>
پاک کردن لیست
</button>
</div>
{/* دسکتاپ: جدول فشرده */}
<div className="hidden sm:block">
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
<table className="w-full text-[12px]">
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2 px-3">متریال</th>
<th className="py-2 px-3">برند</th>
<th className="py-2 px-3">مشخصه‌ها</th>
<th className="py-2 px-3">تعداد</th>
<th className="py-2 px-3">ساعت</th>
<th className="py-2 px-3">انبار</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{[...entries].reverse().map((e, i) => (
<tr key={i}>
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
<td className="py-1.5 px-3 text-muted">{e.time}</td>
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* موبایل: ردیف‌های فشرده */}
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
{[...entries].reverse().map((e, i) => (
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
</div>
<div className="mt-0.5 text-[11px] text-muted">
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
// ============================================================
// نوار فیلتر بازه‌ی روز بخش «برگشت»
// پیش‌فرض‌های لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازه‌ی دلخواه با تقویم جلالی.
// چیپ‌ها در موبایل افقی اسکرول‌پذیرند (no-scrollbar) با ارتفاع تاچ.
// ============================================================
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
import { cn } from "../../../lib/utils";
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
interface ReturnFilterBarProps {
preset: DatePreset;
onPreset: (p: DatePreset) => void;
customFrom: string | null;
customTo: string | null;
onCustomFrom: (iso: string | null) => void;
onCustomTo: (iso: string | null) => void;
}
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
return (
<div className="space-y-2">
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
{DATE_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onPreset(p.id)}
className={cn(
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
preset === p.id
? "bg-accent font-bold text-white shadow-xs"
: "border border-line bg-panel text-ink hover:bg-bg",
)}
>
{p.label}
</button>
))}
</div>
{preset === "custom" && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
</div>
<div>
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,164 @@
// ============================================================
// جدول برداشت‌های بازه برای بخش «برگشت»
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
// برگشت محاسبه‌شده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
// ورودی‌ها کلید-محورند (کلید = tx-id) تا حذف ردیف‌های موفق، بقیه را
// به‌هم نریزد.
// ============================================================
import type { InventoryTransaction } from "../../../lib/types";
import { faNum, fmtDateTime } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine } from "./StockTable";
interface ReturnTableProps {
withdrawals: InventoryTransaction[];
qtyOf: (id: string) => string;
noteOf: (id: string) => string;
onQty: (id: string, v: string) => void;
onNote: (id: string, v: string) => void;
remainders: Map<string, number>;
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
loading?: boolean;
}
function parseNum(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
if (loading) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشت‌ها…</div>;
}
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[760px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2.5 px-3">تاریخ</th>
<th className="py-2.5 px-3">متریال</th>
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">برداشت</th>
<th className="py-2.5 px-3">مانده قابل برگشت</th>
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{withdrawals.map((w) => {
const cap = remainders.get(w.id) ?? 0;
const q = parseNum(qtyOf(w.id));
const over = q > cap + 1e-9;
return (
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={qtyOf(w.id)}
onChange={(e) => onQty(w.id, e.target.value)}
/>
<input
type="text"
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={noteOf(w.id)}
onChange={(e) => onNote(w.id, e.target.value)}
/>
</div>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-2 text-[11px] text-muted">
«مانده قابل برگشت» = مقدار برداشت − برگشت‌های ثبت‌شده همان ردیف. سقف ورودی همین مانده است.
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{withdrawals.map((w) => {
const cap = remainders.get(w.id) ?? 0;
const q = parseNum(qtyOf(w.id));
const over = q > cap + 1e-9;
return (
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
</div>
{w.specs && Object.keys(w.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
</div>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{fmtDateTime(w.transaction_date)}</span>
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="مقدار برگشت"
value={qtyOf(w.id)}
onChange={(e) => onQty(w.id, e.target.value)}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح (اختیاری)"
value={noteOf(w.id)}
onChange={(e) => onNote(w.id, e.target.value)}
/>
</div>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -0,0 +1,72 @@
// ============================================================
// ویرایشگر مشخصه‌های فنی Key-Value (موجودی اولیه)
// خروجی: jsonb استاندارد {عنوان: مقدار} هم‌خوان با specs_hash بک‌اند.
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته می‌شود.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface SpecKvRow { key: string; value: string; }
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
const k = r.key.trim();
if (k) out[k] = r.value.trim();
}
return out;
}
interface SpecsKvEditorProps {
rows: SpecKvRow[];
onChange: (rows: SpecKvRow[]) => void;
disabled?: boolean;
}
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
const update = (i: number, patch: Partial<SpecKvRow>) =>
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
return (
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="عنوان مشخصه (مثلاً ضخامت)"
value={r.key}
onChange={(e) => update(i, { key: e.target.value })}
/>
<span className="shrink-0 text-[12px] text-muted">:</span>
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="مقدار (مثلاً ۲ میلی‌متر)"
value={r.value}
onChange={(e) => update(i, { value: e.target.value })}
/>
<button
type="button"
disabled={disabled}
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
title="حذف این مشخصه"
>
✕
</button>
</div>
))}
<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, { key: "", value: "" }])}
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
>
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
</button>
</div>
);
}
@@ -0,0 +1,154 @@
// ============================================================
// هسته‌ی نمایش مشترک لیست متریال‌های انبار برای بخش‌های لیست‌محور
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
//
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
// موبایل (<640px): کارت‌های عمودی لمسی با فیلد بزرگ ورود مقدار.
// هر بخش ستون‌های نمایش یکسان را از اینجا می‌گیرد و فقط رفتار ورودی
// (تعداد ساده یا شمارش‌شده) را با isCount انتخاب می‌کند.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface StockRecord {
id: string;
material: { id: string; name: string };
brand: { id: string; name: string } | null;
unit: { id: number; code: string; name_fa: string | null } | null;
quantity_number: number;
specs: Record<string, string>;
}
export interface RowDraft { key: string; qty: string; note: string; }
export function specsLine(specs: Record<string, string> | undefined): string {
return (
Object.entries(specs || {})
.map(([k, v]) => `${k}: ${v}`)
.join(" · ") || "—"
);
}
interface StockTableProps {
records: StockRecord[];
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
/** شمارش موجودی: placeholder «شمارش‌شده» و پیش‌نمایش مقدار فعلی هنگام خالی بودن */
isCount?: boolean;
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است می‌توان پنهان کرد */
showMaterial?: boolean;
emptyMsg: string;
unitNameFn: (id: number | null | undefined) => string;
}
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
const qtyPlaceholder = isCount ? "شمارش‌شده" : "۰";
return (
<>
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[560px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
<tr className="text-right text-muted">
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r, i) => (
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
{showMaterial && (
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
)}
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="py-2 px-3">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={qtyPlaceholder}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2 px-3">
<input
type="text"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div>
{/* موبایل: کارت‌های عمودی لمسی */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{records.map((r, i) => (
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
</div>
{r.specs && Object.keys(r.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">موجودی</div>
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
placeholder={qtyPlaceholder}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
</div>
))}
</div>
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
</div>
</>
);
}
@@ -0,0 +1,244 @@
// ============================================================
// منطق بخش «برگشت» — واکشی برداشت‌ها/برگشت‌ها و محاسبه مانده قابل برگشت
//
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمی‌شود؛
// مانده هر برداشت = مقدار برداشت − مجموع برگشت‌های متصل به آن.
// اتصال دو راه دارد:
// ۱) برگشت‌های جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
// ۲) برگشت‌های قدیمی بدون REF (ثبت‌شده در همان انبار مبدأ) → تخصیص FIFO
// بین برداشت‌های همان ردیف انبار (متریال+برند+مشخصات+واحد).
// همه‌ی جمع/تفریق‌ها با round4 گرد می‌شوند تا خطای اعشار جاوااسکریپت
// (مثل 0.30000000000000004) در مانده‌ها و سقف‌ها نشت نکند.
// ============================================================
import { apiV26 } from "../../../lib/api";
import type { InventoryTransaction } from "../../../lib/types";
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همه‌ی محاسبات مانده */
export function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}
// ------------------------------------------------------------
// تاریخ — کلید روز میلادی محلی و بازه‌های پیش‌فرض
// ------------------------------------------------------------
export function gregKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
{ id: "today", label: "امروز" },
{ id: "yesterday", label: "دیروز" },
{ id: "7d", label: "۷ روز اخیر" },
{ id: "30d", label: "۳۰ روز اخیر" },
{ id: "custom", label: "بازه دلخواه" },
];
/** بازه‌ی سرور-ساید برای هر پیش‌فرض؛ date_to روزِ (inclusive بک‌اند: < date_to + 24h) */
export function presetRange(
preset: DatePreset,
customFrom?: string,
customTo?: string,
): { date_from: string; date_to: string } {
const today = new Date();
switch (preset) {
case "today":
return { date_from: gregKey(today), date_to: gregKey(today) };
case "yesterday": {
const y = gregKey(addDays(today, -1));
return { date_from: y, date_to: y };
}
case "7d":
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
case "30d":
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
case "custom":
return {
date_from: customFrom || gregKey(today),
date_to: customTo || customFrom || gregKey(today),
};
}
}
// ------------------------------------------------------------
// تگ رفرنس [REF:<uuid>]
// ------------------------------------------------------------
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
export function parseRef(note?: string | null): string | null {
if (!note) return null;
const m = note.match(REF_RE);
return m ? m[1] : null;
}
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
const tag = `[REF:${withdrawalTxId}]`;
const note = (userNote || "").trim();
return note ? `${note} ${tag}` : tag;
}
// ------------------------------------------------------------
// واکشی صفحه‌بندی‌شده (هر صفحه ۱۰۰ — سقف بک‌اند)
// ------------------------------------------------------------
const MAX_PAGES = 10; // سقف محافظه‌کارانه: ۱۰۰۰ رکورد
export async function fetchWithdrawalsPage(
sourceWhId: string,
dateFrom: string,
dateTo: string,
page: number,
): Promise<{ rows: InventoryTransaction[]; total: number }> {
return apiV26.inventory.transactionsPaged({
warehouse_id: sourceWhId,
transaction_type: "WITHDRAWAL",
date_from: dateFrom,
date_to: dateTo,
sort: "transaction_date",
page,
});
}
/** همه‌ی برگشت‌های پروژه از ابتدای بازه (برای تطبیق REF) — صفحه‌به‌صفحه تا سقف */
export async function fetchProjectReturns(
dateFrom: string,
): Promise<InventoryTransaction[]> {
const out: InventoryTransaction[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const { rows, total } = await apiV26.inventory.transactionsPaged({
transaction_type: "RETURN",
date_from: dateFrom,
sort: "transaction_date",
page,
});
out.push(...rows);
if (out.length >= total || rows.length === 0) break;
}
return out;
}
/** همه‌ی تراکنش‌های یک نوع در یک انبار (برای محاسبه مانده مفقودی‌ها) */
export async function fetchAllTypeInWarehouse(
warehouseId: string,
transactionType: string,
): Promise<InventoryTransaction[]> {
const out: InventoryTransaction[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const { rows, total } = await apiV26.inventory.transactionsPaged({
warehouse_id: warehouseId,
transaction_type: transactionType,
sort: "transaction_date",
page,
});
out.push(...rows);
if (out.length >= total || rows.length === 0) break;
}
return out;
}
// ------------------------------------------------------------
// مانده قابل برگشت
// ------------------------------------------------------------
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتب‌شده) + واحد — هم‌ارز specs_hash بک‌اند */
function rowKey(t: InventoryTransaction): string {
const specs = Object.keys(t.specs || {})
.sort()
.map((k) => `${k}=${String((t.specs || {})[k])}`)
.join("&");
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
}
/**
* مانده قابل برگشت هر برداشت را حساب می‌کند.
* - برگشت‌های REF-دار: مستقیماً از برداشت مقصد کم می‌شوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
* - برگشت‌های REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته می‌شوند (به برداشت خودشان تعلق دارند).
* - برگشت‌های قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشت‌های هم‌ردیف تخصیص می‌یابند.
*/
export function computeRemainders(
withdrawals: InventoryTransaction[],
returns: InventoryTransaction[],
sourceWhId: string,
): Map<string, number> {
const rem = new Map<string, number>();
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
const inList = new Set(withdrawals.map((w) => w.id));
const legacy: InventoryTransaction[] = [];
for (const r of returns) {
const qty = round4(Math.abs(r.quantity_number || 0));
if (qty <= 0) continue;
const ref = parseRef(r.note);
if (ref) {
if (inList.has(ref)) {
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
}
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
} else {
legacy.push(r);
}
}
// تخصیص FIFO برگشت‌های قدیمیِ هم‌انبار مبدأ بین برداشت‌های هم‌ردیف (قدیمی→جدید)
const buckets = new Map<string, InventoryTransaction[]>();
for (const w of withdrawals) {
const k = rowKey(w);
const list = buckets.get(k);
if (list) list.push(w);
else buckets.set(k, [w]);
}
for (const list of buckets.values()) {
list.sort(
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
);
}
for (const r of legacy) {
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
const list = buckets.get(rowKey(r));
if (!list) continue;
let q = round4(Math.abs(r.quantity_number || 0));
for (const w of list) {
if (q <= 0) break;
const cur = rem.get(w.id) ?? 0;
if (cur <= 0) continue;
const take = Math.min(cur, q);
rem.set(w.id, round4(cur - take));
q = round4(q - take);
}
}
// کف صفر (محافظت در برابر داده‌های ناسازگار قدیمی)
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
return rem;
}
/** مانده مفقودیِ باقی‌مانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
export function computeLossRemainders(
losses: InventoryTransaction[],
founds: InventoryTransaction[],
): { tx: InventoryTransaction; remaining: number }[] {
const foundByKey = new Map<string, number>();
for (const f of founds) {
const k = rowKey(f);
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
}
return losses
.map((tx) => {
const loss = Math.abs(tx.quantity_number || 0);
const found = foundByKey.get(rowKey(tx)) || 0;
return { tx, remaining: Math.max(0, round4(loss - found)) };
})
.filter((x) => x.remaining > 0);
}
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { apiV26, type UserRow } from "../../lib/api";
import { api, apiV26, type UserRow } from "../../lib/api";
import type { Project } from "../../lib/types";
import { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
@@ -17,10 +18,12 @@ const ROLE_LABELS: Record<string, string> = {
supervisor: "سرپرست پروژه",
};
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها و پنل‌ها
export function OrganizationPage() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const storeProjects = useProjects();
const [directProjects, setDirectProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
@@ -28,11 +31,25 @@ export function OrganizationPage() {
const [busyId, setBusyId] = useState<string | null>(null);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد؛ مدیران می‌توانند پروژه‌ها را به سرپرستان تخصیص دهند
const canChangeRoles = user?.role === "admin";
const canChangeRoles = canManage;
const fetchProjects = useCallback(async () => {
try {
setLoadingProjects(true);
const list = await api.projects.list();
if (list && Array.isArray(list)) {
setDirectProjects(list);
}
} catch {
// fallback silently
} finally {
setLoadingProjects(false);
}
}, []);
const loadUsers = useCallback(async () => {
try {
setLoadingUsers(true);
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
@@ -43,8 +60,13 @@ export function OrganizationPage() {
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
if (canManage) {
void loadUsers();
void fetchProjects();
}
}, [canManage, loadUsers, fetchProjects]);
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
const syncUsers = async () => {
setSyncing(true);
@@ -57,6 +79,7 @@ export function OrganizationPage() {
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
void fetchProjects();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
@@ -91,39 +114,8 @@ export function OrganizationPage() {
}
};
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id);
try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
const names = nextIds
.map((id) => projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers();
} finally {
setBusyId(null);
}
};
// ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ----------
const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => {
const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
setBusyId(u.id);
const next = { ...(u.panels ?? {}) };
if (level) next[panel] = level;
@@ -157,12 +149,14 @@ export function OrganizationPage() {
const panelLabel = (level: string | undefined) =>
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
// سرپرستان تخصیص‌یافته به یک پروژه
// سرپرستان و مسئولان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) =>
users.filter(
(u) =>
(u.role === "supervisor" || u.role === "pm") &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
(u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
(u.projects?.some((p) => p.id === projectId) ||
u.project_id === projectId ||
u.member_project_ids?.includes(projectId)),
);
return (
@@ -172,34 +166,33 @@ export function OrganizationPage() {
brand
menu={<AppSwitcherMenu activeModule="organization" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند.
مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند.
<PageLayout className="flex flex-col gap-5">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌های سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین می‌گردد.
</div>
{/* ---------- بخش ۱: جدول کاربران و نقش‌ها ---------- */}
{/* ---------- بخش ۱: جدول یکپارچه کاربران، نقش‌ها و سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[14px] font-bold">👥 کاربران و نقش‌ها</h3>
<h3 className="text-[14px] font-bold">👥 کاربران، نقش‌ها و سطوح دسترسی پنل‌ها</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه
تعیین نقش سازمانی، تخصیص پنل‌ها (تدارکات، انبار، PM) و مشخص نمودن پروژه‌های عضویت کاربر
</p>
</div>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
<div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
<div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
@@ -207,210 +200,397 @@ export function OrganizationPage() {
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">نام کاربری</th>
<th className="py-2 pe-3">نقش</th>
<th className="py-2 pe-3">پروژه‌های تخصیص‌یافته (سرپرست پروژه)</th>
<th className="py-2">وضعیت</th>
<th className="py-2.5 pe-3">نام و مشخصات</th>
<th className="py-2.5 pe-3">نقش سازمانی</th>
<th className="py-2.5 pe-3">پنل تدارکات</th>
<th className="py-2.5 pe-3">پنل انبار</th>
<th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
<th className="py-2.5">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
</td>
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
{u.username || "—"}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
<td className="py-2.5 pe-3">
{u.role === "supervisor" || u.role === "pm" ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
disabled={busyId === u.id}
/>
{users.map((u) => {
const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
{/* ۱. نام و نام کاربری */}
<td className="py-2.5 pe-3">
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-bold text-foreground">
{u.first_name} {u.last_name}
</span>
<span className="font-mono text-[10px] text-muted">
({u.username || "—"})
</span>
</div>
{busyId === u.id && (
<span className="text-[10px] text-accent animate-pulse font-medium">
در حال ذخیره تغییرات…
</span>
)}
</div>
) : (
<span className="text-[11px] text-muted">
{u.role === "admin"
? "دسترسی کامل به همه پروژه‌ها"
: u.role === "procurement_manager"
? "دسترسی کامل مدیریتی"
: "—"}
</span>
)}
</td>
<td className="py-2.5 text-[11px] text-muted">
{busyId === u.id ? (
<span className="text-accent">در حال ذخیره…</span>
) : (
<span className="text-emerald-600">✓ ثبت‌شده</span>
)}
</td>
</tr>
))}
</td>
{/* ۲. نقش سازمانی */}
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
{/* ۳. پنل تدارکات */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
{/* ۴. پنل انبار */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
{/* ۵. پنل مدیریت پروژه (PM) */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.pm ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
)}
</td>
{/* ۶. پروژه‌های عضویت */}
<td className="py-2.5">
{isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : canAssignProjects ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted">
سرپرستان پروژه‌ای که پروژه‌ای به آن‌ها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
</div>
</div>
)}
</div>
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
<div>
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => {
const sups = supervisorsOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
{p.status || "پروژه فعال"}
</span>
</div>
{p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)}
<div>
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
{!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
</div>
) : (
<div className="flex flex-col gap-1">
{sups.map((s) => (
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
<span className="font-semibold">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username})</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* ---------- بخش ۳: پنل‌ها و سطوح دسترسی (جایگزین نقش‌ها) ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3">
<h3 className="text-[14px] font-bold">🔑 پنل‌ها و سطوح دسترسی</h3>
<p className="mt-0.5 text-[11.5px] leading-5 text-muted">
به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن
استعلام برای پروژه‌های عضویت. انبار — کامل: همه پروژه‌ها بدون تغییر موجودی؛ محدود: فقط پروژه‌های عضویت با
امکان تغییر موجودی. ادمین دست‌نخورده است.
</p>
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<p className="text-[11.5px] text-muted">
مشاهده وضعیت پروژه‌ها و سرپرستان یا اعضای تخصیص‌یافته به هر پروژه
</p>
</div>
{loadingProjects && (
<span className="text-[11px] text-muted animate-pulse">در حال به‌روزرسانی پروژه‌ها…</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">پنل تدارکات</th>
<th className="py-2 pe-3">پنل انبار</th>
<th className="py-2">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
if (u.role === "admin") {
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3"><b>{u.first_name} {u.last_name}</b></td>
<td colSpan={3} className="py-2.5 text-[11px] text-muted">
ادمین — دسترسی کامل به همه پنل‌ها (دست‌نخورده)
</td>
</tr>
);
}
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory);
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
<td className="py-2.5">
{hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{loadingProjects && !projects.length ? (
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
در حال دریافت اطلاعات پروژه‌ها…
</div>
) : !projects.length ? (
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
<span>پروژه‌ای برای نمایش یافت نشد یا در دسترس نیست.</span>
<button
type="button"
onClick={() => void fetchProjects()}
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
>
🔄 تلاش مجدد برای دریافت پروژه‌ها
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => {
const sups = supervisorsOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
<div>
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
<span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
{p.status || "پروژه فعال"}
</span>
</div>
{p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)}
</div>
<div className="pt-2 border-t border-line/50">
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
{!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
</div>
) : (
<div className="flex flex-col gap-1">
{sups.map((s) => (
<div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
<span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-5">
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<div>
<h3 className="flex items-center gap-2 text-[15px] font-bold">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
📖
</span>
توضیحات پنل‌ها و راهنمای سطوح دسترسی
</h3>
<p className="mt-1 text-[12px] text-muted">
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنل‌های سامانه
</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
مدل دسترسی ماژولار مبتنی بر پنل
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* کارت پنل تدارکات */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
🛒
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
</div>
</div>
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
خرید و بازرگانی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی کامل مدیریتی به تمامی بخش‌های تدارکات</li>
<li>ثبت و ویرایش پیش‌فاکتورها، شرایط پرداخت و فاکتورها</li>
<li>مدیریت تأمین‌کنندگان، اقلام و تصمیم‌گیری خرید در همه پروژه‌ها</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>فقط امکان ثبت درخواست استعلام قیمت و پیش‌فاکتور اولیه</li>
<li>محدود منحصراً به پروژه‌هایی که کاربر در آن‌ها عضویت دارد</li>
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمت‌ها یا پروژه‌های غیرعضو</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل انبار */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
📦
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
</div>
</div>
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
انبارداری و موجودی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژه‌ها</li>
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارش‌گیری کلان</li>
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی فقط به انبارهای پروژه‌های عضویت کاربر</li>
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
<li>تفکیک دقیق موجودی و تراکنش‌های انبار بر اساس پروژه</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل مدیریت پروژه (PM) */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
📊
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
</div>
</div>
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
کنترل پروژه و اجرا
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مدیریت جامع ساختار شکست کار (WBS)، زمان‌بندی و گانت چارت تعاملی</li>
<li>تعریف و ویرایش فعالیت‌ها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
<li>پایش شاخص‌های پیشرفت زمانی و مالی (EVM) و گزارش‌های تحلیلی</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده فعالیت‌ها و تسک‌های مربوط به پروژه‌های عضویت کاربر</li>
<li>به‌روزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسک‌ها</li>
<li>تکمیل چک‌لیست‌های کنترل کیفیت (QC) و ثبت گزارش‌های اجرایی روزانه</li>
</ul>
</div>
</div>
</div>
</div>
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">👑</span>
<div>
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
<p className="mt-0.5 text-muted leading-relaxed">
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنل‌ها و پروژه‌ها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنل‌ها ندارد.
</p>
</div>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">🎯</span>
<div>
<span className="font-bold text-foreground">کارکرد «پروژه‌های عضویت»:</span>
<p className="mt-0.5 text-muted leading-relaxed">
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه داده‌ها و اختیارات وی دقیقاً به پروژه‌های انتخابی در ستون «پروژه‌های عضویت» محدود خواهد شد.
</p>
</div>
</div>
</div>
</div>
</PageLayout>
+5 -1
View File
@@ -213,7 +213,11 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
/>
))}
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
<div className="dd-empty">
{canAddProject
? "هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید."
: "هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
+66 -26
View File
@@ -22,6 +22,7 @@ import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { matchMaterial } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier";
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
@@ -154,13 +155,19 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
const user = useAuthStore((s) => s.user);
const isLimitedProcurement =
user?.panels?.procurement === "limited" && user?.role !== "admin";
const memberProjectNames = useMemo(
() =>
projects
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
.map((p) => p.name),
[projects, user],
);
const memberProjectNames = useMemo(() => {
const allowedIds = new Set([
...(user?.member_project_ids ?? []),
...(user?.projects ?? []).map((p) => p.id),
...(user?.project_id ? [user.project_id] : []),
]);
const matched = projects
.filter((p) => allowedIds.has(p.id))
.map((p) => p.name);
const directNames = (user?.projects ?? []).map((p) => p.name);
if (user?.project_name) directNames.push(user.project_name);
return [...new Set([...matched, ...directNames])];
}, [projects, user]);
const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote();
const addSupplier = useAddSupplier();
@@ -309,22 +316,37 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
[suppliers, materials, topics, items],
);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ----------
// ---------- prefill از لینک‌ها (?topic=&supplier=&project=) ----------
useEffect(() => {
const t = params.get("topic");
if (!t) return;
const topic = topicByName(t, topics);
if (!topic) return;
setSelectedTopic(topic.name);
const sid = params.get("supplier");
if (sid) {
const sup = suppliers.find((s) => s.id === sid);
if (sup && normalize(sup.topic) === normalize(topic.name)) {
setSupplierName(sup.name);
setSupplierInvalid(false);
if (t) {
const topic = topicByName(t, topics);
if (topic) {
setSelectedTopic(topic.name);
const sid = params.get("supplier");
if (sid) {
const sup = suppliers.find((s) => s.id === sid);
if (sup && normalize(sup.topic) === normalize(topic.name)) {
setSupplierName(sup.name);
setSupplierInvalid(false);
}
}
}
}
}, [params, suppliers, topics]);
const proj = params.get("project");
if (proj) {
setAssignment((cur) => {
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [proj];
}
}
return next;
});
}
}, [params, suppliers, topics, items]);
// ---------- بارگذاری اولیه پیش‌نویس ربات بله (?token= یا ?draft=) ----------
useEffect(() => {
@@ -516,11 +538,10 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
// ---------- پروژه‌ها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(
isLimitedProcurement
? memberProjectNames
: projects.map((p) => p.name),
);
if (isLimitedProcurement) {
return memberProjectNames;
}
const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => {
if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n));
@@ -528,6 +549,24 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
return [...names];
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص می‌یابند
useEffect(() => {
if (isLimitedProcurement && memberProjectNames.length === 1) {
const sole = memberProjectNames[0];
setAssignment((cur) => {
let changed = false;
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [sole];
changed = true;
}
}
return changed ? next : cur;
});
}
}, [isLimitedProcurement, memberProjectNames, items]);
const assign = (tempId: string, proj: string) => {
setAssignment((a) => {
const cur = a[tempId] || [];
@@ -898,8 +937,9 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/"
backLabel="بازگشت به دفتر"
backTo={isLimitedProcurement ? "/requester" : "/"}
backLabel={isLimitedProcurement ? "بازگشت به استعلام‌ها" : "بازگشت به دفتر"}
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
)}
<PageLayout>
+9
View File
@@ -786,3 +786,12 @@ button:disabled {
padding: 0.45rem;
}
}
/* اسکرول افقی بدون نوار اسکرول (چیپ‌های لمسی موبایل) */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
+19
View File
@@ -0,0 +1,19 @@
# PM — Construction Project Management Platform
# Copy to .env and customize as needed (never commit .env)
# Database credentials
PM_DB_PASSWORD=pm_secret_2026
# Session secret (random 32+ bytes hex string)
PM_SESSION_SECRET=change-me-32-bytes-hex
# Initial admin account
PM_ADMIN_USER=admin
PM_ADMIN_PASSWORD=pm123456
# ERP service account (Casdoor org `inventory` - for materials/warehouse integration)
PM_ERP_CASDOOR_CLIENT_ID=inventory-app-client
PM_ERP_CASDOOR_CLIENT_SECRET=inventory-app-secret-2026
PM_ERP_SERVICE_USERNAME=pm-service
PM_ERP_SERVICE_PASSWORD=pm-service-2026
+8
View File
@@ -0,0 +1,8 @@
# Normalize line endings to LF (mirrors the ERP repo convention)
* text=auto eol=lf
*.png binary
*.jpg binary
*.webp binary
*.ico binary
*.dump binary
+23
View File
@@ -0,0 +1,23 @@
# secrets & local state — never commit
.env
.env.local
# python
__pycache__/
*.pyc
.venv/
venv/
# node
node_modules/
dist/
*.local
# editor / OS
.vscode/
.idea/
Thumbs.db
.DS_Store
# uploaded defect photos (runtime volume)
files/
+102
View File
@@ -0,0 +1,102 @@
# PM API Contract (v3) — Standalone Native Platform — پایه: /api/v1
پلتفرم مدیریت پروژه ساختمانی بومی (Standalone)، مستقل و بدون وابستگی به هیچ سیستم خارجی.
## Auth
- `POST /auth/login` `{username, password}` → `{data:{token, user:{id, username, display_name, role}}}`
- `GET /auth/dev-roles` (در حالت توسعه) → لیست نقش‌های آماده جهت ورود سریع
- `POST /auth/dev-login` `{role}` (در حالت توسعه) → دریافت سریع توکن نقش انتخابی
- همه endpointها (جز لاگین و فایل‌های عمومی) هدر `Authorization: Bearer <token>` می‌خواهند.
- خطاها: `{"error": {"code", "message", "field"?}}` — کدها: `VALIDATION_ERROR` (400)،
`UNAUTHORIZED` (401)، `FORBIDDEN` (403)، `NOT_FOUND` (404)، `CONFLICT` (409)،
`UPSTREAM_UNAVAILABLE` (503).
## تاریخ جلالی
- **همه تاریخ‌های قابل مشاهده در UI شمسی‌اند.** API هم ISO می‌پذیرد/می‌دهد و هم شمسی.
- هر تسک علاوه بر `start_date`/`due_date` (ISO) فیلدهای `start_date_jalali`/
`due_date_jalali` را هم برمی‌گرداند («1405/06/23»).
- `PATCH /tasks/{id}/dates` هر دو قالب را می‌پذیرد: `1405/06/23` یا `2026-09-14`
(سال ≥ 1700 میلادی، کمتر از آن شمسی تفسیر می‌شود). روز نامعتبر → 400.
- تبدیل‌ها در `backend/app/utils/jalali.py` و `frontend/src/lib/jalali.ts` انجام می‌شوند.
## Dashboard
- `GET /dashboard/today` → `{data:{summary:{problems,warnings,ok}, sections:{overdue,due_today,awaiting_qc,failed_qc,blocked,upcoming}}}`
- `awaiting_qc` = وضعیت‌های `pre_qc_pending|exec_qc_pending|ready`
- `failed_qc` = وضعیت‌های `pre_qc_failed|qc_failed_rework`
## Projects
- `GET /projects` → `[...{id, name, identifier, description, active, erp_project_id, created_at, stats:{total, awaiting_qc, failed_qc, overdue}}]`
- `POST /projects` → ساخت پروژه جدید
- `GET /projects/{id}` → جزئیات پروژه به همراه آمارهای تسک‌ها
- `PUT /projects/{id}` | `DELETE /projects/{id}`
- `GET /projects/{id}/gantt` → داده‌های گانت چارت پروژه
- `PATCH /projects/{id}/erp-mapping {erp_project_id}`
## Tasks
- `GET /tasks?project_id=&search=&qc_status=&status=&page=&page_size=`
- `POST /tasks` → ثبت فعالیت/تسک جدید
- `GET /tasks/{id}` → تسک + `relations` + `children` + **`qc`** + **`qc_definitions`** + `defects` + `open_defects`
- `PUT /tasks/{id}` → ویرایش اطلاعات تسک
- `DELETE /tasks/{id}` → حذف تسک
- `PATCH /tasks/{id}/progress {percentage: 0..100}`
- **قاعده ۱:** اگر `percentage > 0` و بازرسی پیش از اجرا تأیید نشده باشد → 400
«بازرسی پیش از اجرا تأیید نشده است (N سؤال باقی‌مانده)»
- **قاعده ۲:** اگر `percentage >= 100` و هر سؤال الزامی تأییدنشده/مردود باشد → 400
«تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (…)»
- `PATCH /tasks/{id}/dates {start_date?, due_date?}` (ISO یا شمسی)
- `POST /tasks/relations` | `DELETE /tasks/relations/{id}` → مدیریت روابط وابستگی تسک‌ها (FS, SS, FF, SF)
### شکل بلوک `qc` در جزئیات تسک
```json
{"pre": {"total":3,"passed":3,"failed":0,"pending":0,"na":0,"required_unresolved":0,"status":"passed"},
"exec": {"total":6,"passed":2,"failed":1,"pending":3,"na":0,"required_unresolved":4,"status":"pending"},
"all": { /* مجموع */ },
"status": "exec_qc_pending",
"can_start": true, "can_complete": false,
"block_reasons": ["تا زمانی که همه بازرسی‌های الزامی تأیید نشوند پیشرفت ۱۰۰٪ مجاز نیست (۴ سؤال باقی‌مانده، ۱ مورد مردود)"]}
```
### وضعیت‌های `qc_status` روی تسک
`none` · `pre_qc_pending` · `pre_qc_failed` · `ready` · `exec_qc_pending` ·
`in_progress` · `qc_failed_rework` · `final_passed`
## QC — تعریف (Definition)
### قالب‌های سراسری (قابل استفاده مجدد)
- `GET /qc/templates` → `[{id,name,activity_type,description,questions:[{id,phase,text,sort_order,required}]}]`
- `POST /qc/templates {name, activity_type?, description?, questions?:[…]}` → `{data:{id}}`
- `PATCH /qc/templates/{id} {name?,activity_type?,description?}`
- `DELETE /qc/templates/{id}`
- `POST /qc/templates/{id}/questions {phase,text,required?,sort_order?}`
- `PATCH /qc/questions/{qid} {text?,phase?,required?,sort_order?}`
- `DELETE /qc/questions/{qid}`
- `PUT /qc/templates/{id}/questions {questions:[…]}` — جایگزینی گروهی (برای ادیتور)
`phase` ∈ `"pre"` (پیش از اجرا) · `"during"` (هنگام اجرا) · `"after"` (پس از اجرا)
### تعریف مخصوص تسک (کپی مستقل — ویرایش آن قالب سراسری را تغییر نمی‌دهد)
- `GET /qc/tasks/{task_id}` → `{data:{task, definitions:[{id,name,source_template_id,created_at,questions:[{…,result,history_count}],summary}], readiness}}`
- `POST /qc/tasks/{task_id}/definition {name?, template_id?}` → **کپی عمیق** سؤالات قالب
- `PATCH /qc/definitions/{id} {name}` | `DELETE /qc/definitions/{id}`
- `POST /qc/definitions/{id}/questions {text,phase,required?,sort_order?}`
- `PATCH /qc/task-questions/{qid} {text?,phase?,required?,sort_order?}`
- `DELETE /qc/task-questions/{qid}`
## QC — اجرا (Execution)
- `PUT /qc/task-questions/{qid}/result {answer:"pass"|"fail"|"na", comment?}`
→ `{data:{question_id, answer, task_qc_status, readiness}}`
- پاسخ‌ها **append-only** هستند (تاریخچه می‌ماند؛ بازرسی مجدد پس از رفع عیب ممکن است)
- تعریف و اجرا کاملاً جدا هستند: هنگام ساخت/کپی سؤال هیچ پاس/ردی ثبت نمی‌شود.
- عیب‌ها:
- `POST /qc/defects {task_id, question_id?, title, description?, severity?, corrective_action?, photo_id?}`
- `POST /qc/defects/{id}/resolve {resolution_notes?}`
- عکس عیب: `POST /files` (multipart) → `{data:{id, url}}` سپس `photo_id`
## فایل‌ها
- `POST /files` multipart (jpg/png/webp تا ۱۰MB) → `{data:{id,url,filename}}`
- `GET /files/{id}`
## Materials / Resources (یکپارچگی ERP)
- `GET/POST /tasks/{task_id}/materials` · `PATCH/DELETE /materials/{req_id}`
- `GET /erp/materials?search=` · `GET /erp/projects`
- `POST /materials/{req_id}/reserve|purchase-request` · `GET /reservations` · `POST /reservations/{id}/release`
- `GET/PATCH /purchase-requests` · `POST /projects/{project_id}/erp-mapping`
+174
View File
@@ -0,0 +1,174 @@
# راهنمای جامع معماری و مدل داده پایگاه‌داده سیستم مدیریت پروژه عمرانی (Construction PM)
> **دستورالعمل برای هوش مصنوعی (AI Prompt / Context):**
> این سند، مشخصات دقیق معماری، موجودیت‌ها (Entities)، فیلدها، روابط و قواعد تجاری یک سیستم بومی مدیریت پروژه عمرانی (Construction Project Management) است.
> لطفاً این راهنما را مطالعه کرده و بر اساس آن، ساختار سیستم، نحوه ذخیره‌سازی داده‌ها، جریان کار (Workflow) و روابط میان جداول را برای کاربر تحلیل و تشریح نما.
---
## ۱. نمای کلی سیستم و فناوری‌ها (Architecture Overview)
- **ماهیت سامانه:** پلتفرم یکپارچه و بومی مدیریت پروژه‌های عمرانی و ساختمانی (Native Construction PM Platform).
- **لایه‌های نرم‌افزاری:**
- **فرانت‌اند:** React + TypeScript + TailwindCSS + TanStack Query (رابط کاربری مدرن با پشتیبانی تقویم شمسی و نمودار گانت تعاملی).
- **بک‌اند:** Python 3.10+ با فریم‌ورک FastAPI و SQLAlchemy 2.0 (Async ORM با درایور aiosqlite / asyncpg).
- **پایگاه‌داده:** SQLite / PostgreSQL با ساختار کاملاً رابطه‌ای (Relational) و فیلدهای ساخت‌یافته JSON.
- **شناسه‌های یکتا (Primary Keys):** تمامی جداول از کلیدهای اصلی ۱۰۰٪ بومی از نوع `UUID v4` استفاده می‌کنند. هیچ‌گونه شناسه یا وابستگی به سیستم‌های قدیمی (Legacy) وجود ندارد.
- **منطق تاریخ‌ها:** تاریخ‌ها در پایگاه‌داده به صورت استاندارد میلادی (`Date` و `DateTime`) ذخیره می‌شوند و تبدیل‌های شمسی به‌صورت لایه‌ای و لحظه‌ای برای نمایش به کاربر انجام می‌گیرد.
---
## ۲. نمودار رابطه موجودیت‌ها (ERD)
```mermaid
erDiagram
PROJECTS ||--o{ TASKS : "شامل می‌شود (1:N)"
PROJECTS ||--o{ PROJECT_MEMBERS : "تخصیص اعضا (1:N)"
PM_USERS ||--o{ PROJECT_MEMBERS : "عضویت کاربر (1:N)"
PM_USERS ||--o{ PROJECT_ASSIGNMENTS : "دسترسی پروژه (1:N)"
PROJECTS ||--o{ PROJECT_ASSIGNMENTS : "محدوده دسترسی (1:N)"
PM_USERS ||--o{ TASKS : "مسئول وظیفه (Assignee)"
TASKS ||--o{ TASKS : "ساختار درختی WBS (والد/فرزند)"
TASKS ||--o{ TASK_RELATIONS : "وظیفه پیش‌نیاز (from_task)"
TASKS ||--o{ TASK_RELATIONS : "وظیفه پس‌نیاز (to_task)"
PROJECTS ||--o{ TASK_RELATIONS : "روابط گانت پروژه"
TASKS ||--o{ TASK_QC_DEFINITIONS : "چک‌لیست‌های کنترلی"
TASK_QC_DEFINITIONS ||--o{ TASK_QC_QUESTIONS : "آیتم‌های کنترلی"
TASK_QC_QUESTIONS ||--o{ TASK_QC_RESULTS : "تاریخچه بازرسی‌ها"
TASKS ||--o{ DEFECTS : "عیوب و عدم انطباق‌ها"
TASK_QC_QUESTIONS ||--o{ DEFECTS : "علت ریشه‌ای عیب (اختیاری)"
QC_TEMPLATES ||--o{ QC_QUESTIONS : "قالب‌های عمومی بازرسی"
```
---
## ۳. شرح تفصیلی جداول و موجودیت‌ها (Data Dictionary)
### ۱. جدول پروژه‌ها (`projects`)
موجودیت ریشه سامانه که اطلاعات اصلی کارگاه‌های عمرانی را نگهداری می‌کند.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه یکتای پروژه |
| `name` | `VARCHAR(255)` | Index | نام پروژه (مانند «پروژه برج بهارستان») |
| `description` | `TEXT` | - | شرح پروژه، الزامات و مشخصات فنی |
| `location` | `VARCHAR(255)` | - | موقعیت جغرافیایی / نشانی کارگاه |
| `project_manager` | `VARCHAR(255)` | - | نام مدیر یا سرپرست کارگاه |
| `status` | `VARCHAR(32)` | Index | وضعیت: `PLANNED`, `ACTIVE`, `ON_HOLD`, `COMPLETED`, `CANCELLED` |
| `planned_start` | `DATE` | - | تاریخ شروع مصوب (محاسبه و تجمیع خودکار از کمترین تاریخ شروع تسک‌ها) |
| `planned_end` | `DATE` | - | تاریخ پایان مصوب (محاسبه و تجمیع خودکار از بیشترین تاریخ پایان تسک‌ها) |
| `actual_start` | `DATE` | - | تاریخ شروع واقعی (محاسبه خودکار از زمان آغاز اولین تسک) |
| `actual_end` | `DATE` | - | تاریخ پایان واقعی (محاسبه خودکار با اتمام ۱۰۰٪ تمام تسک‌ها) |
| `progress` | `FLOAT` | - | درصد پیشرفت فیزیکی پروژه (میانگین وزنی درصد پیشرفت تسک‌ها از ۰ تا ۱۰۰) |
| `created_at` | `TIMESTAMP` | - | زمان ثبت رکورد |
| `updated_at` | `TIMESTAMP` | - | زمان آخرین ویرایش رکورد |
---
### ۲. جدول وظایف و فعالیت‌ها (`tasks`)
اصلی‌ترین موجودیت سامانه برای ساختار شکست کار (WBS)، گانت، تخصیص منابع و کنترل کیفی.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه یکتای وظیفه |
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه‌ای که وظیفه به آن تعلق دارد (Cascade Delete) |
| `parent_id` | `UUID` (Nullable) | **FK** -> `tasks.id` | والد در ساختار درختی WBS با عمق نامحدود (فاز ➔ فعالیت ➔ زیرتسک) |
| `title` | `VARCHAR(512)` | - | عنوان وظیفه (مانند «بتن‌ریزی سقف طبقه ۲») |
| `description` | `TEXT` | - | مشخصات و الزامات کار |
| `status` | `VARCHAR(32)` | Index | وضعیت: `TODO`, `IN_PROGRESS`, `BLOCKED`, `COMPLETED`, `CANCELLED` |
| `priority` | `VARCHAR(32)` | - | اولویت: `low`, `medium`, `high`, `urgent` |
| `assignee_id` | `UUID` (Nullable) | **FK** -> `pm_users.id` | کاربر سیستم مسئول اجرای وظیفه |
| `assignee_name` | `VARCHAR(255)` | - | نام متنی کاربر جهت نمایش سریع |
| `planned_start` | `DATE` | - | تاریخ شروع برنامه‌ریزی‌شده |
| `planned_end` | `DATE` | - | تاریخ پایان برنامه‌ریزی‌شده |
| `actual_start` | `DATE` (Nullable) | - | شروع واقعی (ثبت خودکار هنگام تغییر پیشرفت به بیشتر از صفر) |
| `actual_end` | `DATE` (Nullable) | - | پایان واقعی (ثبت خودکار در زمان تکمیل ۱۰۰٪) |
| `duration` | `INTEGER` | - | مدت زمان برآورد شده بر حسب روز کاری |
| `progress` | `INTEGER` | - | درصد پیشرفت واقعی از ۰ تا ۱۰۰ |
| `qc_status` | `VARCHAR(32)` | - | وضعیت کنترل کیفی: `none`, `pre_qc_pending`, `pre_qc_failed`, `ready`, `exec_qc_pending`, `in_progress`, `qc_failed_rework`, `final_passed` |
| `human_resources` | `JSON` | - | لیست نقش‌ها و نفرات: `[{"role": "آرماتوربند", "count": 4}]` |
| `materials` | `JSON` | - | لیست مصالح و تجهیزات/ماشین‌آلات: `[{"name": "سیمان تیپ ۲", "unit": "تن", "specifications": ["عیار ۳۵۰"], "count": 10}, {"name": "تاور کرین", "unit": "دستگاه", "specifications": ["ظرفیت ۸ تن"], "count": 1}]` |
| `created_at` | `TIMESTAMP` | - | تاریخ ایجاد رکورد |
| `updated_at` | `TIMESTAMP` | - | تاریخ آخرین به‌روزرسانی |
---
### ۳. جدول روابط وابستگی و گانت (`task_relations`)
پایه‌گذار روش مسیر بحرانی (CPM Scheduling) و شبکه فعالیت‌های گانت.
| نام فیلد | نوع داده | کلید / ایندکس | توضیحات |
|---|---|---|---|
| `id` | `UUID` | **PK** | شناسه رابطه |
| `project_id` | `UUID` | **FK** -> `projects.id` | پروژه مربوطه |
| `from_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت پیش‌نیاز (Predecessor) |
| `to_task_id` | `UUID` | **FK** -> `tasks.id` | فعالیت وابسته / پس‌نیاز (Successor) |
| `relation_type` | `VARCHAR(8)` | - | نوع وابستگی: `FS` (پایان به شروع)، `SS` (شروع به شروع)، `FF` (پایان به پایان)، `SF` (شروع به پایان) |
| `lag_days` | `INTEGER` | - | فاصله زمانی مجاز (تاخیر یا تقدم) بر حسب روز |
| `created_at` | `TIMESTAMP` | - | زمان ثبت رابطه |
*محدودیت (Constraint):* زوج `(from_task_id, to_task_id)` یکتا بوده و از ایجاد حلقه (Cycle) در الگوریتم CPM جلوگیری می‌شود.
---
### ۴. حوزه کنترل کیفیت و بازرسی فنی (QC Domain)
این بخش تضمین می‌کند کلیه اقدامات اجرایی کارگاه دارای تاییدیه‌های استاندارد باشند:
#### الف) قالب‌های سراسری بازرسی (`qc_templates` و `qc_questions`)
- **`qc_templates`:** کتابخانه فرم‌های استاندارد کیفی (مثلاً قالب بازرسی بتن‌ریزی، قالب سفت‌کاری).
- **`qc_questions`:** سوالات و ضوابط کنترلی در سه فاز اجرایی:
- `pre`: پیش از اجرا (آمادگی کارگاه، تایید نقشه‌ها، مصالح ورودی).
- `during`: حین اجرا (دمای بتن، ویبره مناسب، ایمنی).
- `after`: پس از اجرا (عمل‌آوری، مقاومت فشاری نمونه‌ها).
#### ب) فرم‌های کیفی اختصاص‌یافته به وظایف (`task_qc_definitions` و `task_qc_questions`)
- با تخصیص چک‌لیست به یک تسک، یک نسخه مستقل از قالب بر روی وظیفه ایجاد می‌شود تا هرگونه تغییر روی قالب سراسری به تاریخچه بازرسی‌های ثبت‌شده آسیب نزند.
#### ج) نتایج و تاریخچه بازرسی (`task_qc_results`)
- سیستم ثبت پیوسته (Append-Only Log) بدون امکان حذف؛ هر ارزیابی بازرس شامل:
- `question_id`: شناسه آیتم بازرسی.
- `answer`: پاسخ بازرس (`pass` تایید، `fail` عدم انطباق، `na` عدم کاربرد).
- `comment`: توضیحات فنی و نواقص مشاهده‌شده.
- `evaluated_by`: نام بازرس ناظر.
- `evaluated_at`: زمان دقیق بازرسی.
#### د) جدول عیوب و عدم انطباق‌ها (`defects`)
- ثبت رخدادهای نامنطبق در جریان اجرای کار:
- `id` (UUID - PK): شناسه عیب.
- `task_id` (UUID - FK -> `tasks.id`): وظیفه مربوطه.
- `question_id` (UUID - FK اختیاری): سوال بازرسی که منجر به رد شدن شده است.
- `title`: عنوان عدم انطباق (مثلاً: «کرموشدگی بتن ستون جنوبی»).
- `description`: شرح دقیق ایراد.
- `severity`: درجه شدت عیب (`minor`, `medium`, `major`, `critical`).
- `status`: وضعیت رفع (`open`, `in_progress`, `resolved`).
- `corrective_action`: دستور اقدام اصلاحی صادرشده توسط مهندس ناظر.
- `resolution_notes`: شرح نحوه رفع عیب.
- `photo_path`: مسیر ذخیره تصویر مستند عیب در سرور.
- `resolved_at`: زمان تایید رفع عیب.
---
### ۵. احراز هویت و دسترسی‌ها (`pm_users`, `project_members`, `project_assignments`)
- **`pm_users`:** کاربران سازمانی سیستم (`username`, `password_hash`, `display_name`, `role`).
- نقش‌های سیستمی: `project_manager` (دسترسی نامحدود به تمام پروژه‌ها)، `project_supervisor` (دسترسی محدود به پروژه‌های مشخص).
- **`project_members`:** اعضای منتسب به هر پروژه با نقش کارگاهی:
- `role`: `project_manager`, `site_engineer`, `qc_inspector`, `member`.
- **`project_assignments`:** جدول دامنه دسترسی برای ناظران و سرپرستان دارای دسترسی محدود (Scoped Roles).
---
## ۴. قواعد و منطق‌های تجاری نهفته در سیستم (Business Invariants)
1. **گیتینگ کنترل کیفی (QC Gating Rule):**
اگر وظیفه‌ای دارای چک‌لیست بازرسی فاز `pre` (پیش از اجرا) باشد، تا زمانی که تمامی این سوالات وضعیت `pass` یا `na` دریافت نکنند، سیستم اجازه شروع کار (`IN_PROGRESS`) و افزایش پیشرفت را نمی‌دهد.
2. **محاسبات پیش‌بینی مسیر بحرانی (CPM Logic):**
محاسبات زمان‌بندی با عبور رفت و برگشت (Forward & Backward Pass) تاریخ‌های زودهنگام و دیرهنگام را استخراج کرده و فعالیت‌های با شناوری صفر (`Total Float = 0`) را به عنوان مسیر بحرانی مشخص می‌کند.
3. **چرخه حیات خودکار تاریخ‌های واقعی:**
با اولین تغییر در پیشرفت تسک (`progress > 0`)، فیلد `actual_start` به تاریخ جاری تنظیم می‌گردد. با رسیدن پیشرفت به `100%`، فیلد `actual_end` تنظیم و وضعیت وظیفه خودکار به `COMPLETED` تغییر می‌یابد.
4. **تجمیع پروژه (Rollup Logic):**
تاریخ شروع، پایان و درصد پیشرفت فیزیکی کل پروژه همواره حاصل تجمیع و میانگین‌گیری پویای وظایف موجود در پروژه است.
+91
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
__pycache__
*.pyc
.pytest_cache
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.13-slim
WORKDIR /srv/pm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
# PIP_INDEX_URL overridable like the ERP api/bot images (mirror for restricted networks).
ARG PIP_INDEX_URL=https://pypi.org/simple
RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt
COPY app ./app
COPY tools ./tools
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1
View File
@@ -0,0 +1 @@
"""PM application package."""
+296
View File
@@ -0,0 +1,296 @@
"""Central authorization (RBAC) — roles, permissions and per-project scoping.
Everything authorization-related lives in this module so that adding a role is
one entry in `ROLE_PERMISSIONS` (plus a nav entry on the frontend), not a hunt
through the routers.
Two independent questions, answered separately:
* **Role → permissions** (`Perm`, `ROLE_PERMISSIONS`) decides *which operations*
a user may call at all.
* **Project scope** decides *which projects* those operations may touch. A role
listed in `SCOPED_ROLES` is limited to the rows in `project_assignments`
(an empty set means "no project at all"); every other role is unrestricted.
Routers must go through the dependencies and helpers below — hiding a button in
the UI is a convenience, never the enforcement.
"""
from __future__ import annotations
import enum
import logging
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from fastapi import Depends
from sqlalchemy import Select, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.errors import ForbiddenError, NotFoundError
from app.models import PMUser, ProjectAssignment, Task
from app.security import get_current_user
logger = logging.getLogger("pm.authz")
# --- roles --------------------------------------------------------------------
class Role(str, enum.Enum):
project_manager = "project_manager"
project_supervisor = "project_supervisor"
ROLE_LABELS: dict[str, str] = {
Role.project_manager.value: "مدیر پروژه",
Role.project_supervisor.value: "سرپرست پروژه",
}
# Users created before RBAC existed keep their previous (full) access.
DEFAULT_ROLE = Role.project_manager
# Roles whose access is limited to their assigned projects.
SCOPED_ROLES: frozenset[Role] = frozenset({Role.project_supervisor})
# --- permissions --------------------------------------------------------------
class Perm(str, enum.Enum):
"""One value per authorizable operation — keep it coarse and stable."""
dashboard_view = "dashboard.view"
projects_view = "projects.view"
projects_create = "projects.create"
projects_update = "projects.update"
tasks_view = "tasks.view"
tasks_create = "tasks.create"
tasks_update = "tasks.update"
tasks_delete = "tasks.delete"
tasks_progress = "tasks.progress"
tasks_dates = "tasks.dates"
qc_view = "qc.view"
# executing QC: recording answers and raising/resolving defects
qc_execute = "qc.execute"
# QC structure: global templates + per-task definitions
qc_define = "qc.define"
materials_view = "materials.view"
materials_manage = "materials.manage"
users_manage = "users.manage"
files_upload = "files.upload"
# Project manager: full access to everything, across every project.
_MANAGER_PERMISSIONS: frozenset[str] = frozenset(p.value for p in Perm)
# Project supervisor: executes the assigned projects — reads the plan, records
# progress and QC results, needs the material list; never edits the QC
# structure, templates, procurement or integration settings.
_SUPERVISOR_PERMISSIONS: frozenset[str] = frozenset(
{
Perm.dashboard_view.value,
Perm.projects_view.value,
Perm.tasks_view.value,
Perm.tasks_progress.value,
Perm.qc_view.value,
Perm.qc_execute.value,
Perm.materials_view.value,
Perm.files_upload.value,
}
)
ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
Role.project_manager: _MANAGER_PERMISSIONS,
Role.project_supervisor: _SUPERVISOR_PERMISSIONS,
}
def permissions_for(role: Role) -> frozenset[str]:
return ROLE_PERMISSIONS.get(role, frozenset())
def role_of(user: PMUser) -> Role:
try:
return Role(user.role)
except ValueError:
logger.warning(
"unknown role %r on user %s — falling back to %s", user.role, user.username, DEFAULT_ROLE.value
)
return DEFAULT_ROLE
# --- request context ----------------------------------------------------------
@dataclass(frozen=True)
class AuthContext:
"""Who is calling, what they may do, and which projects they may touch.
`project_uuids` — native projects (`projects.id`).
None for an unscoped role (project manager). For a scoped role a set — possibly empty — means "only these".
"""
user: PMUser
role: Role
project_uuids: frozenset[uuid.UUID] | None = None
@property
def permissions(self) -> frozenset[str]:
return permissions_for(self.role)
@property
def role_label(self) -> str:
return ROLE_LABELS.get(self.role.value, self.role.value)
@property
def all_projects(self) -> bool:
return self.project_uuids is None
def can(self, permission: Perm | str) -> bool:
value = permission.value if isinstance(permission, Perm) else permission
return value in self.permissions
def require(self, permission: Perm | str) -> None:
if not self.can(permission):
raise ForbiddenError(f"نقش «{self.role_label}» به این عملیات دسترسی ندارد")
def allows_project(self, project_id) -> bool:
"""Check whether the user is allowed to access the specified project."""
if self.project_uuids is None:
return True
try:
pid = uuid.UUID(str(project_id))
except (ValueError, AttributeError):
return False
return pid in self.project_uuids
def ensure_project(self, project_id) -> None:
"""Raise 403 when the project is outside this user's scope."""
if not self.allows_project(project_id):
raise ForbiddenError("این پروژه به شما اختصاص داده نشده است")
allows_native_project = allows_project
ensure_native_project = ensure_project
def scope(self, stmt: Select, column) -> Select:
"""Restrict a query over project UUIDs."""
if self.project_uuids is None:
return stmt
return stmt.where(column.in_(self.project_uuids))
scope_native = scope
@property
def scoped_project_count(self) -> int:
"""How many projects a scoped user may see (0 when nothing is assigned)."""
return len(self.project_uuids or ())
def as_dict(self) -> dict:
return {
"role": self.role.value,
"role_label": self.role_label,
"permissions": sorted(self.permissions),
"scope": {
"all_projects": self.all_projects,
"project_count": None if self.all_projects else self.scoped_project_count,
},
}
async def assigned_scope(db: AsyncSession, user_id) -> frozenset[uuid.UUID]:
"""Load assigned project UUIDs for a scoped user."""
rows = (
await db.execute(
select(ProjectAssignment.project_id).where(
ProjectAssignment.user_id == user_id
)
)
).scalars().all()
assigned = set(r for r in rows if r is not None)
# Also resolve projects assigned via projects.pm_user_id or user_project_members in the shared DB
pm_user = await db.get(PMUser, user_id)
if pm_user and pm_user.username:
erp_rows = (
await db.execute(
text("""
SELECT p.id FROM projects p JOIN users u ON p.pm_user_id = u.id WHERE u.username = :username
UNION
SELECT upm.project_id FROM user_project_members upm JOIN users u ON upm.user_id = u.id WHERE u.username = :username
"""),
{"username": pm_user.username},
)
).scalars().all()
for r in erp_rows:
if r:
try:
assigned.add(uuid.UUID(str(r)))
except (ValueError, TypeError):
pass
return frozenset(assigned)
async def build_context(db: AsyncSession, user: PMUser) -> AuthContext:
"""Resolve role + project scope for a user."""
role = role_of(user)
if role not in SCOPED_ROLES:
return AuthContext(user=user, role=role)
native = await assigned_scope(db, user.id)
return AuthContext(user=user, role=role, project_uuids=native)
async def get_auth_context(
user: PMUser = Depends(get_current_user), db: AsyncSession = Depends(get_db)
) -> AuthContext:
return await build_context(db, user)
def require_permission(permission: Perm) -> Callable[..., Awaitable[AuthContext]]:
"""Dependency: authenticated **and** allowed to perform `permission`."""
async def _guard(ctx: AuthContext = Depends(get_auth_context)) -> AuthContext:
ctx.require(permission)
return ctx
return _guard
# --- data-access guards -------------------------------------------------------
async def resolve_task(db: AsyncSession, task_ref: str | uuid.UUID) -> Task | None:
"""Find a native task by UUID."""
task_ref = task_ref.strip() if isinstance(task_ref, str) else task_ref
try:
task_uuid = uuid.UUID(str(task_ref))
except (ValueError, AttributeError):
return None
return await db.scalar(select(Task).where(Task.id == task_uuid))
async def scoped_task(db: AsyncSession, ctx: AuthContext, task_ref: str | uuid.UUID) -> Task:
"""Load a native task: 404 unknown, 403 out of scope."""
task = await resolve_task(db, task_ref)
if task is None:
raise NotFoundError("تسک یافت نشد")
ctx.ensure_project(task.project_id)
return task
scoped_native_task = scoped_task
resolve_native_task = resolve_task
# --- development role switching ----------------------------------------------
def dev_switch_enabled() -> bool:
return bool(settings.DEV_ROLE_SWITCH)
def dev_login_usernames() -> list[str]:
"""Demo accounts the dev switcher may log in as (never arbitrary users)."""
return [name for name in settings.dev_demo_usernames() if name]
+184
View File
@@ -0,0 +1,184 @@
"""Casdoor SSO for PM — one identity source for the whole suite.
The Casdoor JWT issued by the ERP login flow is accepted on every PM endpoint:
- The token is verified against Casdoor's JWKS (same as the ERP backend):
signature (RS256 family), issuer = ``PM_CASDOOR_ISSUER`` (the browser-facing
origin Casdoor advertises — ``casdoor.origin`` in the compose env) and
audience = ``PM_CASDOOR_AUDIENCE`` (the inventory-app client).
- The caller's system-wide role lives in the ERP (``users.role``) and is
resolved by calling ``GET {ERP_BASE_URL}/api/v1/users/me`` with the SAME
Bearer token (the ERP verifies the identical JWT). Result is cached briefly
per casdoor id — one upstream call per role change window, not per request.
- Mapping to the PM world (single source of truth stays the ERP):
admin -> project_manager (full PM access)
pm (مدیر پروژه) -> project_manager (full PM access)
supervisor -> project_supervisor (project-scoped, as today)
procurement_manager -> 403 — مدیر تدارکات به پنل PM دسترسی ندارد
- The PMUser row is auto-provisioned / role-synced by username on first use,
so تخصیص سرپرست‌ها به پروژه‌ها (ProjectAssignment) بلافاصله بعد از اولین
ورود قابل انجام است.
Legacy HMAC session tokens (POST /auth/login, dev role switcher, standalone
:8083 deployments) keep working: ``get_current_user`` dispatches on token
shape (two dot-separated segments = legacy, three = Casdoor JWT).
"""
import logging
import time
import httpx
import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.errors import AuthError, ForbiddenError, UpstreamError
from app.models import PMUser
# نقش‌های PM به‌صورت متن (PMUser.role ستون متنی است) — import از authz اینجا
# چرخه‌ی import می‌سازد (authz → security → casdoor_auth → authz)
_PM_PROJECT_MANAGER = "project_manager"
_PM_PROJECT_SUPERVISOR = "project_supervisor"
logger = logging.getLogger("pm.casdoor")
_JWKS_CACHE: dict | None = None
# casdoor_id -> (erp_role, expiry monotonic seconds)
_ROLE_CACHE: dict[str, tuple[str, float]] = {}
_ROLE_CACHE_TTL_SECONDS = 30
def _jwks_url() -> str:
return f"{settings.ERP_CASDOOR_ENDPOINT.rstrip('/')}/.well-known/jwks"
async def _get_jwks() -> dict:
global _JWKS_CACHE
if _JWKS_CACHE is not None:
return _JWKS_CACHE
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(_jwks_url())
resp.raise_for_status()
_JWKS_CACHE = resp.json()
except httpx.HTTPError as exc:
logger.error("Casdoor JWKS fetch failed (%s): %s", _jwks_url(), exc)
raise UpstreamError("سرویس احراز هویت (Casdoor) در دسترس نیست") from exc
return _JWKS_CACHE
def _key_from_jwk(jwk: dict):
kty = jwk.get("kty")
if kty == "RSA":
return jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
if kty == "EC":
return jwt.algorithms.ECAlgorithm.from_jwk(jwk)
return jwk
async def verify_casdoor_token(token: str) -> dict:
"""Validate the Casdoor JWT and return its claims (AuthError on failure)."""
try:
kid = jwt.get_unverified_header(token).get("kid")
except jwt.PyJWTError as exc:
logger.warning("JWT unreadable: %s", exc)
raise AuthError("توکن نامعتبر است") from exc
jwks = await _get_jwks()
entry = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
if entry is None:
logger.warning("JWT kid %r not found in JWKS", kid)
raise AuthError("امضای توکن نامعتبر است")
key = _key_from_jwk(entry)
try:
return jwt.decode(
token,
key,
algorithms=["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"],
issuer=settings.CASDOOR_ISSUER,
audience=settings.CASDOOR_AUDIENCE,
)
except jwt.PyJWTError as exc:
logger.warning("JWT verification failed: %s", exc)
raise AuthError("توکن نامعتبر یا منقضی شده است") from exc
async def _erp_identity_for_token(token: str, casdoor_id: str) -> dict:
"""نقش + پنل‌های کاربر از ERP (users/me) — کش کوتاه."""
cached = _ROLE_CACHE.get(casdoor_id)
if cached and cached[1] > time.monotonic():
return cached[0]
url = f"{settings.ERP_BASE_URL.rstrip('/')}/api/v1/users/me"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as exc:
logger.error("ERP /users/me unreachable (%s): %s", url, exc)
raise UpstreamError("سامانه تدارکات (ERP) برای تعیین دسترسی در دسترس نیست") from exc
if resp.status_code == 401:
raise AuthError("توکن نامعتبر یا منقضی شده است")
if resp.status_code != 200:
logger.error("ERP /users/me failed (%s): HTTP %s", url, resp.status_code)
raise UpstreamError("تعیین دسترسی کاربر از ERP ناموفق بود")
data = resp.json().get("data") or {}
identity = {"role": data.get("role"), "panels": data.get("panels") or {}}
_ROLE_CACHE[casdoor_id] = (identity, time.monotonic() + _ROLE_CACHE_TTL_SECONDS)
return identity
# سطح پنل PM -> نقش PM: full = همه بخش‌ها با اختیار کامل تغییر؛
# limited = پاسخ QC + درصد پیشرفت + مشاهده جزئیات (محدود به پروژه‌های تخصیصی).
# مدیر تدارکات به PM دسترسی ندارد.
async def casdoor_user_from_token(token: str, db: AsyncSession) -> PMUser:
"""Verify the JWT, resolve the system role from ERP and return the
provisioned PMUser (role synced). 403 for roles without PM access."""
claims = await verify_casdoor_token(token)
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
username = str(claims.get("name") or claims.get("preferred_username") or "")
if not casdoor_id or not username:
raise AuthError("توکن فاقد شناسه کاربر است")
identity = await _erp_identity_for_token(token, casdoor_id)
panels = identity.get("panels") or {}
erp_role = identity.get("role")
pm_level = panels.get("pm")
if erp_role == "admin":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "full":
pm_role = _PM_PROJECT_MANAGER
elif pm_level == "limited":
pm_role = _PM_PROJECT_SUPERVISOR
elif not panels and erp_role in ("admin", "pm"): # میراث — دوره مهاجرت فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_MANAGER
elif not panels and erp_role == "supervisor": # میراث — فقط در صورت عدم تعریف پنل‌ها
pm_role = _PM_PROJECT_SUPERVISOR
else:
raise ForbiddenError("به پنل مدیریت پروژه دسترسی ندارید")
user = await db.scalar(select(PMUser).where(PMUser.username == username))
if user is None:
display_name = str(
claims.get("displayName") or claims.get("firstName") or username
)
user = PMUser(
username=username,
password_hash="", # SSO identities never authenticate locally
display_name=display_name,
role=pm_role,
)
db.add(user)
await db.commit()
await db.refresh(user)
elif user.role != pm_role:
# نقش سراسری عوض شده — با PM همگام می‌شود
user.role = pm_role
await db.commit()
await db.refresh(user)
return user
def is_casdoor_token(token: str) -> bool:
"""Legacy session tokens are ``body.signature`` (one dot); Casdoor JWTs
have the standard three header/payload/signature segments."""
return token.count(".") == 2
+49
View File
@@ -0,0 +1,49 @@
"""PM settings — env prefix PM_ (mirrors the ERP PROCUREMENT_ pattern)."""
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="PM_", env_file=".env", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://pm:pm@localhost:5432/pm"
SESSION_SECRET: str = "change-me"
SESSION_TTL_HOURS: int = 24 * 14
ADMIN_USER: str = "admin"
ADMIN_PASSWORD: str = "admin"
# Development role switching — the dashboard shows a role picker so the two
# roles can be tried without separate logins. Set PM_DEV_ROLE_SWITCH=false
# (production) to disable the dev endpoints entirely.
DEV_ROLE_SWITCH: bool = False
DEV_SUPERVISOR_USER: str = "supervisor"
DEV_SUPERVISOR_PASSWORD: str = "pm123456"
DEV_SUPERVISOR2_USER: str = "supervisor2"
DEV_SUPERVISOR2_PASSWORD: str = "pm123456"
def dev_demo_usernames(self) -> list[str]:
"""Accounts the dev role switcher may log in as (manager + supervisors)."""
return [self.ADMIN_USER, self.DEV_SUPERVISOR_USER, self.DEV_SUPERVISOR2_USER]
FILES_DIR: Path = Path("/data/files")
# ERP (procurement/inventory) integration — read-only service account
ERP_BASE_URL: str = "http://procurement-api:8000"
ERP_CASDOOR_ENDPOINT: str = "http://casdoor:8000"
ERP_CASDOOR_CLIENT_ID: str = "inventory-app-client"
ERP_CASDOOR_CLIENT_SECRET: str = "inventory-app-secret-2026"
ERP_CASDOOR_ORGANIZATION: str = "inventory"
ERP_SERVICE_USERNAME: str = "pm-service"
ERP_SERVICE_PASSWORD: str = ""
ERP_CACHE_TTL_SECONDS: int = 120
# Casdoor SSO — PM accepts the ERP-issued JWT as its own session.
# ISSUER = the browser-facing origin Casdoor advertises (casdoor `origin`),
# not the in-network endpoint; AUDIENCE = the inventory-app client id.
CASDOOR_ISSUER: str = "http://localhost:8080"
CASDOOR_AUDIENCE: str = "inventory-app-client"
settings = Settings()
+20
View File
@@ -0,0 +1,20 @@
"""Async SQLAlchemy engine/session for PM database."""
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
try:
engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_size=5, max_overflow=5)
except Exception:
# Fallback for testing environments or when asyncpg driver is not installed on host
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
+151
View File
@@ -0,0 +1,151 @@
"""Development-only demo accounts for the role switcher on the dashboard.
While the two roles are being built, the «امروز» dashboard shows a role picker
so nobody has to juggle logins. This module creates those demo users and gives
the demo supervisors a project scope, so the picker is immediately useful —
the enforcement itself is the real thing (see app/authz.py).
Everything here is inert unless `PM_DEV_ROLE_SWITCH` is enabled; the endpoints
in routers/auth.py return 404 in that case. For production set the flag to
false and manage users through `PUT /users/{id}/projects`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.authz import Role, dev_switch_enabled
from app.config import settings
from app.models import PMUser, Project, ProjectAssignment
from app.security import hash_password
logger = logging.getLogger("pm.dev")
@dataclass(frozen=True)
class DemoAccount:
username: str
password: str
display_name: str
role: Role
share: tuple[int, int] | None = None
def demo_accounts() -> list[DemoAccount]:
total = len(settings.dev_demo_usernames())
return [
DemoAccount(settings.ADMIN_USER, settings.ADMIN_PASSWORD, "مدیر سیستم", Role.project_manager),
DemoAccount(
settings.DEV_SUPERVISOR_USER,
settings.DEV_SUPERVISOR_PASSWORD,
"سرپرست پروژه الف",
Role.project_supervisor,
share=(1, 2),
),
DemoAccount(
settings.DEV_SUPERVISOR2_USER,
settings.DEV_SUPERVISOR2_PASSWORD,
"سرپرست پروژه ب",
Role.project_supervisor,
share=(2, 2),
),
][:total]
def _share_of(items: list, share: tuple[int, int]) -> list:
"""Deterministic, non-overlapping slice: part N of M (1-based)."""
part, of = share
size = -(-len(items) // of)
start = (part - 1) * size
return items[start : start + size]
def demo_account(username: str) -> DemoAccount | None:
for account in demo_accounts():
if account.username == username:
return account
return None
async def ensure_demo_accounts(db: AsyncSession) -> None:
if not dev_switch_enabled():
# در پروداکشن، حساب‌های نمایشی توسعه نباید فعال باشند
demo_names = [settings.DEV_SUPERVISOR_USER, settings.DEV_SUPERVISOR2_USER]
demo_users = (
await db.execute(select(PMUser).where(PMUser.username.in_(demo_names)))
).scalars().all()
for u in demo_users:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == u.id))
await db.delete(u)
logger.info("cleaned up demo account %s for production", u.username)
admin_u = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if admin_u is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role=Role.project_manager.value,
)
)
logger.info("seeded admin account in production mode")
else:
# همیشه رمز عبور را از تنظیمات به‌روز کن تا لاگین همیشه کار کند
admin_u.password_hash = hash_password(settings.ADMIN_PASSWORD)
admin_u.role = Role.project_manager.value
logger.info("refreshed admin password hash in production mode")
await db.commit()
return
for account in demo_accounts():
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
db.add(
PMUser(
username=account.username,
password_hash=hash_password(account.password),
display_name=account.display_name,
role=account.role.value,
)
)
logger.info("seeded %s account %s", account.role.value, account.username)
else:
user.role = account.role.value
user.display_name = account.display_name
await db.commit()
async def ensure_demo_assignments(db: AsyncSession) -> None:
if not dev_switch_enabled():
return
native_ids = [p for p in (await db.execute(select(Project.id).order_by(Project.name))).scalars().all()]
if not native_ids:
return
for account in demo_accounts():
if account.share is None:
continue
user = await db.scalar(select(PMUser).where(PMUser.username == account.username))
if user is None:
continue
existing = await db.scalar(
select(func.count()).where(ProjectAssignment.user_id == user.id)
)
if existing:
continue
mine = _share_of(native_ids, account.share)
for project_uuid in mine:
db.add(ProjectAssignment(user_id=user.id, project_id=project_uuid))
logger.info(
"assigned %d demo projects to %s",
len(mine),
account.username,
)
await db.commit()
async def clear_assignments(db: AsyncSession, user_id) -> None:
await db.execute(delete(ProjectAssignment).where(ProjectAssignment.user_id == user_id))
+53
View File
@@ -0,0 +1,53 @@
"""PM error envelope — same contract as the ERP: {"error": {code, message, field}}."""
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class AppError(Exception):
status_code = 400
code = "BAD_REQUEST"
def __init__(self, message: str, field: str | None = None, code: str | None = None):
super().__init__(message)
self.message = message
self.field = field
if code:
self.code = code
@property
def body(self) -> dict:
error = {"code": self.code, "message": self.message}
if self.field is not None:
error["field"] = self.field
return {"error": error}
class AuthError(AppError):
status_code, code = 401, "UNAUTHORIZED"
class ValidationError(AppError):
status_code, code = 400, "VALIDATION_ERROR"
class ForbiddenError(AppError):
status_code, code = 403, "FORBIDDEN"
class NotFoundError(AppError):
status_code, code = 404, "NOT_FOUND"
class ConflictError(AppError):
status_code, code = 409, "CONFLICT"
class UpstreamError(AppError):
"""Upstream service unreachable / errored."""
status_code, code = 503, "UPSTREAM_UNAVAILABLE"
def install_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppError)
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content=exc.body)
+1
View File
@@ -0,0 +1 @@
"""Integration connectors (ERP/Inventory/Procurement). Phase-2 lives here."""
+160
View File
@@ -0,0 +1,160 @@
"""ERP (procurement/inventory) read-only connector.
Auth: Casdoor password grant with a dedicated service account (no changes to
the ERP codebase). The token is cached until expiry. ERP stays the source of
truth for physical stock; PM only READS it (reservations/purchase requests are
PM-owned operational overlays).
"""
import asyncio
import logging
import time
from typing import Any
import httpx
from app.config import settings
from app.errors import UpstreamError
logger = logging.getLogger("pm.erp")
class ERPClient:
def __init__(self):
self._token: str | None = None
self._token_exp: float = 0
self._lock = asyncio.Lock()
async def _ensure_token(self) -> str:
async with self._lock:
if self._token and time.time() < self._token_exp - 30:
return self._token
usernames = [
settings.ERP_SERVICE_USERNAME,
f"{settings.ERP_CASDOOR_ORGANIZATION}/{settings.ERP_SERVICE_USERNAME}",
]
last_error: Exception | None = None
async with httpx.AsyncClient(timeout=12) as client:
for username in usernames:
try:
resp = await client.post(
f"{settings.ERP_CASDOOR_ENDPOINT}/api/login/oauth/access_token",
data={
"grant_type": "password",
"client_id": settings.ERP_CASDOOR_CLIENT_ID,
"client_secret": settings.ERP_CASDOOR_CLIENT_SECRET,
"username": username,
"password": settings.ERP_SERVICE_PASSWORD,
},
)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if token:
self._token = token
self._token_exp = time.time() + int(data.get("expires_in") or 3600)
return token
except Exception as exc: # noqa: BLE001
last_error = exc
logger.warning("ERP token grant failed for %s: %s", username, exc)
raise UpstreamError(f"احراز هویت سرویس با ERP/Casdoor ناموفق بود: {last_error}")
async def get(self, path: str, params: dict | None = None) -> Any:
token = await self._ensure_token()
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{settings.ERP_BASE_URL}{path}",
params=params,
headers={"Authorization": f"Bearer {token}"},
)
except httpx.HTTPError as exc:
raise UpstreamError(f"ERP در دسترس نیست: {exc.__class__.__name__}") from exc
if resp.status_code == 401:
self._token = None
raise UpstreamError("توکن سرویس ERP نامعتبر شد")
if resp.status_code >= 400:
logger.warning("ERP GET %s → %s %s", path, resp.status_code, resp.text[:200])
raise UpstreamError(f"خطای ERP ({resp.status_code})")
if not resp.content:
return {}
return resp.json()
# --- domain reads (normalize to PM-friendly dicts) -----------------------
async def materials(self) -> list[dict]:
"""Global material catalog: [{id, name, unit_id, unit_name}]"""
materials: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/materials?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
materials.extend(elements)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(materials)):
break
page += 1
unit_names: dict[int, str] = {}
try:
units = await self.get("/api/v1/units?page_size=100")
for u in units.get("data", []):
unit_names[u.get("id")] = u.get("name_fa") or u.get("name") or u.get("code") or ""
except Exception: # noqa: BLE001 — unit names are cosmetic
pass
return [
{
"id": m.get("id"),
"name": m.get("name") or "",
"unit_id": m.get("canonical_unit_id"),
"unit_name": unit_names.get(m.get("canonical_unit_id"), ""),
}
for m in materials
if m.get("id") and (m.get("status") or "active") == "active"
]
async def projects(self) -> list[dict]:
"""ERP projects for the mapping picker: [{id, name}]"""
data = await self.get("/api/v1/projects?page_size=100")
elements = data.get("data", []) if isinstance(data, dict) else []
return [{"id": p.get("id"), "name": p.get("name") or ""} for p in elements if p.get("id")]
async def warehouses(self, erp_project_id: str) -> list[dict]:
data = await self.get("/api/v1/inventory/warehouses")
elements = data.get("data", data) if isinstance(data, dict) else data
if isinstance(elements, dict):
elements = elements.get("data", [])
return [w for w in (elements or []) if w.get("project_id") == erp_project_id]
async def inventory_rows(self) -> list[dict]:
"""Normalized stock rows: [{warehouse_id, material_id, quantity}].
ERP returns nested objects (warehouse: {...}, material: {...}) plus a
numeric `quantity_number` — flatten to the three fields PM needs.
"""
rows: list[dict] = []
page = 1
while True:
data = await self.get(f"/api/v1/inventory?page_size=100&page={page}")
elements = data.get("data", []) if isinstance(data, dict) else []
for r in elements:
warehouse = r.get("warehouse") or {}
material = r.get("material") or {}
qty = r.get("quantity_number")
if qty is None:
qty = r.get("quantity")
rows.append(
{
"warehouse_id": warehouse.get("id"),
"material_id": material.get("id"),
"quantity": float(qty or 0),
}
)
meta = data.get("meta", {}) if isinstance(data, dict) else {}
if not elements or page * 100 >= int(meta.get("total") or len(rows)):
break
page += 1
return rows
erp_client = ERPClient()
+79
View File
@@ -0,0 +1,79 @@
"""PM — Project Management operational layer. FastAPI entrypoint."""
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy import select
from app.config import settings
from app.db import SessionFactory, engine
from app.dev_accounts import ensure_demo_accounts, ensure_demo_assignments
from app.errors import install_error_handlers
from app.models import Base, PMUser
from app.routers import auth, dashboard, files, projects, qc, tasks, users
from app.security import hash_password
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING)
async def _ensure_admin() -> None:
"""Seed the local admin user (role: project_manager) from env on first boot."""
async with SessionFactory() as db:
existing = await db.scalar(select(PMUser).where(PMUser.username == settings.ADMIN_USER))
if existing is None:
db.add(
PMUser(
username=settings.ADMIN_USER,
password_hash=hash_password(settings.ADMIN_PASSWORD),
display_name="مدیر سیستم",
role="project_manager",
)
)
await db.commit()
logging.getLogger("pm.boot").info("seeded admin user %s", settings.ADMIN_USER)
# dev role switcher: demo accounts for the dashboard picker
await ensure_demo_accounts(db)
@asynccontextmanager
async def lifespan(_: FastAPI):
if settings.DEV_ROLE_SWITCH:
logging.getLogger("pm.boot").warning(
"PM_DEV_ROLE_SWITCH is ON — /auth/dev-login issues tokens without a password. "
"Set PM_DEV_ROLE_SWITCH=false for production."
)
async with engine.begin() as conn:
from app.schema_sync import drop_legacy, ensure_schema
await drop_legacy(conn)
await conn.run_sync(Base.metadata.create_all)
await ensure_schema(conn)
await _ensure_admin()
asyncio.create_task(_init_demo_assignments())
yield
async def _init_demo_assignments() -> None:
await asyncio.sleep(1)
try:
async with SessionFactory() as db:
await ensure_demo_assignments(db)
except Exception: # noqa: BLE001
logging.getLogger("pm.boot").exception("demo assignment failed")
app = FastAPI(title="PM — Construction Project Management Platform", version="1.0.0", lifespan=lifespan)
API_V1_PREFIX = "/api/v1"
for module in (auth, users, dashboard, projects, tasks, qc, files):
app.include_router(module.router, prefix=API_V1_PREFIX)
@app.get("/health")
async def health():
return {"status": "ok", "service": "pm-api"}
install_error_handlers(app)
+446
View File
@@ -0,0 +1,446 @@
"""PM data model — Native Construction Project Management Platform.
- Project and Task entities are owned directly by PM with hierarchical WBS,
CPM scheduling, dependencies, and milestones.
- QC domain (templates, definitions, inspections, results, defects) is PM-owned.
- Material requirements and reservations integrate directly with ERP stock.
"""
import enum
import uuid
from datetime import date, datetime
from sqlalchemy import (
JSON,
Boolean,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
def _uuid() -> uuid.UUID:
return uuid.uuid4()
# --- Native PM Platform Models -----------------------------------------------
class ProjectStatus(str, enum.Enum):
PLANNED = "PLANNED"
ACTIVE = "ACTIVE"
ON_HOLD = "ON_HOLD"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskStatus(str, enum.Enum):
TODO = "TODO"
IN_PROGRESS = "IN_PROGRESS"
BLOCKED = "BLOCKED"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class TaskPriority(str, enum.Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class TaskRelationType(str, enum.Enum):
FS = "FS" # Finish-to-Start (پایان به شروع)
SS = "SS" # Start-to-Start (شروع به شروع)
FF = "FF" # Finish-to-Finish (پایان به پایان)
SF = "SF" # Start-to-Finish (شروع به پایان)
class Project(Base):
"""Native Construction Project entity owned directly by PM."""
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255), index=True)
description: Mapped[str] = mapped_column(Text, default="")
location: Mapped[str] = mapped_column(String(255), default="")
project_manager: Mapped[str] = mapped_column(String(255), default="")
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(
String(32),
default=ProjectStatus.ACTIVE.value,
server_default=ProjectStatus.ACTIVE.value,
index=True,
)
progress: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
members: Mapped[list["ProjectMember"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
tasks: Mapped[list["Task"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
relations: Mapped[list["TaskRelation"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
class ProjectMember(Base):
"""Team members assigned to a project with designated roles."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
role: Mapped[str] = mapped_column(String(32), default="site_engineer")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="members")
user: Mapped["PMUser"] = relationship(lazy="selectin")
class Task(Base):
"""Hierarchical Construction Task / Activity entity owned directly by PM.
Supports arbitrary depth: Project -> Phase -> Activity -> Subtask through self-referencing `parent_id`.
"""
__tablename__ = "tasks"
__table_args__ = (
Index("ix_tasks_project", "project_id"),
Index("ix_tasks_parent", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True, index=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(
String(32),
default=TaskStatus.TODO.value,
server_default=TaskStatus.TODO.value,
index=True,
)
priority: Mapped[str] = mapped_column(
String(32),
default=TaskPriority.MEDIUM.value,
server_default=TaskPriority.MEDIUM.value,
)
assignee_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="SET NULL"), nullable=True
)
assignee_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
planned_start: Mapped[date | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[date | None] = mapped_column(Date, nullable=True)
duration: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
qc_status: Mapped[str] = mapped_column(
String(32),
default="none",
server_default="none",
comment="none|pre_qc_pending|pre_qc_failed|ready|exec_qc_pending|in_progress|qc_failed_rework|final_passed",
)
human_resources: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
materials: Mapped[list[dict]] = mapped_column(
JSON, default=list, server_default="[]"
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
project: Mapped["Project"] = relationship(back_populates="tasks", lazy="selectin")
parent: Mapped["Task | None"] = relationship(
back_populates="children", remote_side=[id], lazy="selectin"
)
children: Mapped[list["Task"]] = relationship(
back_populates="parent", cascade="all, delete-orphan", lazy="selectin"
)
assignee: Mapped["PMUser | None"] = relationship(lazy="selectin")
outgoing_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.from_task_id]",
back_populates="from_task",
cascade="all, delete-orphan",
)
incoming_relations: Mapped[list["TaskRelation"]] = relationship(
foreign_keys="[TaskRelation.to_task_id]",
back_populates="to_task",
cascade="all, delete-orphan",
)
progress_logs: Mapped[list["TaskProgressLog"]] = relationship(
back_populates="task", cascade="all, delete-orphan", lazy="selectin"
)
class TaskProgressLog(Base):
"""ثبت گزارش پیشرفت مقطعی/روزانه وظیفه به همراه مصرف واقعی نیروی انسانی و مصالح."""
__tablename__ = "task_progress_logs"
__table_args__ = (
Index("ix_task_progress_logs_task", "task_id"),
Index("ix_task_progress_logs_created", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
progress_before: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
progress_after: Mapped[int] = mapped_column(Integer)
human_resources_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
materials_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]")
description: Mapped[str] = mapped_column(Text, default="", server_default="")
logged_by: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
task: Mapped["Task"] = relationship(back_populates="progress_logs")
class TaskRelation(Base):
"""Dependency / precedence between two tasks in a project (FS, SS, FF, SF + lag)."""
__tablename__ = "task_relations"
__table_args__ = (
UniqueConstraint("from_task_id", "to_task_id", name="uq_task_relations_pair"),
Index("ix_task_relations_project", "project_id"),
Index("ix_task_relations_from", "from_task_id"),
Index("ix_task_relations_to", "to_task_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
from_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
to_task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
relation_type: Mapped[str] = mapped_column(
String(8), default=TaskRelationType.FS.value, server_default=TaskRelationType.FS.value
)
lag_days: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
project: Mapped["Project"] = relationship(back_populates="relations")
from_task: Mapped["Task"] = relationship(
foreign_keys=[from_task_id], back_populates="outgoing_relations"
)
to_task: Mapped["Task"] = relationship(
foreign_keys=[to_task_id], back_populates="incoming_relations"
)
# --- PM-owned QC domain ------------------------------------------------------
class QCQuestionPhase(str, enum.Enum):
"""مرحله‌ای که یک سوال QC به آن تعلق دارد.
pre = پیش از اجرا (آمادگی شروع کار)
during = هنگام اجرا
after = پس از اجرا
"""
pre = "pre"
during = "during"
after = "after"
class QCAnswer(str, enum.Enum):
pass_ = "pass"
fail = "fail"
na = "na"
class QCTemplate(Base):
__tablename__ = "qc_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String(255))
activity_type: Mapped[str] = mapped_column(String(64), default="", index=True)
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
questions: Mapped[list["QCQuestion"]] = relationship(
back_populates="template", cascade="all, delete-orphan", order_by="QCQuestion.sort_order"
)
class QCQuestion(Base):
__tablename__ = "qc_questions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("qc_templates.id", ondelete="CASCADE"),
index=True,
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
template: Mapped[QCTemplate] = relationship(back_populates="questions")
class TaskQCDefinition(Base):
__tablename__ = "task_qc_definitions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(255), default="")
# provenance only — deleting/editing the template never affects this copy
source_template_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
questions: Mapped[list["TaskQCQuestion"]] = relationship(
back_populates="definition",
cascade="all, delete-orphan",
order_by="TaskQCQuestion.sort_order",
)
class TaskQCQuestion(Base):
__tablename__ = "task_qc_questions"
__table_args__ = (Index("ix_task_qc_q_def", "definition_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
definition_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_definitions.id", ondelete="CASCADE")
)
phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase"))
text: Mapped[str] = mapped_column(String(512))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
required: Mapped[bool] = mapped_column(Boolean, default=True)
definition: Mapped[TaskQCDefinition] = relationship(back_populates="questions")
results: Mapped[list["TaskQCResult"]] = relationship(
back_populates="question", cascade="all, delete-orphan", order_by="TaskQCResult.evaluated_at"
)
class TaskQCResult(Base):
"""Evaluation of ONE task-specific question — append-only history.
The current answer is the latest row for the question; keeping history is
what makes rework → re-inspection → pass traceable.
"""
__tablename__ = "task_qc_results"
__table_args__ = (Index("ix_task_qc_res_q", "question_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
question_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="CASCADE")
)
answer: Mapped[QCAnswer] = mapped_column(Enum(QCAnswer, name="qc_answer"))
comment: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
evaluated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
evaluated_by: Mapped[str] = mapped_column(String(255), default="")
question: Mapped[TaskQCQuestion] = relationship(back_populates="results")
class Defect(Base):
"""عیب ثبت‌شده روی یک تسک (اختیاری متصل به سؤال QC مردود)."""
__tablename__ = "defects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True
)
question_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(String(512))
description: Mapped[str] = mapped_column(Text, default="")
severity: Mapped[str] = mapped_column(String(16), default="medium", server_default="medium")
status: Mapped[str] = mapped_column(String(16), default="open", server_default="open")
corrective_action: Mapped[str] = mapped_column(Text, default="")
resolution_notes: Mapped[str] = mapped_column(Text, default="")
photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# --- Auth & access control ------------------------------------------------------
class PMUser(Base):
__tablename__ = "pm_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(255), default="")
# Values live in app/authz.py (Role); stored as text so new roles need no
# Postgres enum migration.
role: Mapped[str] = mapped_column(
String(32),
default="project_manager",
server_default="project_manager",
comment="project_manager|project_supervisor",
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ProjectAssignment(Base):
"""Projects a scoped role may access (e.g. a supervisor's assigned projects).
Unscoped roles (project_manager) see every project and need no rows here;
scoped roles are limited to exactly the rows present for them. Source of
truth for the scoping done in app/authz.py.
"""
__tablename__ = "project_assignments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+276
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""PM API routers."""
+98
View File
@@ -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)}}
+113
View File
@@ -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}
+54
View File
@@ -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("فایل یافت نشد")
+601
View File
@@ -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}}
+729
View File
@@ -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