5 Commits
10 changed files with 1902 additions and 480 deletions
+29 -16
View File
@@ -96,22 +96,35 @@ async def dashboard_scope(
def _what_filters( def _what_filters(
category_id: uuid.UUID | None, material_id: str | None,
material_id: uuid.UUID | None,
brand_id: uuid.UUID | None, brand_id: uuid.UUID | None,
specs_hash: str | None, specs_hash: str | None,
spec_id: str | None = None,
) -> dict: ) -> dict:
"""دامنه‌ی «چه چیزی» را به پارامترهای سرویس تبدیل می‌کند.
شناسه‌ی ماده اگر UUID معتبر باشد → دامنه‌ی همان ماده (همه‌ی مشخصات آن).
اگر مقدار ارسالی UUID نباشد (یعنی شناسه‌ی مشخصه فرستاده شده) یا spec_id
صریح بیاید → دامنه به همان مشخصه محدود می‌شود (specs_hash).
"""
resolved_material: uuid.UUID | None = None
resolved_specs = specs_hash or spec_id
if material_id:
try:
resolved_material = uuid.UUID(str(material_id))
except (ValueError, AttributeError, TypeError):
# شناسه‌ی مشخصه (نه UUID ماده) — به‌عنوان دامنه‌ی مشخصه استفاده می‌شود
resolved_specs = resolved_specs or str(material_id)
return { return {
"category_id": category_id, "material_id": resolved_material,
"material_id": material_id,
"brand_id": brand_id, "brand_id": brand_id,
"specs_hash": specs_hash, "specs_hash": resolved_specs,
} }
@router.get("/filters") @router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)): 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) return await dashboard_service.filter_options(db)
@@ -119,17 +132,17 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
async def inventory_drilldown( async def inventory_drilldown(
scope: DashboardScope = Depends(dashboard_scope), scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
category_id: uuid.UUID | None = Query(default=None), material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None), brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None), specs_hash: str | None = Query(default=None),
spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"),
): ):
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates).""" """Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
return await dashboard_service.inventory_drilldown( return await dashboard_service.inventory_drilldown(
db, db,
project_ids=scope.project_ids, project_ids=scope.project_ids,
project_id=scope.project_id, project_id=scope.project_id,
**_what_filters(category_id, material_id, brand_id, specs_hash), **_what_filters(material_id, brand_id, specs_hash, spec_id),
) )
@@ -139,10 +152,10 @@ async def dead_stock(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"), idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
limit: int = Query(default=200, ge=1, le=1000), limit: int = Query(default=200, ge=1, le=1000),
category_id: uuid.UUID | None = Query(default=None), material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None), brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None), specs_hash: str | None = Query(default=None),
spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"),
): ):
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود.""" """Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود."""
return await dashboard_service.dead_stock( return await dashboard_service.dead_stock(
@@ -151,7 +164,7 @@ async def dead_stock(
project_id=scope.project_id, project_id=scope.project_id,
idle_days=idle_days, idle_days=idle_days,
limit=limit, limit=limit,
**_what_filters(category_id, material_id, brand_id, specs_hash), **_what_filters(material_id, brand_id, specs_hash, spec_id),
) )
@@ -161,11 +174,11 @@ async def inventory_flow(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"), date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"),
date_to: 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), warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همه‌ی انبارها"),
category_id: uuid.UUID | None = Query(default=None), material_id: str | None = Query(default=None, description="UUID ماده یا شناسه‌ی مشخصه"),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None), brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None), specs_hash: str | None = Query(default=None),
spec_id: str | None = Query(default=None, description="دامنه‌ی یک مشخصه‌ی خاص"),
): ):
"""Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده.""" """Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده."""
today = datetime.now(dashboard_service.TEHRAN).date() today = datetime.now(dashboard_service.TEHRAN).date()
@@ -182,7 +195,7 @@ async def inventory_flow(
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN), 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), date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
warehouse_id=warehouse_id, warehouse_id=warehouse_id,
**_what_filters(category_id, material_id, brand_id, specs_hash), **_what_filters(material_id, brand_id, specs_hash, spec_id),
) )
+212 -70
View File
@@ -3,7 +3,7 @@
Read-only service. Every function receives the resolved scope (PM → the list Read-only service. Every function receives the resolved scope (PM → the list
of projects they manage; office roles → project_ids=None = whole of projects they manage; office roles → project_ids=None = whole
organization) plus an optional explicit project filter, and the optional 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 Quantities are NEVER mixed across units: inventory rows live in the
material's canonical unit, so stock aggregates carry their unit code and the material's canonical unit, so stock aggregates carry their unit code and the
@@ -19,8 +19,9 @@ from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession 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.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -43,10 +44,8 @@ OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
WASTE_TYPES = ("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.""" """«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: if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id) stmt = stmt.where(InventoryItem.material_id == material_id)
if brand_id is not None: if brand_id is not None:
@@ -74,23 +73,15 @@ def _qty(value) -> float:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict: 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 Materials come from the catalog (active only). Brand options per material
per material are the UNION of the catalog links (brand_materials — every are the UNION of the catalog links (brand_materials — every brand defined
brand defined for that material) and the distinct brands actually present for that material) and the distinct brands actually present in stock, so
in stock, so the cascade is complete even for materials whose current the cascade is complete even for materials whose current stock rows carry
stock rows carry no brand yet. Spec options come from stock rows no brand yet. Spec options come from stock rows (inventory.specs_hash),
(inventory.specs_hash), since specs are a stock-level dimension. 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 = ( materials = (
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name)) await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
).scalars().all() ).scalars().all()
@@ -148,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, []) return {"data": {"materials": [material_entry(m) for m in materials]}}
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}}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -171,69 +152,164 @@ async def inventory_drilldown(
*, *,
project_ids, project_ids,
project_id, project_id,
category_id=None,
material_id=None, material_id=None,
brand_id=None, brand_id=None,
specs_hash=None, specs_hash=None,
) -> dict: ) -> dict:
"""Stock quantity as a three-level tree; each aggregate keyed by unit code """Stock quantity as a three-level tree: Material -> Project -> Warehouse.
so incompatible units never merge into one number."""
stmt = ( 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( select(
Project.id, InventoryItem.material_id.label("material_id"),
Project.name, InventoryItem.brand_id.label("brand_id"),
Warehouse.id, InventoryItem.specs_hash.label("specs_hash"),
Warehouse.name, InventoryItem.specs.label("specs"),
Unit.code, Project.id.label("pid"),
Unit.name_fa, Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code.label("ucode"),
Unit.name_fa.label("uname_fa"),
func.sum(InventoryItem.quantity).label("qty"), func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"), func.count().label("rows"),
) )
.select_from(InventoryItem) .select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) .join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id) .join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id) .join(Unit, Unit.id == InventoryItem.unit_id)
.where(InventoryItem.quantity > 0) .group_by(
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa) InventoryItem.material_id,
.order_by(Project.name, Warehouse.name) 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
) )
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id) 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")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
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() rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {} units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {} total_by_unit: dict[str, float] = {}
total_rows = 0 total_rows = 0
projects_tree: dict[uuid.UUID, dict] = {}
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows: materials_tree: dict[str, dict] = {}
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) q = _qty(qty)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4) 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 total_rows += row_count
proj = projects_tree.setdefault(
# Build full material name
parts = [mname]
if bname:
parts.append(bname)
if specs:
parts.extend(str(v) for v in specs.values() if v)
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": {}},
)
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, pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}}, {"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["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0}) 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["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count wh["rows"] += row_count
projects = [] materials = []
for proj in projects_tree.values(): for mat in materials_tree.values():
projects_list = []
for proj in mat.pop("_projects").values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"]) warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects.append({**proj, "warehouses": warehouses}) projects_list.append({**proj, "warehouses": warehouses})
projects.sort(key=lambda p: p["name"]) projects_list.sort(key=lambda p: p["name"])
materials.append({**mat, "projects": projects_list})
materials.sort(key=lambda m: m["name"])
return { return {
"data": { "data": {
"units": units_legend, "units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows}, "total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"projects": projects, "materials": materials,
} }
} }
@@ -248,7 +324,6 @@ async def dead_stock(
project_ids, project_ids,
project_id, project_id,
idle_days: int = 60, idle_days: int = 60,
category_id=None,
material_id=None, material_id=None,
brand_id=None, brand_id=None,
specs_hash=None, specs_hash=None,
@@ -293,7 +368,7 @@ async def dead_stock(
.limit(limit) .limit(limit)
) )
stmt = _stock_filters( 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) stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
rows = (await db.execute(stmt)).all() rows = (await db.execute(stmt)).all()
@@ -328,7 +403,6 @@ async def daily_flow(
date_from: datetime, date_from: datetime,
date_to: datetime, date_to: datetime,
warehouse_id=None, warehouse_id=None,
category_id=None,
material_id=None, material_id=None,
brand_id=None, brand_id=None,
specs_hash=None, specs_hash=None,
@@ -339,14 +413,24 @@ async def daily_flow(
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment). (DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
balance = running sum of EVERY signed movement (transfers/found/stock balance = running sum of EVERY signed movement (transfers/found/stock
counts included), seeded with the opening balance before `date_from`. counts included), seeded with the opening balance before `date_from`.
Each day also carries a `breakdown` list — the ledger rows behind that
day's aggregates, grouped by (category, transaction type, specs, brand,
warehouse) — so the chart can subdivide categories into tonal shades and
the tooltip can itemize them. When a single material is selected, the
payload also carries that material's canonical `unit`.
""" """
def _with_joins(sel): def _with_joins(sel, *, include_brand: bool = False):
return ( # ترتیب join‌ها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
stmt = (
sel.select_from(InventoryTransaction) sel.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id) .join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id) .join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id) .join(Material, Material.id == InventoryItem.material_id)
) )
if include_brand:
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
return stmt
scope_conditions = [] scope_conditions = []
if project_id is not None: if project_id is not None:
@@ -355,7 +439,7 @@ async def daily_flow(
scope_conditions.append(Warehouse.project_id.in_(project_ids)) scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None: if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id) 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: if material_id is not None:
what_conditions.append(InventoryItem.material_id == material_id) what_conditions.append(InventoryItem.material_id == material_id)
if brand_id is not None: if brand_id is not None:
@@ -363,18 +447,38 @@ async def daily_flow(
if specs_hash: if specs_hash:
what_conditions.append(InventoryItem.specs_hash == specs_hash) what_conditions.append(InventoryItem.specs_hash == specs_hash)
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day") # «روز» بر مبنای تقویم تهران است؛ بدون این تبدیل، تراکنش‌های بامداد
bucket_rows = ( # (۰۰:۰۰ تا ۰۳:۳۰ تهران) به روز قبلِ UTC می‌افتادند.
day_expr = func.date_trunc(
"day", func.timezone("Asia/Tehran", InventoryTransaction.transaction_date)
).label("day")
# ریزتراکنش‌ها به‌ازای (نوع، مشخصات، برند، انبار) گروه می‌شوند تا هم تجمیع
# روزانه و هم شکست چندبعدی نمودار از یک کوئری واحد بیایند (بدون تکرار رکورد).
detail_rows = (
await db.execute( await db.execute(
_with_joins( _with_joins(
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity)) select(
day_expr,
InventoryTransaction.transaction_type,
InventoryItem.specs,
Brand.name,
Warehouse.name,
func.sum(InventoryTransaction.quantity),
)
.where( .where(
InventoryTransaction.transaction_date >= date_from, InventoryTransaction.transaction_date >= date_from,
InventoryTransaction.transaction_date < date_to, InventoryTransaction.transaction_date < date_to,
*scope_conditions, *scope_conditions,
*what_conditions, *what_conditions,
) )
.group_by(day_expr, InventoryTransaction.transaction_type) .group_by(
day_expr,
InventoryTransaction.transaction_type,
InventoryItem.specs,
Brand.name,
Warehouse.name,
),
include_brand=True,
) )
) )
).all() ).all()
@@ -391,10 +495,47 @@ async def daily_flow(
) )
) )
def category_of(tx_type: str) -> str:
if tx_type == "PURCHASE":
return "inflow"
if tx_type == "RETURN":
return "returns"
if tx_type == "WITHDRAWAL":
return "siteOut"
if tx_type in WASTE_TYPES:
return "waste"
# انتقال‌ها/شمارش/یافت‌شده — خارج از چهار سبد نمایشی، علامت‌دار
return "other"
signed_by_day: dict[date_cls, dict[str, float]] = {} signed_by_day: dict[date_cls, dict[str, float]] = {}
for day, tx_type, signed in bucket_rows: breakdown_by_day: dict[date_cls, list[dict]] = {}
for day, tx_type, specs, brand_name, warehouse_name, signed in detail_rows:
day_key = day.date() if isinstance(day, datetime) else day day_key = day.date() if isinstance(day, datetime) else day
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed) tx_type = str(tx_type)
qty = _qty(signed)
day_types = signed_by_day.setdefault(day_key, {})
day_types[tx_type] = round(day_types.get(tx_type, 0.0) + qty, 4)
breakdown_by_day.setdefault(day_key, []).append({
"category": category_of(tx_type),
"transaction_type": tx_type,
"specs": specs or None,
"brand": brand_name or None,
"warehouse": warehouse_name,
"quantity": qty,
})
unit = None
if material_id is not None:
unit_row = (
await db.execute(
select(Unit.code, Unit.name_fa)
.select_from(Material)
.join(Unit, Unit.id == Material.canonical_unit_id)
.where(Material.id == material_id)
)
).first()
if unit_row is not None:
unit = {"code": unit_row[0], "name_fa": unit_row[1]}
days = [] days = []
running = opening running = opening
@@ -411,10 +552,11 @@ async def daily_flow(
"waste": waste, "waste": waste,
"returns": round(types.get("RETURN", 0.0), 4) or 0.0, "returns": round(types.get("RETURN", 0.0), 4) or 0.0,
"balance": running, "balance": running,
"breakdown": breakdown_by_day.get(current, []),
}) })
current += timedelta(days=1) current += timedelta(days=1)
return {"data": {"opening_balance": opening, "days": days}} return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+29 -8
View File
@@ -17,6 +17,9 @@ export interface ComboboxProps {
value?: string; value?: string;
onChange?: (item: ComboboxItem) => void; onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name} items: ComboboxSource; // آرایه از string یا {id, name}
// شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر
// فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد)
pinnedIds?: string[];
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از + addSuffix?: string; // متن بعد از +
@@ -50,6 +53,7 @@ export function Combobox({
value, value,
onChange, onChange,
items, items,
pinnedIds,
allowAdd = true, allowAdd = true,
addPrefix, addPrefix,
addSuffix = "به عنوان گزینه جدید", addSuffix = "به عنوان گزینه جدید",
@@ -88,21 +92,37 @@ export function Combobox({
return all.filter((x) => normalize(x.label).includes(q)); return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]); }, [all, q]);
// آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود.
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
const pin = pinnedIds ?? [];
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
const pinned = pin
.map((id) => all.find((x) => x.id === id))
.filter((x): x is ComboboxItem => x !== undefined);
const pinnedSet = new Set(pinned.map((x) => x.id));
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
}, [all, filtered, pinnedIds]);
const showAdd = const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q); allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0; const showClear = Boolean(onClear) && !disabled && text.length > 0;
const listCount = filtered.length + (showAdd ? 1 : 0); const listCount = visible.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false)); const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => { const pick = (it: ComboboxItem) => {
setText(it.label); // آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به
// placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند.
const nextText = it.id ? it.label : "";
setText(nextText);
setOpen(false); setOpen(false);
setHighlight(-1); setHighlight(-1);
onChange?.(it); onChange?.(it);
onInputChange?.(it.label); onInputChange?.(nextText);
}; };
const addCurrent = () => { const addCurrent = () => {
@@ -127,9 +147,9 @@ export function Combobox({
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1)); setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (highlight >= 0 && highlight < filtered.length) { if (highlight >= 0 && highlight < visible.length) {
pick(filtered[highlight]); pick(visible[highlight]);
} else if (highlight === filtered.length && showAdd) { } else if (highlight === visible.length && showAdd) {
addCurrent(); addCurrent();
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -197,13 +217,14 @@ export function Combobox({
{listCount === 0 && ( {listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div> <div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
)} )}
{filtered.map((it, i) => ( {visible.map((it, i) => (
<button <button
type="button" type="button"
key={it.id + "|" + i} key={it.id + "|" + i}
className={cn( className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg", "block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg", i === highlight && "bg-slate-bg",
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
)} )}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input e.preventDefault(); // حفظ فوکوس input
@@ -1,49 +1,96 @@
// ============================================================ // ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات // فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب // داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند: // هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی) // متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال // متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند. // هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی).
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود. // وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================ // ============================================================
import { useMemo } from "react"; import { useMemo } from "react";
import { Filter } from "lucide-react"; import { Filter, X } from "lucide-react";
import { Combobox } from "../Combobox"; import { Combobox, type ComboboxItem } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData"; import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter"; import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند.
// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود.
const ALL_OPTION_ID = "";
// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
const LABELS = {
allMaterials: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
cascade: "متریال ← برند ← مشخصات فنی",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
}
export function DashboardFilterBar({ className }: { className?: string }) { export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions(); const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter(); const state = useDashboardFilter();
const categories = filterQuery.data?.categories ?? []; // فهرست متریال‌ها مستقیماً از کاتالوگ سراسری می‌آید؛ فقط یکتاسازی شناسه‌ها
// انجام می‌شود تا هیچ گزینه‌ی تکراری در فهرست نباشد.
// متریال‌های سطح فعلی: اگر دسته انتخاب شده باشد فقط متریال‌های همان دسته const materials = useMemo(() => {
const materialOptions = useMemo(() => {
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
// حذف متریال تکراری وقتی «همه‌ی دسته‌ها» فعال است
const seen = new Set<string>(); const seen = new Set<string>();
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true))); return (filterQuery.data?.materials ?? []).filter((m) =>
}, [categories, state.categoryId]); seen.has(m.id) ? false : (seen.add(m.id), true),
);
}, [filterQuery.data]);
const selectedMaterial = useMemo( const selectedMaterial = useMemo(
() => materialOptions.find((m) => m.id === state.materialId) ?? null, () => materials.find((m) => m.id === state.materialId) ?? null,
[materialOptions, state.materialId], [materials, state.materialId],
); );
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length; const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)» // انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const specLabel = (specs: Record<string, string> | null) => { const pick =
const parts = Object.entries(specs || {}); (set: (id: string | null, label: string | null) => void) =>
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)"; (item: ComboboxItem) =>
}; item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
// فهرست هر فیلد با گزینه‌ی «همه» در ابتدا
const materialItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
[materials],
);
const brandItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allBrands),
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
],
[selectedMaterial],
);
const specItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allSpecs),
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
],
[selectedMaterial],
);
return ( return (
<section <section
@@ -56,61 +103,61 @@ export function DashboardFilterBar({ className }: { className?: string }) {
</span> </span>
<div> <div>
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2> <h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">دسته‌بندی ← متریال ← برند ← مشخصات فنی</p> <p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
</div> </div>
{activeCount > 0 && ( {activeCount > 0 && (
<button <button
type="button" type="button"
onClick={state.reset} onClick={state.reset}
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80" aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
> >
حذف فیلترها ({faNum(activeCount)}) <X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
{LABELS.clearAll}
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
{faNum(activeCount)}
</span>
</button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4"> {/* سه فیلتر با عرض یکسان، وسط‌چین و متقارن؛ روی موبایل تک‌ستونی می‌شود */}
{/* ۱ — دسته‌بندی */} <div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
<Combobox {/* ۱ — متریال (سرآغاز آبشار) */}
value={state.categoryLabel ?? ""}
placeholder="همه‌ی دسته‌ها"
allowAdd={false}
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
onClear={() => state.setCategory(null, null)}
onChange={(item) => state.setCategory(item.id, item.label)}
/>
{/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
<Combobox <Combobox
value={state.materialLabel ?? ""} value={state.materialLabel ?? ""}
placeholder="همه‌ی متریال‌ها" placeholder={LABELS.allMaterials}
allowAdd={false} allowAdd={false}
disabled={materialOptions.length === 0} disabled={materials.length === 0}
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))} items={materialItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)} onClear={() => state.setMaterial(null, null)}
onChange={(item) => state.setMaterial(item.id, item.label)} onChange={pick(state.setMaterial)}
/> />
{/* ۳ — برند (فقط برندهای همان متریال) */} {/* ۲ — برند (فقط برندهای همان متریال) */}
<Combobox <Combobox
value={state.brandLabel ?? ""} value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی برندها" : "ابتدا متریال را انتخاب کنید"} placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
allowAdd={false} allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0} disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))} items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)} onClear={() => state.setBrand(null, null)}
onChange={(item) => state.setBrand(item.id, item.label)} onChange={pick(state.setBrand)}
/> />
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */} {/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox <Combobox
value={state.specsLabel ?? ""} value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی مشخصات" : "ابتدا متریال را انتخاب کنید"} placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
allowAdd={false} allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0} disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))} items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpecs(null, null)} onClear={() => state.setSpecs(null, null)}
onChange={(item) => state.setSpecs(item.id, item.label)} onChange={pick(state.setSpecs)}
/> />
</div> </div>
</section> </section>
File diff suppressed because it is too large Load Diff
+550 -66
View File
@@ -1,8 +1,16 @@
// ============================================================ // ============================================================
// ویجت ۳ — سری زمانی جریان روزانه // ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تک‌متریالی)
// ستون‌های روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایه‌ی // هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته — // کشیده می‌شود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
// به‌علاوه‌ی خط «مانده» (بالانس تجمعی). تاریخ‌ها شمسی، اعداد فارسی. // روی مسیر واقعی مانده می‌نشینند، پس ستون دقیقاً پله‌ی خط stepAfter را می‌سازد.
//
// قواعد کسب‌وکار:
// • جمع‌زدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بی‌معناست؛ پس
// بدون انتخاب متریال، نمودار رندر نمی‌شود و به‌جایش انتخاب سریع متریال می‌آید.
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) می‌تواند چند «گروه» (مشخصات + برند)
// داشته باشد؛ هر گروه یک پله‌ی روشن‌تر از خانواده‌ی رنگ همان دسته می‌گیرد و
// سگمنت‌ها یک ستون مشترک با جداکننده‌ی سفید ۱px می‌سازند.
// • همه‌ی موجودیت‌ها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق می‌شوند.
// ============================================================ // ============================================================
import { useMemo } from "react"; import { useMemo } from "react";
@@ -10,66 +18,534 @@ import {
Bar, Bar,
CartesianGrid, CartesianGrid,
ComposedChart, ComposedChart,
Legend,
Line, Line,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { useDailyFlow } from "../../hooks/useDashboardData"; import { Package } from "lucide-react";
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
import { import { fmtNum } from "../../lib/formatters";
CHART_COLORS, import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
ChartCard, import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
interface Row { // ——— چیدمان ———
const FLOW_STACK_ID = "flow-waterfall";
const MAX_BAR_SIZE = 20;
const BAR_CATEGORY_GAP = "18%";
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
const OTHER_RESIDUAL_KEY = "__adjust__";
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
// خانواده‌ی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
const CATEGORY_META: Record<
FlowCategory,
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
> = {
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
};
// تنالیته‌ی گروه‌های یک دسته: هر گروه (مشخصات+برند) یک پله روشن‌تر
const SHADE_STEP_L = 9;
const SHADE_MAX_L = 78;
function categoryColor(category: FlowCategory, shadeIndex: number): string {
const { h, s, l } = CATEGORY_META[category].base;
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
return `hsl(${h} ${s}% ${lightness}%)`;
}
const LABELS = {
title: "جریان روزانه موجودی",
needMaterial:
"برای مشاهده‌ی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
quickPick: "انتخاب سریع متریال",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
opening: "مانده ابتدای روز",
net: "تغییر خالص روز",
closing: "مانده پایان روز",
unit: "واحد",
brand: "برند",
warehouse: "انبار",
noSpecs: "بدون مشخصه",
aggregate: "کل دسته",
adjustment: "تعدیل حساب",
shadeHint: "طیف روشن‌تر هر رنگ = برند/مشخصات دیگر در همان روز",
error: "دریافت داده‌ی جریان روزانه ناموفق بود",
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
} as const;
// ——— مدل داده ———
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
interface CatItem {
key: string; // هویت گروه: مشخصات|برند
specLabel: string;
brand: string | null;
warehouse: string;
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامت‌دار
}
interface CatGroup {
key: string;
label: string;
brand: string | null;
quantity: number; // مجموع گروه (برای «سایر» علامت‌دار)
items: CatItem[]; // ریزردیف‌ها برای تولتیپ
}
type CatMap = Record<FlowCategory, CatGroup[]>;
interface FlowRow {
date: string; date: string;
ورود: number; opening: number;
برگشت: number; closing: number;
"خروج سایت": number; net: number;
"ضایعات و اتلاف": number; balance: number;
مانده: number; groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
_total: number; hasMovement: boolean;
// سگمنت‌ها با کلیدهای مسطح seg_<category>_<shadeIndex>
[segmentKey: string]: unknown;
}
const round4 = (v: number) => Math.round(v * 10000) / 10000;
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «داده‌ای ثبت نشده» تلقّی شود
function FlowError() {
return (
<div
dir="rtl"
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
>
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
</div>
);
}
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs ?? {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
}
// ——— تولتیپ تحلیلی RTL ———
function FlowTooltip({
active,
payload,
unitLabel,
materialName,
}: {
active?: boolean;
payload?: readonly { payload?: FlowRow }[];
unitLabel: string;
materialName: string;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
const netTone =
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
return (
<div
dir="rtl"
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[380px] sm:whitespace-nowrap"
>
{/* سربرگ: تاریخ + متریال + نشان واحد */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</span>
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
{unitLabel && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
{unitLabel}
</span>
)}
</div>
<div className="my-2 border-b border-slate-100" />
{/* مانده ابتدای روز */}
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(fmtNum(row.opening))}
</span>
</div>
<div className="my-2 border-b border-slate-100" />
{/* دسته‌ها + ریزردیف مشخصات/برند/انبار */}
<div className="flex flex-col gap-2">
{CATEGORY_ORDER.map((category) => {
const groups = row.groups[category] ?? [];
if (groups.length === 0) return null;
const total = round4(
groups.reduce((sum, group) => sum + group.quantity, 0) *
CATEGORY_META[category].sign,
);
if (total === 0) return null;
return (
<div key={category} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: categoryColor(category, 0) }}
/>
{CATEGORY_META[category].label}
</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(signed(total))}
</span>
</div>
{groups.map((group) => (
<div key={group.key} className="flex flex-col">
{group.items.map((item) => (
<div
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
>
<span className="flex flex-wrap items-center gap-1">
<span aria-hidden>↳</span>
<span>{item.specLabel}</span>
{item.brand && (
<>
<span className="text-slate-300">|</span>
<span>
{LABELS.brand}: {item.brand}
</span>
</>
)}
{item.warehouse && (
<span className="text-slate-400">
({LABELS.warehouse}: {item.warehouse})
</span>
)}
</span>
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
</span>
</div>
))}
</div>
))}
</div>
);
})}
</div>
<div className="my-2 border-b border-slate-100" />
{/* جمع‌بندی */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
{withUnit(signed(row.net))}
</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(fmtNum(row.closing))}
</span>
</div>
</div>
</div>
);
}
// ——— نرمال‌سازی یک روز: گروه‌بندی ریزردیف‌ها بر اساس (دسته، مشخصات+برند) ———
function dayGroups(day: FlowDay): CatMap {
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
const insert = (category: FlowCategory, item: CatItem) => {
const existing = groups[category].find((group) => group.key === item.key);
if (existing) {
existing.quantity = round4(existing.quantity + item.quantity);
existing.items.push(item);
} else {
groups[category].push({
key: item.key,
label: item.specLabel,
brand: item.brand,
quantity: item.quantity,
items: [item],
});
}
};
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
if (breakdown.length > 0) {
breakdown.forEach((item) => {
// چهار سبد نمایشی همیشه مثبت نشان داده می‌شوند؛ «سایر» علامت دفتر را نگه می‌دارد
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
if (quantity === 0) return;
insert(item.category, {
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
specLabel: specLabel(item.specs),
brand: item.brand,
warehouse: item.warehouse,
quantity: round4(quantity),
});
});
} else {
// سازگاری عقب‌رو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته می‌شود
const aggregates: [FlowCategory, number][] = [
["inflow", day.inbound],
["returns", day.returns],
["siteOut", Math.max(0, day.outbound - day.waste)],
["waste", day.waste],
];
aggregates.forEach(([category, quantity]) => {
if (quantity > 0) {
insert(category, {
key: `${category}-all`,
specLabel: LABELS.aggregate,
brand: null,
warehouse: "",
quantity: round4(quantity),
});
}
});
}
return groups;
} }
export function FlowChart() { export function FlowChart() {
const query = useDailyFlow(); const filter = useDashboardFilter();
// بدون متریال، کوئری اصلاً اجرا نمی‌شود (جلوگیری از تجمیع واحدهای ناسازگار)
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
const options = useDashboardFilterOptions();
const rows: Row[] = useMemo( const materials = options.data?.materials ?? [];
() => const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
(query.data?.days ?? []).map((d) => ({ const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
date: d.date, const unitLabel =
ورود: d.inbound, query.data?.unit?.name_fa ??
برگشت: d.returns, query.data?.unit?.code ??
"خروج سایت": Math.max(0, d.outbound - d.waste), selectedMaterial?.unit?.name_fa ??
"ضایعات و اتلاف": d.waste, selectedMaterial?.unit?.code ??
مانده: d.balance, "";
_total: d.inbound + d.outbound + d.returns,
})), // ——— هم‌سازمان‌دهی روزها + سگمنت‌های شناور ———
[query.data], const { rows, groupCounts, hasMovement } = useMemo(() => {
const days = query.data?.days ?? [];
let opening = query.data?.opening_balance ?? 0;
// گذر اول: نرمال‌سازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
const normalized = days.map((day) => {
const closing = day.balance;
const net = round4(closing - opening);
const groups = dayGroups(day);
const categorized = round4(
CATEGORY_ORDER.reduce((sum, category) => {
const categoryTotal = groups[category].reduce(
(groupSum, group) => groupSum + group.quantity,
0,
); );
return sum + categoryTotal * CATEGORY_META[category].sign;
}, 0),
);
const residual = round4(net - categorized);
if (residual !== 0) {
groups.other.push({
key: OTHER_RESIDUAL_KEY,
label: LABELS.adjustment,
brand: null,
quantity: residual,
items: [],
});
}
const normalizedDay = { day, opening, closing, net, groups };
opening = closing;
return normalizedDay;
});
const hasAnyData = rows.some((r) => r._total > 0); // ترتیب گروه‌ها (و در نتیجه پله‌ی رنگ) در کل بازه ثابت می‌ماند
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
normalized.forEach(({ groups }) => {
CATEGORY_ORDER.forEach((category) => {
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
groups[category].forEach((group) => {
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
});
totalByCategory.set(category, perCategory);
});
});
const orderByCategory = new Map<FlowCategory, string[]>();
const groupCounts: Record<FlowCategory, number> = {
inflow: 0,
returns: 0,
siteOut: 0,
waste: 0,
other: 0,
};
CATEGORY_ORDER.forEach((category) => {
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
.sort((a, b) => b[1] - a[1])
.map(([key]) => key);
orderByCategory.set(category, orderedKeys);
groupCounts[category] = orderedKeys.length;
});
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده می‌گیرد
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
const row: FlowRow = {
date: day.date,
opening: dayOpening,
closing,
net,
balance: closing,
groups,
hasMovement: false,
};
let cursor = dayOpening;
CATEGORY_ORDER.forEach((category) => {
const orderedKeys = orderByCategory.get(category) ?? [];
const sign = CATEGORY_META[category].sign;
orderedKeys.forEach((key, index) => {
const group = groups[category].find((item) => item.key === key);
let range: Range = [cursor, cursor];
if (group && group.quantity !== 0) {
const from = cursor;
cursor = round4(cursor + group.quantity * sign);
range = [Math.min(from, cursor), Math.max(from, cursor)];
row.hasMovement = true;
}
row[`seg_${category}_${index}`] = range;
});
});
return row;
});
return {
rows,
groupCounts,
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
};
}, [query.data]);
// ——— دامنه‌ی محور Y با ۱۰٪ سرصفحه ———
const [domainMin, domainMax] = useMemo(() => {
const values: number[] = [];
rows.forEach((row) => {
values.push(row.balance, row.opening, row.closing);
CATEGORY_ORDER.forEach((category) => {
const count = groupCounts[category] ?? 0;
for (let index = 0; index < count; index += 1) {
const range = row[`seg_${category}_${index}`] as Range | undefined;
if (range) values.push(range[0], range[1]);
}
});
});
if (values.length === 0) return [0, 1] as const;
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
}, [rows, groupCounts]);
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
// ——— دروازه‌ی تک‌متریالی: بدون متریال، نمودار رندر نمی‌شود ———
if (!filter.materialId) {
return (
<ChartCard title={LABELS.title}>
{options.isError ? (
<FlowError />
) : options.isLoading ? (
<ChartLoading />
) : (
<div
dir="rtl"
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
<Package aria-hidden className="h-5 w-5" />
</span>
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
{materials.length > 0 && (
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
<div className="flex max-w-xl flex-wrap justify-center gap-2">
{materials.map((material) => {
const materialUnit =
material.unit?.name_fa ?? material.unit?.code ?? "";
return (
<button
key={material.id}
type="button"
onClick={() => filter.setMaterial(material.id, material.name)}
title={material.name}
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
>
<span className="truncate">{material.name}</span>
{materialUnit && (
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
{materialUnit}
</span>
)}
</button>
);
})}
</div>
</div>
)}
</div>
)}
</ChartCard>
);
}
return ( return (
<ChartCard <ChartCard
title="جریان روزانه موجودی" title={LABELS.title}
sub="ورود، خروج (با لایه‌ی قرمز ضایعات)، برگشت از سایت و مانده‌ی تجمعی — ۳۰ روز اخیر" sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
> >
{query.isLoading ? ( {query.isLoading ? (
<ChartLoading /> <ChartLoading />
) : !hasAnyData ? ( ) : query.isError ? (
<ChartEmpty message="در این بازه جابه‌جایی موجودی ثبت نشده است" /> <FlowError />
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : ( ) : (
<div dir="rtl" className="flex flex-col gap-1">
{/* راهنمای رنگ دسته‌ها (به‌جای Legend پیش‌فرض؛ سری‌های تنالیته معنا ندارند) */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
{visibleCategories.map((category) => (
<span key={category} className="inline-flex items-center gap-1">
<span
className="h-2 w-2 rounded-full"
style={{ background: categoryColor(category, 0) }}
/>
{CATEGORY_META[category].label}
</span>
))}
{hasMultipleShades && (
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
)}
</div>
<div dir="ltr" className="h-72 w-full"> <div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}> <ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis <XAxis
dataKey="date" dataKey="date"
@@ -80,43 +556,50 @@ export function FlowChart() {
minTickGap={18} minTickGap={18}
/> />
<YAxis <YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }} tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick} tickFormatter={faAxisTick}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
width={56} width={unitLabel ? 70 : 56}
label={
unitLabel
? {
value: unitLabel,
angle: -90,
position: "insideLeft",
style: { fontSize: 10, fill: "#64748b" },
}
: undefined
}
/> />
<Tooltip <Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }} cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={ content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
<ChartTooltip
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
/> />
} {/* سگمنت‌های شناور: هر گروه (مشخصات+برند) یک پله از خانواده‌ی رنگ
دسته، با جداکننده‌ی سفید ۱px و یک ستون مشترک */}
{CATEGORY_ORDER.map((category) =>
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
<Bar
key={`${category}-${index}`}
dataKey={`seg_${category}_${index}`}
fill={categoryColor(category, index)}
stroke="#ffffff"
strokeWidth={1}
stackId={FLOW_STACK_ID}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/> />
<Legend )),
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)} )}
/> {/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
{/* ستون‌های کنار هم: ورود و برگشت */}
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
{/* ستون خروج: دو لایه‌ی سته — بخش قرمز ضایعات داخل ستون خروج */}
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Line <Line
type="monotone" type="stepAfter"
dataKey="مانده" dataKey="balance"
stroke={CHART_COLORS.balance} name={LABELS.closing}
strokeWidth={1.5} stroke="#0f172a"
strokeWidth={1.75}
strokeDasharray="5 3" strokeDasharray="5 3"
dot={false} dot={false}
isAnimationActive={false} isAnimationActive={false}
@@ -124,6 +607,7 @@ export function FlowChart() {
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
)} )}
</ChartCard> </ChartCard>
); );
+3 -1
View File
@@ -43,11 +43,13 @@ export function useDeadStock(idleDays: number) {
} }
// ویجت ۳ — سری زمانی جریان روزانه // ویجت ۳ — سری زمانی جریان روزانه
export function useDailyFlow(dateFrom?: string, dateTo?: string) { // فقط با متریال مشخص اجرا میشود: جمع چند متریال با واحدهای متفاوت بی‌معناست.
export function useDailyFlow(dateFrom?: string, dateTo?: string, enabled = true) {
const params = useFilterParams(); const params = useFilterParams();
return useQuery({ return useQuery({
queryKey: ["dashboard", "flow", dateFrom, dateTo, params], queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }), queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
enabled,
}); });
} }
+3 -4
View File
@@ -23,7 +23,7 @@ import type {
DailyControlData, DailyControlData,
DeadStockItem, DeadStockItem,
DrilldownData, DrilldownData,
FlowDay, FlowData,
InTransitRow, InTransitRow,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
@@ -46,7 +46,6 @@ import type {
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد // پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد) // (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = { export type DashboardFilterParams = {
category_id?: string | null;
material_id?: string | null; material_id?: string | null;
brand_id?: string | null; brand_id?: string | null;
specs_hash?: string | null; specs_hash?: string | null;
@@ -1929,8 +1928,8 @@ export const apiV26 = {
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`), getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> => deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`), getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> => flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`), getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> => dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`), getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
workload: async (): Promise<{ projects: WorkloadProject[] }> => workload: async (): Promise<{ projects: WorkloadProject[] }> =>
+32 -9
View File
@@ -232,15 +232,8 @@ export interface FilterMaterialOption {
specs: FilterSpecOption[]; specs: FilterSpecOption[];
} }
export interface FilterCategoryOption {
id: string | null; // null = «بدون دسته»
name: string;
slug: string | null;
materials: FilterMaterialOption[];
}
export interface DashboardFilterOptions { export interface DashboardFilterOptions {
categories: FilterCategoryOption[]; materials: FilterMaterialOption[];
} }
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) — // — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
@@ -261,10 +254,18 @@ export interface DrilldownProject {
warehouses: DrilldownWarehouse[]; warehouses: DrilldownWarehouse[];
} }
export interface DrilldownMaterial {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
projects: DrilldownProject[];
}
export interface DrilldownData { export interface DrilldownData {
units: Record<string, string>; units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number }; total: { qty_by_unit: QtyByUnit; rows: number };
projects: DrilldownProject[]; materials: DrilldownMaterial[];
} }
// — جدول موجودی راکد — // — جدول موجودی راکد —
@@ -282,6 +283,20 @@ export interface DeadStockItem {
} }
// — سری زمانی جریان روزانه — // — سری زمانی جریان روزانه —
// دسته‌های نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات
// (انتقال/شمارش/یافت‌شده که در چهار سبد نمایشی جا نمی‌گیرند)
export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other";
// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایه‌ی شکست چندبعدی
export interface FlowBreakdownItem {
category: FlowCategory;
transaction_type: string;
specs: Record<string, string> | null;
brand: string | null;
warehouse: string;
quantity: number; // علامت‌دار مطابق دفتر (مثبت/منفی)
}
export interface FlowDay { export interface FlowDay {
date: string; // ISO (YYYY-MM-DD) date: string; // ISO (YYYY-MM-DD)
inbound: number; inbound: number;
@@ -289,6 +304,14 @@ export interface FlowDay {
waste: number; waste: number;
returns: number; returns: number;
balance: number; balance: number;
breakdown?: FlowBreakdownItem[];
}
export interface FlowData {
opening_balance: number;
// واحد کاتالوگی متریال انتخاب‌شده (فقط وقتی یک متریال فیلتر شده باشد)
unit: { code: string; name_fa: string | null } | null;
days: FlowDay[];
} }
// — کنترل روزانه — // — کنترل روزانه —
+3 -20
View File
@@ -1,23 +1,20 @@
// ============================================================ // ============================================================
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد // Zustand store — فیلتر سراسری «چه چیزی» داشبورد
// دسته‌بندی → متریال → برند → مشخصات فنی // متریال → برند → مشخصات فنی
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود. // بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود.
// ============================================================ // ============================================================
import { create } from "zustand"; import { create } from "zustand";
export interface DashboardFilterState { export interface DashboardFilterState {
categoryId: string | null; // null = همه‌ی دسته‌ها materialId: string | null; // null = همه‌ی متریال‌ها
materialId: string | null;
brandId: string | null; brandId: string | null;
specsHash: string | null; specsHash: string | null;
// برچسب‌های انتخاب‌شده برای نمایش چیپ فعال // برچسب‌های انتخاب‌شده برای نمایش چیپ فعال
categoryLabel: string | null;
materialLabel: string | null; materialLabel: string | null;
brandLabel: string | null; brandLabel: string | null;
specsLabel: string | null; specsLabel: string | null;
setCategory: (id: string | null, label: string | null) => void;
setMaterial: (id: string | null, label: string | null) => void; setMaterial: (id: string | null, label: string | null) => void;
setBrand: (id: string | null, label: string | null) => void; setBrand: (id: string | null, label: string | null) => void;
setSpecs: (hash: string | null, label: string | null) => void; setSpecs: (hash: string | null, label: string | null) => void;
@@ -25,11 +22,9 @@ export interface DashboardFilterState {
} }
const INITIAL = { const INITIAL = {
categoryId: null,
materialId: null, materialId: null,
brandId: null, brandId: null,
specsHash: null, specsHash: null,
categoryLabel: null,
materialLabel: null, materialLabel: null,
brandLabel: null, brandLabel: null,
specsLabel: null, specsLabel: null,
@@ -37,18 +32,7 @@ const INITIAL = {
export const useDashboardFilter = create<DashboardFilterState>((set) => ({ export const useDashboardFilter = create<DashboardFilterState>((set) => ({
...INITIAL, ...INITIAL,
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند // آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند (متریال → برند → مشخصات)
setCategory: (id, label) =>
set({
categoryId: id,
categoryLabel: label,
materialId: null,
materialLabel: null,
brandId: null,
brandLabel: null,
specsHash: null,
specsLabel: null,
}),
setMaterial: (id, label) => setMaterial: (id, label) =>
set({ set({
materialId: id, materialId: id,
@@ -72,7 +56,6 @@ export const useDashboardFilter = create<DashboardFilterState>((set) => ({
// پارامترهای آماده برای endpointهای داشبورد // پارامترهای آماده برای endpointهای داشبورد
export function dashboardFilterParams(s: DashboardFilterState) { export function dashboardFilterParams(s: DashboardFilterState) {
return { return {
category_id: s.categoryId,
material_id: s.materialId, material_id: s.materialId,
brand_id: s.brandId, brand_id: s.brandId,
specs_hash: s.specsHash, specs_hash: s.specsHash,