update dashboard logic

This commit is contained in:
fatemeh sane
2026-10-13 21:49:22 +03:30
parent 34b987398a
commit 3d16c435d0
4 changed files with 566 additions and 182 deletions
+92 -21
View File
@@ -19,6 +19,7 @@ from datetime import datetime, time, timedelta, timezone
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.inventory import InventoryItem, InventoryTransaction, Warehouse
@@ -176,34 +177,91 @@ async def inventory_drilldown(
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse."""
stmt = (
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
warehouse, unit) stock aggregation. Active catalog materials without any
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
(labelled with their canonical unit) instead of disappearing in an inner
join.
"""
# تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
# راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
stock = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
InventoryItem.specs,
InventoryItem.material_id.label("material_id"),
InventoryItem.brand_id.label("brand_id"),
InventoryItem.specs_hash.label("specs_hash"),
InventoryItem.specs.label("specs"),
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code,
Unit.name_fa,
Unit.code.label("ucode"),
Unit.name_fa.label("uname_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)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.group_by(Material.id, Material.name, Brand.name, InventoryItem.specs, Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
.group_by(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
)
)
stmt = _stock_filters(
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
stock_sq = stock.subquery()
# واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد»)
canon_unit = aliased(Unit)
stmt = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
stock_sq.c.specs,
stock_sq.c.pid,
stock_sq.c.pname,
stock_sq.c.wid,
stock_sq.c.wname,
stock_sq.c.ucode,
stock_sq.c.uname_fa,
func.coalesce(stock_sq.c.qty, 0).label("qty"),
func.coalesce(stock_sq.c.rows, 0).label("rows"),
canon_unit.code.label("canon_ucode"),
canon_unit.name_fa.label("canon_uname_fa"),
)
.select_from(Material)
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
.where(Material.status == "active")
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
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)
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
# موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده)
if brand_id is not None:
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
@@ -212,12 +270,18 @@ async def inventory_drilldown(
materials_tree: dict[str, dict] = {}
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
q = _qty(qty)
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
units_legend.setdefault(canon_ucode, canon_uname_fa)
total_by_unit.setdefault(canon_ucode, 0.0)
total_rows += row_count
# Build full material name
parts = [mname]
if bname:
@@ -227,14 +291,21 @@ async def inventory_drilldown(
full_name = " ".join(parts)
# Use full_name + unit as unique key for material level, or just full_name
mat_key = f"{mid}_{full_name}"
mat = materials_tree.setdefault(
mat_key,
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
)
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
if ucode is not None:
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
mat["rows"] += row_count
# مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار)
if pid is None:
continue
proj = mat["_projects"].setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},