feat(dashboard): implement floating waterfall inventory bridge with step-line balance
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user