feat(dashboard): implement floating waterfall inventory bridge with step-line balance

This commit is contained in:
fatemeh sane
2026-10-15 15:21:08 +03:30
parent eedb821e3b
commit 692343423c
8 changed files with 492 additions and 230 deletions
+4 -9
View File
@@ -96,13 +96,11 @@ async def dashboard_scope(
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,
@@ -111,7 +109,7 @@ def _what_filters(
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Cascading options for the global filter bar (Category→Material→Brand→Specs)."""
"""Options for the global filter bar (Material→Brand→Specs)."""
return await dashboard_service.filter_options(db)
@@ -119,7 +117,6 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
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),
@@ -129,7 +126,7 @@ async def inventory_drilldown(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
**_what_filters(category_id, material_id, brand_id, specs_hash),
**_what_filters(material_id, brand_id, specs_hash),
)
@@ -139,7 +136,6 @@ async def dead_stock(
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),
@@ -151,7 +147,7 @@ async def dead_stock(
project_id=scope.project_id,
idle_days=idle_days,
limit=limit,
**_what_filters(category_id, material_id, brand_id, specs_hash),
**_what_filters(material_id, brand_id, specs_hash),
)
@@ -162,7 +158,6 @@ async def inventory_flow(
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),
@@ -182,7 +177,7 @@ async def inventory_flow(
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),
**_what_filters(material_id, brand_id, specs_hash),
)
+14 -39
View File
@@ -3,7 +3,7 @@
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).
«What» filters (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
@@ -21,7 +21,7 @@ from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
from app.models.catalog import Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
@@ -44,10 +44,8 @@ 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):
def _stock_filters(stmt, *, 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:
@@ -75,23 +73,15 @@ def _qty(value) -> float:
# ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict:
"""Cascading options for the global filter bar.
"""Options for the global filter bar (Material → Brand → Specs).
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.
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.
"""
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()
@@ -149,18 +139,8 @@ async def filter_options(db: AsyncSession) -> dict:
],
}
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}}
# فهرست تخت متریال‌ها — سرآغاز آبشار سه‌فیلتری (متریال → برند → مشخصات)
return {"data": {"materials": [material_entry(m) for m in materials]}}
# ---------------------------------------------------------------------------
@@ -172,7 +152,6 @@ async def inventory_drilldown(
*,
project_ids,
project_id,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
@@ -251,8 +230,6 @@ async def inventory_drilldown(
.where(Material.status == "active")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
if category_id is not None:
stmt = stmt.where(Material.category_id == category_id)
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
@@ -347,7 +324,6 @@ async def dead_stock(
project_ids,
project_id,
idle_days: int = 60,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
@@ -392,7 +368,7 @@ async def dead_stock(
.limit(limit)
)
stmt = _stock_filters(
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
stmt, 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()
@@ -427,7 +403,6 @@ async def daily_flow(
date_from: datetime,
date_to: datetime,
warehouse_id=None,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
@@ -454,7 +429,7 @@ async def daily_flow(
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 []
what_conditions = []
if material_id is not None:
what_conditions.append(InventoryItem.material_id == material_id)
if brand_id is not None: