From b82c039265e64a2049a5befc70119ecdc2c9ec3d Mon Sep 17 00:00:00 2001 From: fatemeh sane Date: Thu, 8 Oct 2026 11:18:47 +0330 Subject: [PATCH] feat(dashboard): reconstruct backend router/service, register route, fix suppliers NameError --- backend/app/routers/__init__.py | 2 + backend/app/routers/dashboard.py | 217 +++++++ backend/app/routers/supplier_materials.py | 19 - backend/app/services/dashboard_service.py | 658 ++++++++++++++++++++++ 4 files changed, 877 insertions(+), 19 deletions(-) create mode 100644 backend/app/routers/dashboard.py create mode 100644 backend/app/services/dashboard_service.py diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index 79fda88..0f3b761 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -4,6 +4,7 @@ from app.routers import ( auth, brand_materials, brands, + dashboard, drafts, files, inventory, @@ -53,6 +54,7 @@ ROUTERS = [ inventory.router, pm.router, project_transfers.router, + dashboard.router, users.router, drafts.router, ] diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..fe82041 --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,217 @@ +"""MVP dashboard router — read-only aggregations for the warehouse + +procurement dashboard widgets consumed by the frontend `apiV26.dashboard` +client (frontend/src/lib/api.ts). + +Scope (same resolution pattern as the inventory / pm routers): +- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N); + an explicit ?project_id must be one of them. +- role 'admin' / 'procurement_manager' → whole organization; ?project_id is + an optional filter. +- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when + PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the + inventory router). + +Endpoints: +- GET /dashboard/filters — «What» cascade options +- GET /dashboard/inventory/drilldown — org → project → warehouse tree +- GET /dashboard/inventory/dead-stock — dormant stock table +- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance +- GET /dashboard/daily-control — today's tickets + performers +- GET /dashboard/procurement/workload — workload per project (density) + +Every response is the standard `{"data": …}` envelope. +""" +import uuid +from dataclasses import dataclass +from datetime import date as date_cls +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import UserContext, get_optional_user +from app.config import settings +from app.db.engine import get_db +from app.services import dashboard_service +from app.utils.errors import ( + ForbiddenError, + UnauthorizedError, + ValidationError, +) + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +@dataclass +class DashboardScope: + role: str # "pm" | "office" + project_ids: list | None # PM: managed project ids; office: None = all + project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM) + + +async def dashboard_scope( + request: Request, + project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژه‌های خودش)"), + db: AsyncSession = Depends(get_db), +) -> DashboardScope: + """Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback.""" + if request.headers.get("authorization"): + user: UserContext | None = await get_optional_user(request, db) + if user is not None: + if user.role is None: + raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") + if user.role == "pm": + allowed = list(user.project_ids or []) + if project_id is not None: + if project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + return DashboardScope(role="pm", project_ids=allowed, project_id=project_id) + if not allowed: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + return DashboardScope(role="pm", project_ids=allowed, project_id=None) + if user.role in ("admin", "procurement_manager"): + return DashboardScope(role="office", project_ids=None, project_id=project_id) + raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است") + + if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"): + raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید") + role = (request.headers.get("x-user-role") or "").strip().lower() + if role == "procurement_manager" or role == "admin": + role = "office" + elif role == "pm": + role = "pm" + else: + raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role") + if role == "pm": + raw = request.headers.get("x-project-id") + if not raw: + raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id") + try: + if project_id is None: + project_id = uuid.UUID(str(raw)) + except ValueError: + raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id") + return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id) + return DashboardScope(role="office", project_ids=None, project_id=project_id) + + +def _what_filters( + category_id: uuid.UUID | None, + material_id: uuid.UUID | None, + brand_id: uuid.UUID | None, + specs_hash: str | None, +) -> dict: + return { + "category_id": category_id, + "material_id": material_id, + "brand_id": brand_id, + "specs_hash": specs_hash, + } + + +@router.get("/filters") +async def dashboard_filters(db: AsyncSession = Depends(get_db)): + """Cascading options for the global filter bar (Category→Material→Brand→Specs).""" + return await dashboard_service.filter_options(db) + + +@router.get("/inventory/drilldown") +async def inventory_drilldown( + scope: DashboardScope = Depends(dashboard_scope), + db: AsyncSession = Depends(get_db), + category_id: uuid.UUID | None = Query(default=None), + material_id: uuid.UUID | None = Query(default=None), + brand_id: uuid.UUID | None = Query(default=None), + specs_hash: str | None = Query(default=None), +): + """Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates).""" + return await dashboard_service.inventory_drilldown( + db, + project_ids=scope.project_ids, + project_id=scope.project_id, + **_what_filters(category_id, material_id, brand_id, specs_hash), + ) + + +@router.get("/inventory/dead-stock") +async def dead_stock( + scope: DashboardScope = Depends(dashboard_scope), + db: AsyncSession = Depends(get_db), + idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"), + limit: int = Query(default=200, ge=1, le=1000), + category_id: uuid.UUID | None = Query(default=None), + material_id: uuid.UUID | None = Query(default=None), + brand_id: uuid.UUID | None = Query(default=None), + specs_hash: str | None = Query(default=None), +): + """Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود.""" + return await dashboard_service.dead_stock( + db, + project_ids=scope.project_ids, + project_id=scope.project_id, + idle_days=idle_days, + limit=limit, + **_what_filters(category_id, material_id, brand_id, specs_hash), + ) + + +@router.get("/inventory/flow") +async def inventory_flow( + scope: DashboardScope = Depends(dashboard_scope), + db: AsyncSession = Depends(get_db), + date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"), + date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"), + warehouse_id: uuid.UUID | None = Query(default=None), + category_id: uuid.UUID | None = Query(default=None), + material_id: uuid.UUID | None = Query(default=None), + brand_id: uuid.UUID | None = Query(default=None), + specs_hash: str | None = Query(default=None), +): + """Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده.""" + today = datetime.now(dashboard_service.TEHRAN).date() + date_from = date_from or (today - timedelta(days=29)) + date_to = date_to or (today + timedelta(days=1)) + if date_to <= date_from: + raise ValidationError("بازه‌ی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to") + if (date_to - date_from).days > 180: + raise ValidationError("بازه‌ی تاریخ حداکثر ۱۸۰ روز است", field="date_to") + return await dashboard_service.daily_flow( + db, + project_ids=scope.project_ids, + project_id=scope.project_id, + date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN), + date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN), + warehouse_id=warehouse_id, + **_what_filters(category_id, material_id, brand_id, specs_hash), + ) + + +@router.get("/daily-control") +async def daily_control( + scope: DashboardScope = Depends(dashboard_scope), + db: AsyncSession = Depends(get_db), + date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیش‌فرض امروز)"), + warehouse_id: uuid.UUID | None = Query(default=None), +): + """Daily control: today's outbound/return tickets + exact performer names.""" + day = date or datetime.now(dashboard_service.TEHRAN).date() + return await dashboard_service.daily_control( + db, + project_ids=scope.project_ids, + project_id=scope.project_id, + day=day, + warehouse_id=warehouse_id, + ) + + +@router.get("/procurement/workload") +async def procurement_workload( + scope: DashboardScope = Depends(dashboard_scope), + db: AsyncSession = Depends(get_db), +): + """Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژه‌ها.""" + return await dashboard_service.procurement_workload( + db, + project_ids=scope.project_ids, + project_id=scope.project_id, + ) diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index d796598..44677f2 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -122,25 +122,6 @@ async def delete_supplier_material( return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} -@router.get("/materials/{material_id}/suppliers") -async def list_material_suppliers( - material_id, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - - db: AsyncSession = Depends(get_db), -): - async with db.begin(): - await unlink_supplier_material(db, supplier_id, material_id) - return { - "data": { - "supplier_id": str(supplier_id), - "material_id": str(material_id), - "deleted": True, - } - } - - # --------------------------------------------------------------------------- # GET /materials/{material_id}/suppliers # --------------------------------------------------------------------------- diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..716af08 --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -0,0 +1,658 @@ +"""Aggregation queries behind the MVP dashboard (warehouse + procurement). + +Read-only service. Every function receives the resolved scope (PM → the list +of projects they manage; office roles → project_ids=None = whole +organization) plus an optional explicit project filter, and the optional +«What» filters (category_id / material_id / brand_id / specs_hash). + +Quantities are NEVER mixed across units: inventory rows live in the +material's canonical unit, so stock aggregates carry their unit code and the +UI picks one unit at a time. Numeric results leave the service chart-ready +(floats); identifiers and labels are plain strings. + +Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs) +exactly — this module is the server side of that contract. +""" +import uuid +from datetime import date as date_cls +from datetime import datetime, time, timedelta, timezone + +from sqlalchemy import case, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit +from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse +from app.models.procurement import ( + ItemDecision, + ItemReceipt, + LineItem, + Project, + Quote, + QuoteProjects, +) + +# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the +# daily widgets means today at this offset. +TEHRAN = timezone(timedelta(hours=3, minutes=30)) + +# Flow buckets: outbound = site consumption + shrinkage; waste is reported +# separately so the UI can stack it as a distinct red segment inside the +# outbound bar. Transfers are excluded from the buckets (they are internal +# moves) but DO count toward the running balance. +OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS") +WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS") + + +def _stock_filters(stmt, *, category_id=None, material_id=None, brand_id=None, specs_hash=None): + """«What» filters for a statement that already joins inventory→material.""" + if category_id is not None: + stmt = stmt.where(Material.category_id == category_id) + if material_id is not None: + stmt = stmt.where(InventoryItem.material_id == material_id) + if brand_id is not None: + stmt = stmt.where(InventoryItem.brand_id == brand_id) + if specs_hash: + stmt = stmt.where(InventoryItem.specs_hash == specs_hash) + return stmt + + +def _scope_filters(stmt, *, project_ids, project_id): + """warehouse.project_id restriction for the resolved dashboard scope.""" + if project_id is not None: + stmt = stmt.where(Warehouse.project_id == project_id) + elif project_ids is not None: + stmt = stmt.where(Warehouse.project_id.in_(project_ids)) + return stmt + + +def _qty(value) -> float: + return round(float(value), 4) if value is not None else 0.0 + + +# --------------------------------------------------------------------------- +# «What» filter cascade — Category → Material → Brand → Specs +# --------------------------------------------------------------------------- + +async def filter_options(db: AsyncSession) -> dict: + """Cascading options for the global filter bar. + + Categories/materials come from the catalog (active only). Brand options + per material are the UNION of the catalog links (brand_materials — every + brand defined for that material) and the distinct brands actually present + in stock, so the cascade is complete even for materials whose current + stock rows carry no brand yet. Spec options come from stock rows + (inventory.specs_hash), since specs are a stock-level dimension. + Materials without a category land in the synthetic «بدون دسته» bucket. + """ + categories = ( + await db.execute( + select(MaterialCategory) + .where(MaterialCategory.is_active.is_(True)) + .order_by(MaterialCategory.name) + ) + ).scalars().all() + materials = ( + await db.execute(select(Material).where(Material.status == "active").order_by(Material.name)) + ).scalars().all() + units = {u.id: u for u in (await db.execute(select(Unit))).scalars()} + + inv_rows = ( + await db.execute( + select( + InventoryItem.material_id, + InventoryItem.brand_id, + InventoryItem.specs_hash, + InventoryItem.specs, + Brand.name, + ) + .select_from(InventoryItem) + .outerjoin(Brand, Brand.id == InventoryItem.brand_id) + .distinct() + ) + ).all() + # برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی + catalog_brand_rows = ( + await db.execute( + select(BrandMaterial.material_id, Brand.id, Brand.name) + .select_from(BrandMaterial) + .join(Brand, Brand.id == BrandMaterial.brand_id) + .where(BrandMaterial.is_active.is_(True)) + ) + ).all() + + brands_by_material: dict[uuid.UUID, dict] = {} + specs_by_material: dict[uuid.UUID, dict[str, dict]] = {} + for material_id, brand_id, brand_name in catalog_brand_rows: + brands_by_material.setdefault(material_id, {})[brand_id] = brand_name + for material_id, brand_id, specs_hash, specs, brand_name in inv_rows: + if brand_id is not None: + brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name) + if specs_hash: + specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {} + + def material_entry(m: Material) -> dict: + unit = units.get(m.canonical_unit_id) + brands = brands_by_material.get(m.id, {}) + spec_map = specs_by_material.get(m.id, {}) + return { + "id": str(m.id), + "name": m.name, + "unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None, + "brands": [ + {"id": str(bid), "name": bname} + for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "") + ], + "specs": [ + {"hash": h, "specs": sp} + for h, sp in sorted(spec_map.items()) + ], + } + + buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories} + buckets.setdefault(None, []) + for m in materials: + buckets.setdefault(m.category_id, []).append(material_entry(m)) + + out = [ + {"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])} + for c in categories + ] + if buckets.get(None): + out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]}) + return {"data": {"categories": out}} + + +# --------------------------------------------------------------------------- +# Widget 1 — Drill-down matrix (organization → project → warehouse) +# --------------------------------------------------------------------------- + +async def inventory_drilldown( + db: AsyncSession, + *, + project_ids, + project_id, + category_id=None, + material_id=None, + brand_id=None, + specs_hash=None, +) -> dict: + """Stock quantity as a three-level tree; each aggregate keyed by unit code + so incompatible units never merge into one number.""" + stmt = ( + select( + Project.id, + Project.name, + Warehouse.id, + Warehouse.name, + Unit.code, + Unit.name_fa, + func.sum(InventoryItem.quantity).label("qty"), + func.count().label("rows"), + ) + .select_from(InventoryItem) + .join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) + .join(Project, Project.id == Warehouse.project_id) + .join(Material, Material.id == InventoryItem.material_id) + .join(Unit, Unit.id == InventoryItem.unit_id) + .where(InventoryItem.quantity > 0) + .group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa) + .order_by(Project.name, Warehouse.name) + ) + stmt = _stock_filters( + stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash + ) + stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id) + rows = (await db.execute(stmt)).all() + + units_legend: dict[str, str] = {} + total_by_unit: dict[str, float] = {} + total_rows = 0 + projects_tree: dict[uuid.UUID, dict] = {} + for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows: + q = _qty(qty) + units_legend[ucode] = uname_fa + total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4) + total_rows += row_count + proj = projects_tree.setdefault( + pid, + {"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}}, + ) + proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4) + proj["rows"] += row_count + wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0}) + wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4) + wh["rows"] += row_count + + projects = [] + for proj in projects_tree.values(): + warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"]) + projects.append({**proj, "warehouses": warehouses}) + projects.sort(key=lambda p: p["name"]) + + return { + "data": { + "units": units_legend, + "total": {"qty_by_unit": total_by_unit, "rows": total_rows}, + "projects": projects, + } + } + + +# --------------------------------------------------------------------------- +# Widget 2 — Dead stock (dormant capital) +# --------------------------------------------------------------------------- + +async def dead_stock( + db: AsyncSession, + *, + project_ids, + project_id, + idle_days: int = 60, + category_id=None, + material_id=None, + brand_id=None, + specs_hash=None, + limit: int = 200, +) -> dict: + """Stock rows with quantity > 0 and NO ledger activity within `idle_days`. + + Last activity = MAX(inventory_transactions.transaction_date) per row — + served by idx_inventory_tx_inventory_date — falling back to + inventory.updated_at for rows that never posted a transaction. + """ + last_tx = ( + select(func.max(InventoryTransaction.transaction_date)) + .where(InventoryTransaction.inventory_id == InventoryItem.id) + .correlate(InventoryItem) + .scalar_subquery() + ) + last_activity = func.coalesce(last_tx, InventoryItem.updated_at) + cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days) + + stmt = ( + select( + InventoryItem.id, + InventoryItem.quantity, + InventoryItem.specs, + Material.name, + Brand.name, + Warehouse.name, + Project.name, + Unit.code, + Unit.name_fa, + last_activity.label("last_activity"), + ) + .select_from(InventoryItem) + .join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) + .join(Project, Project.id == Warehouse.project_id) + .join(Material, Material.id == InventoryItem.material_id) + .join(Unit, Unit.id == InventoryItem.unit_id) + .outerjoin(Brand, Brand.id == InventoryItem.brand_id) + .where(InventoryItem.quantity > 0, last_activity <= cutoff) + .order_by(last_activity.asc()) + .limit(limit) + ) + stmt = _stock_filters( + stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash + ) + stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id) + rows = (await db.execute(stmt)).all() + + now = datetime.now(TEHRAN) + items = [] + for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows: + items.append({ + "inventory_id": str(inv_id), + "material": mat_name, + "brand": brand_name, + "specs": specs or {}, + "warehouse": wh_name, + "project": proj_name, + "quantity": _qty(quantity), + "unit": {"code": ucode, "name_fa": uname_fa}, + "last_activity": last.isoformat() if last else None, + "idle_days": (now - last).days if last else None, + }) + return {"data": {"idle_days_threshold": idle_days, "items": items}} + + +# --------------------------------------------------------------------------- +# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance) +# --------------------------------------------------------------------------- + +async def daily_flow( + db: AsyncSession, + *, + project_ids, + project_id, + date_from: datetime, + date_to: datetime, + warehouse_id=None, + category_id=None, + material_id=None, + brand_id=None, + specs_hash=None, +) -> dict: + """Per-day buckets from the signed ledger. + + inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste + (DAMAGE/WASTE/LOSS, reported separately for the stacked red segment). + balance = running sum of EVERY signed movement (transfers/found/stock + counts included), seeded with the opening balance before `date_from`. + """ + def _with_joins(sel): + return ( + sel.select_from(InventoryTransaction) + .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) + .join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id) + .join(Material, Material.id == InventoryItem.material_id) + ) + + scope_conditions = [] + if project_id is not None: + scope_conditions.append(Warehouse.project_id == project_id) + elif project_ids is not None: + scope_conditions.append(Warehouse.project_id.in_(project_ids)) + if warehouse_id is not None: + scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id) + what_conditions = [Material.category_id == category_id] if category_id is not None else [] + if material_id is not None: + what_conditions.append(InventoryItem.material_id == material_id) + if brand_id is not None: + what_conditions.append(InventoryItem.brand_id == brand_id) + if specs_hash: + what_conditions.append(InventoryItem.specs_hash == specs_hash) + + day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day") + bucket_rows = ( + await db.execute( + _with_joins( + select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity)) + .where( + InventoryTransaction.transaction_date >= date_from, + InventoryTransaction.transaction_date < date_to, + *scope_conditions, + *what_conditions, + ) + .group_by(day_expr, InventoryTransaction.transaction_type) + ) + ) + ).all() + + opening = _qty( + await db.scalar( + _with_joins( + select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where( + InventoryTransaction.transaction_date < date_from, + *scope_conditions, + *what_conditions, + ) + ) + ) + ) + + signed_by_day: dict[date_cls, dict[str, float]] = {} + for day, tx_type, signed in bucket_rows: + day_key = day.date() if isinstance(day, datetime) else day + signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed) + + days = [] + running = opening + current = date_from.date() + while current < date_to.date(): + types = signed_by_day.get(current, {}) + waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0 + outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0 + running = round(running + sum(types.values()), 4) + days.append({ + "date": current.isoformat(), + "inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0, + "outbound": outbound, + "waste": waste, + "returns": round(types.get("RETURN", 0.0), 4) or 0.0, + "balance": running, + }) + current += timedelta(days=1) + + return {"data": {"opening_balance": opening, "days": days}} + + +# --------------------------------------------------------------------------- +# Widget 4 — Daily control (today's site operations) +# --------------------------------------------------------------------------- + +async def daily_control( + db: AsyncSession, + *, + project_ids, + project_id, + day: date_cls, + warehouse_id=None, +) -> dict: + """One day (Tehran calendar): outbound / return / waste / purchase ticket + counts + quantities, the exact performer names, and the operation detail.""" + day_start = datetime.combine(day, time.min, tzinfo=TEHRAN) + day_end = day_start + timedelta(days=1) + + scope_conditions = [] + if project_id is not None: + scope_conditions.append(Warehouse.project_id == project_id) + elif project_ids is not None: + scope_conditions.append(Warehouse.project_id.in_(project_ids)) + if warehouse_id is not None: + scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id) + + count_rows = ( + await db.execute( + select( + InventoryTransaction.transaction_type, + func.count().label("tickets"), + func.sum(func.abs(InventoryTransaction.quantity)).label("qty"), + ) + .select_from(InventoryTransaction) + .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) + .where( + InventoryTransaction.transaction_date >= day_start, + InventoryTransaction.transaction_date < day_end, + *scope_conditions, + ) + .group_by(InventoryTransaction.transaction_type) + ) + ).all() + by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows} + + performers_rows = ( + await db.execute( + select( + InventoryTransaction.performed_by, + func.count().label("tickets"), + func.sum(func.abs(InventoryTransaction.quantity)).label("qty"), + ) + .select_from(InventoryTransaction) + .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) + .where( + InventoryTransaction.transaction_date >= day_start, + InventoryTransaction.transaction_date < day_end, + *scope_conditions, + ) + .group_by(InventoryTransaction.performed_by) + .order_by(func.count().desc()) + ) + ).all() + + detail_rows = ( + await db.execute( + select( + InventoryTransaction, + Warehouse.name, + Material.name, + InventoryItem.specs, + Unit.code, + ) + .select_from(InventoryTransaction) + .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) + .join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id) + .join(Material, Material.id == InventoryItem.material_id) + .join(Unit, Unit.id == InventoryItem.unit_id) + .where( + InventoryTransaction.transaction_date >= day_start, + InventoryTransaction.transaction_date < day_end, + InventoryTransaction.transaction_type.in_( + ("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE") + ), + *scope_conditions, + ) + .order_by(InventoryTransaction.transaction_date.desc()) + .limit(200) + ) + ).all() + + zero = {"tickets": 0, "qty": 0.0} + items = [] + for tx, wh_name, mat_name, specs, ucode in detail_rows: + items.append({ + "id": str(tx.id), + "transaction_type": str(tx.transaction_type), + "quantity": _qty(tx.quantity), + "unit_code": ucode, + "performed_by": tx.performed_by, + "note": tx.note, + "warehouse": wh_name, + "material": mat_name, + "specs": specs or {}, + "transaction_date": tx.transaction_date.isoformat(), + }) + + return { + "data": { + "date": day.isoformat(), + "outbound": by_type.get("WITHDRAWAL", zero), + "returns": by_type.get("RETURN", zero), + "waste": { + "tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES), + "qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4), + }, + "purchases": by_type.get("PURCHASE", zero), + "performers": [ + {"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)} + for name, tickets, qty in performers_rows + ], + "items": items, + } + } + + +# --------------------------------------------------------------------------- +# Widget 8 — Procurement workload per project (project density) +# --------------------------------------------------------------------------- + +async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict: + """Per project: offered quotes still open + chosen purchase lines (open + vs fully received). Chosen lines attribute to item_decisions.project_id + (the purchased destination); offered quotes to any linked project.""" + received_sq = ( + select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty")) + .group_by(ItemReceipt.item_decision_id) + .subquery() + ) + order_conditions = [ItemDecision.status == "chosen"] + if project_id is not None: + order_conditions.append(ItemDecision.project_id == project_id) + elif project_ids is not None: + order_conditions.append(ItemDecision.project_id.in_(project_ids)) + + order_rows = ( + await db.execute( + select( + Project.id, + Project.name, + func.count().label("chosen_items"), + func.sum(LineItem.quantity).label("ordered_qty"), + func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"), + func.sum( + case( + (func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1), + else_=0, + ) + ).label("open_items"), + ) + .select_from(ItemDecision) + .join(LineItem, LineItem.id == ItemDecision.line_item_id) + .join(Quote, Quote.id == LineItem.quote_id) + .outerjoin(Project, Project.id == ItemDecision.project_id) + .outerjoin(received_sq, received_sq.c.did == ItemDecision.id) + .where(*order_conditions) + .group_by(Project.id, Project.name) + ) + ).all() + + offered_by_project: dict[uuid.UUID | None, set] = {} + offered_primary = ( + await db.execute( + select(Quote.id, Quote.project_id).where( + Quote.status == "offered", Quote.project_id.is_not(None) + ) + ) + ).all() + for quote_id, pid in offered_primary: + offered_by_project.setdefault(pid, set()).add(quote_id) + offered_junction = ( + await db.execute( + select(QuoteProjects.quote_id, QuoteProjects.project_id) + .join(Quote, Quote.id == QuoteProjects.quote_id) + .where(Quote.status == "offered") + ) + ).all() + for quote_id, pid in offered_junction: + offered_by_project.setdefault(pid, set()).add(quote_id) + + project_ids_needed = {row[0] for row in order_rows if row[0] is not None} + project_ids_needed.update(pid for pid in offered_by_project if pid is not None) + names: dict[uuid.UUID, str] = {} + if project_ids_needed: + names = { + p.id: p.name + for p in ( + await db.execute(select(Project).where(Project.id.in_(project_ids_needed))) + ).scalars() + } + + projects = [] + for pid, _pname, chosen, ordered, received, open_items in order_rows: + if pid is None: + offered = 0 + else: + offered = len(offered_by_project.pop(pid, set())) + if project_id is not None and pid != project_id: + offered = 0 + elif project_ids is not None and pid not in project_ids: + offered = 0 + projects.append({ + "id": str(pid) if pid else None, + "name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه", + "offered_quotes": offered, + "chosen_items": chosen, + "open_orders": open_items, + "ordered_qty": _qty(ordered), + "received_qty": _qty(received), + }) + # offered-only projects that have no chosen lines yet + for pid, quote_ids in offered_by_project.items(): + if not quote_ids: + continue + if project_id is not None and pid != project_id: + continue + if project_ids is not None and (pid is None or pid not in project_ids): + continue + projects.append({ + "id": str(pid) if pid else None, + "name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه", + "offered_quotes": len(quote_ids), + "chosen_items": 0, + "open_orders": 0, + "ordered_qty": 0.0, + "received_qty": 0.0, + }) + + projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"])) + return {"data": {"projects": projects}}