update FlowChart
This commit is contained in:
@@ -96,14 +96,29 @@ async def dashboard_scope(
|
||||
|
||||
|
||||
def _what_filters(
|
||||
material_id: uuid.UUID | None,
|
||||
material_id: str | None,
|
||||
brand_id: uuid.UUID | None,
|
||||
specs_hash: str | None,
|
||||
spec_id: str | None = None,
|
||||
) -> 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 {
|
||||
"material_id": material_id,
|
||||
"material_id": resolved_material,
|
||||
"brand_id": brand_id,
|
||||
"specs_hash": specs_hash,
|
||||
"specs_hash": resolved_specs,
|
||||
}
|
||||
|
||||
|
||||
@@ -117,16 +132,17 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||
async def inventory_drilldown(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||
brand_id: uuid.UUID | 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)."""
|
||||
return await dashboard_service.inventory_drilldown(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -136,9 +152,10 @@ 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),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||
):
|
||||
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||
return await dashboard_service.dead_stock(
|
||||
@@ -147,7 +164,7 @@ async def dead_stock(
|
||||
project_id=scope.project_id,
|
||||
idle_days=idle_days,
|
||||
limit=limit,
|
||||
**_what_filters(material_id, brand_id, specs_hash),
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||
)
|
||||
|
||||
|
||||
@@ -157,10 +174,11 @@ async def inventory_flow(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||
material_id: uuid.UUID | None = Query(default=None),
|
||||
warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همهی انبارها"),
|
||||
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||
):
|
||||
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||
@@ -177,7 +195,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(material_id, brand_id, specs_hash),
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -413,14 +413,24 @@ async def daily_flow(
|
||||
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
||||
balance = running sum of EVERY signed movement (transfers/found/stock
|
||||
counts included), seeded with the opening balance before `date_from`.
|
||||
|
||||
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):
|
||||
return (
|
||||
def _with_joins(sel, *, include_brand: bool = False):
|
||||
# ترتیب joinها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
|
||||
stmt = (
|
||||
sel.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
)
|
||||
if include_brand:
|
||||
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||
return stmt
|
||||
|
||||
scope_conditions = []
|
||||
if project_id is not None:
|
||||
@@ -437,18 +447,38 @@ async def daily_flow(
|
||||
if 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(
|
||||
_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(
|
||||
InventoryTransaction.transaction_date >= date_from,
|
||||
InventoryTransaction.transaction_date < date_to,
|
||||
*scope_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()
|
||||
@@ -465,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]] = {}
|
||||
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
|
||||
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 = []
|
||||
running = opening
|
||||
@@ -485,10 +552,11 @@ async def daily_flow(
|
||||
"waste": waste,
|
||||
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
||||
"balance": running,
|
||||
"breakdown": breakdown_by_day.get(current, []),
|
||||
})
|
||||
current += timedelta(days=1)
|
||||
|
||||
return {"data": {"opening_balance": opening, "days": days}}
|
||||
return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user