diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 9913b57..8932a03 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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), ) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 3b8f4b1..a9b519d 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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}} # --------------------------------------------------------------------------- diff --git a/frontend/src/components/dashboard/FlowChart.tsx b/frontend/src/components/dashboard/FlowChart.tsx index da8685d..437eb43 100644 --- a/frontend/src/components/dashboard/FlowChart.tsx +++ b/frontend/src/components/dashboard/FlowChart.tsx @@ -1,11 +1,16 @@ // ============================================================ -// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور) +// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تک‌متریالی) // هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز» -// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات) -// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون -// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر). -// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز. -// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند. +// کشیده می‌شود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات) +// روی مسیر واقعی مانده می‌نشینند، پس ستون دقیقاً پله‌ی خط stepAfter را می‌سازد. +// +// قواعد کسب‌وکار: +// • جمع‌زدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بی‌معناست؛ پس +// بدون انتخاب متریال، نمودار رندر نمی‌شود و به‌جایش انتخاب سریع متریال می‌آید. +// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) می‌تواند چند «گروه» (مشخصات + برند) +// داشته باشد؛ هر گروه یک پله‌ی روشن‌تر از خانواده‌ی رنگ همان دسته می‌گیرد و +// سگمنت‌ها یک ستون مشترک با جداکننده‌ی سفید ۱px می‌سازند. +// • همه‌ی موجودیت‌ها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق می‌شوند. // ============================================================ import { useMemo } from "react"; @@ -13,151 +18,247 @@ import { Bar, CartesianGrid, ComposedChart, - Legend, Line, ResponsiveContainer, Tooltip, XAxis, YAxis, } 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 { fmtNum } from "../../lib/formatters"; import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared"; +import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types"; -// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ——— -const FLOW_COLORS = { - inbound: "#059669", // ورود به انبار — زمردی - returns: "#0d9488", // برگشت از سایت — تیل - siteOut: "#f59e0b", // خروج به سایت — کهربایی - waste: "#dc2626", // ضایعات و اتلاف — قرمز - other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری - balance: "#0f172a", // خط مانده — سرمهای -} as const; +// ——— چیدمان ——— +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}%)`; +} -// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX) const LABELS = { title: "جریان روزانه موجودی", - sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات", - inbound: "ورود به انبار", - returns: "برگشت از سایت", - siteOut: "خروج به سایت", - waste: "ضایعات و اتلاف", - other: "سایر حرکات (انتقال/شمارش)", - balance: "مانده", + needMaterial: + "برای مشاهده‌ی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.", + quickPick: "انتخاب سریع متریال", + empty: "در این بازه جابه‌جایی موجودی ثبت نشده است", opening: "مانده ابتدای روز", net: "تغییر خالص روز", closing: "مانده پایان روز", - empty: "در این بازه جابه‌جایی موجودی ثبت نشده است", + unit: "واحد", + brand: "برند", + warehouse: "انبار", + noSpecs: "بدون مشخصه", + aggregate: "کل دسته", + adjustment: "تعدیل حساب", + shadeHint: "طیف روشن‌تر هر رنگ = برند/مشخصات دیگر در همان روز", + error: "دریافت داده‌ی جریان روزانه ناموفق بود", + errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.", } as const; -// سگمنت شناور: [پایین، بالا] — همان چیزی که به‌عنوان مقدار محدوده می‌خواند -type Range = [number, number]; +// ——— مدل داده ——— +type Range = [number, number]; // [پایین، بالا] — سگمنت شناور + +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; -// یک ردیف زمانی — سگمنتها به‌صورت پراپرتی‌های مسطح می‌نشینند تا dataKey هر -// مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن). interface FlowRow { date: string; - balance: number; // خط پله‌ای مانده = مانده پایان روز opening: number; closing: number; net: number; - inbound: number; - returns: number; - siteOut: number; - waste: number; - other: number; + balance: number; + groups: CatMap; // متادیتای تولتیپ (dataKey نیست) hasMovement: boolean; - segInbound: Range; - segReturns: Range; - segSiteOut: Range; - segWaste: Range; - segOther: Range; + // سگمنت‌ها با کلیدهای مسطح seg__ + [segmentKey: string]: unknown; } -// گرد کردن تا ۴ رقم اعشار — مثل بقیه‌ی تجمیع‌های داشبورد const round4 = (v: number) => Math.round(v * 10000) / 10000; - -// حاشیه‌ی دامنه‌ی محور Y (نسبت به بازه‌ی داده) تا لبه‌ها به قاب نچسبند -const DOMAIN_PAD_RATIO = 0.04; -const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور می‌آید -const BAR_CATEGORY_GAP = "18%"; -// همه‌ی سگمنت‌ها یک ستون مشترک می‌سازند (نه چند ستون باریک کنار هم)؛ چون -// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking) -// دقیقاً در همان ارتفاع شناور رسم می‌کند. -const FLOW_STACK_ID = "flow-waterfall"; - -// عدد با علامت برای «تغییر خالص» — ارقام فارسی const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`; -// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص +// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «داده‌ای ثبت نشده» تلقّی شود +function FlowError() { + return ( +
+ {LABELS.error} + {LABELS.errorHint} +
+ ); +} + +// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه» +function specLabel(specs: Record | 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; - /** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */ - const lines: { label: string; value: number; className: string; signed?: boolean }[] = [ - { label: LABELS.opening, value: row.opening, className: "text-slate-600" }, - { label: LABELS.inbound, value: row.inbound, className: "text-slate-700" }, - { label: LABELS.returns, value: row.returns, className: "text-slate-700" }, - { label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" }, - { label: LABELS.waste, value: -row.waste, className: "text-slate-700" }, - ]; - if (row.other !== 0) { - lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" }); - } + 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 (
-
- {isoToJalaliStr(row.date) ?? row.date} -
-
-
- {lines.map((line) => ( -
- - {line.label} - - - {line.value === 0 ? fmtNum(0) : signed(line.value)} - -
- ))} -
-
- - {LABELS.net} + {/* سربرگ: تاریخ + متریال + نشان واحد */} +
+ + {isoToJalaliStr(row.date) ?? row.date} + + {materialName && {materialName}} + {unitLabel && ( + + {unitLabel} - - {signed(row.net)} + )} +
+ +
+ + {/* مانده ابتدای روز */} +
+ {LABELS.opening} + + {withUnit(fmtNum(row.opening))} + +
+ +
+ + {/* دسته‌ها + ریزردیف مشخصات/برند/انبار */} +
+ {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 ( +
+
+ + + {CATEGORY_META[category].label} + + + {withUnit(signed(total))} + +
+ {groups.map((group) => ( +
+ {group.items.map((item) => ( +
+ + ↳ + {item.specLabel} + {item.brand && ( + <> + | + + {LABELS.brand}: {item.brand} + + + )} + {item.warehouse && ( + + ({LABELS.warehouse}: {item.warehouse}) + + )} + + + {withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))} + +
+ ))} +
+ ))} +
+ ); + })} +
+ +
+ + {/* جمع‌بندی */} +
+
+ {LABELS.net} + + {withUnit(signed(row.net))}
- - {LABELS.closing} - - - {fmtNum(row.closing)} + {LABELS.closing} + + {withUnit(fmtNum(row.closing))}
@@ -165,65 +266,188 @@ function FlowTooltip({ ); } +// ——— نرمال‌سازی یک روز: گروه‌بندی ریزردیف‌ها بر اساس (دسته، مشخصات+برند) ——— +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() { - const query = useDailyFlow(); + const filter = useDashboardFilter(); + // بدون متریال، کوئری اصلاً اجرا نمی‌شود (جلوگیری از تجمیع واحدهای ناسازگار) + const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId)); + const options = useDashboardFilterOptions(); - // ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار می‌شود ——— - const rows: FlowRow[] = useMemo(() => { + const materials = options.data?.materials ?? []; + const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null; + const materialName = selectedMaterial?.name ?? filter.materialLabel ?? ""; + const unitLabel = + query.data?.unit?.name_fa ?? + query.data?.unit?.code ?? + selectedMaterial?.unit?.name_fa ?? + selectedMaterial?.unit?.code ?? + ""; + + // ——— هم‌سازمان‌دهی روزها + سگمنت‌های شناور ——— + const { rows, groupCounts, hasMovement } = useMemo(() => { const days = query.data?.days ?? []; - let running = query.data?.opening_balance ?? 0; - return days.map((d) => { - const opening = running; - const closing = d.balance; - const waste = Math.max(0, d.waste); - // ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته می‌شود - const siteOut = Math.max(0, d.outbound - waste); + let opening = query.data?.opening_balance ?? 0; + + // گذر اول: نرمال‌سازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند) + const normalized = days.map((day) => { + const closing = day.balance; const net = round4(closing - opening); - // «سایر حرکات» = باقی‌مانده‌ی تراز (انتقال/شمارش/یافت‌شده)؛ معمولاً صفر - const other = round4(net - (d.inbound + d.returns - siteOut - waste)); + 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; + }); - let cursor = opening; - // هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء - const segment = (delta: number): Range => { - const from = cursor; - cursor = round4(cursor + delta); - return [Math.min(from, cursor), Math.max(from, cursor)]; - }; - const segInbound = segment(d.inbound); - const segReturns = segment(d.returns); - const segSiteOut = segment(-siteOut); - const segWaste = segment(-waste); - const segOther = segment(other); + // ترتیب گروه‌ها (و در نتیجه پله‌ی رنگ) در کل بازه ثابت می‌ماند + const totalByCategory = new Map>(); + normalized.forEach(({ groups }) => { + CATEGORY_ORDER.forEach((category) => { + const perCategory = totalByCategory.get(category) ?? new Map(); + groups[category].forEach((group) => { + perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity))); + }); + totalByCategory.set(category, perCategory); + }); + }); - running = closing; - return { - date: d.date, - balance: closing, - opening, + const orderByCategory = new Map(); + const groupCounts: Record = { + inflow: 0, + returns: 0, + siteOut: 0, + waste: 0, + other: 0, + }; + CATEGORY_ORDER.forEach((category) => { + const orderedKeys = [...(totalByCategory.get(category) ?? new Map()).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, - inbound: d.inbound, - returns: d.returns, - siteOut, - waste, - other, - hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0, - segInbound, - segReturns, - segSiteOut, - segWaste, - segOther, + 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 از خود داده (نه از صفر) تا ستون‌ها و خط در کادر جا شوند ——— + // ——— دامنه‌ی محور Y با ۱۰٪ سرصفحه ——— const [domainMin, domainMax] = useMemo(() => { const values: number[] = []; - rows.forEach((r) => { - values.push(r.balance, r.opening, r.closing); - [r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => { - values.push(lo, hi); + 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; @@ -231,120 +455,158 @@ export function FlowChart() { 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]); + }, [rows, groupCounts]); - const hasMovement = rows.some((r) => r.hasMovement); - const hasOther = rows.some((r) => r.other !== 0); + const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0); + const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1); + + // ——— دروازه‌ی تک‌متریالی: بدون متریال، نمودار رندر نمی‌شود ——— + if (!filter.materialId) { + return ( + + {options.isError ? ( + + ) : options.isLoading ? ( + + ) : ( +
+ + + +

{LABELS.needMaterial}

+ {materials.length > 0 && ( +
+ {LABELS.quickPick} +
+ {materials.map((material) => { + const materialUnit = + material.unit?.name_fa ?? material.unit?.code ?? ""; + return ( + + ); + })} +
+
+ )} +
+ )} +
+ ); + } return ( - + {query.isLoading ? ( + ) : query.isError ? ( + ) : !hasMovement ? ( ) : ( -
- - - - isoToJalaliStr(v) ?? v} - tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }} - tickLine={false} - axisLine={{ stroke: "#e5e9f0" }} - minTickGap={18} - /> - - } - /> - ( - - {String(value)} - +
+ {/* راهنمای رنگ دسته‌ها (به‌جای Legend پیش‌فرض؛ سری‌های تنالیته معنا ندارند) */} +
+ {visibleCategories.map((category) => ( + + + {CATEGORY_META[category].label} + + ))} + {hasMultipleShades && ( + {LABELS.shadeHint} + )} +
+ +
+ + + + isoToJalaliStr(v) ?? v} + tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }} + tickLine={false} + axisLine={{ stroke: "#e5e9f0" }} + minTickGap={18} + /> + + } + /> + {/* سگمنت‌های شناور: هر گروه (مشخصات+برند) یک پله از خانواده‌ی رنگ + دسته، با جداکننده‌ی سفید ۱px و یک ستون مشترک */} + {CATEGORY_ORDER.map((category) => + Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => ( + + )), )} - /> - {/* سگمنت‌های شناور آبشاری — هر جزء دقیقاً روی مسیر مانده می‌نشیند */} - - - - - {/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده می‌شود */} - {hasOther && ( - - )} - {/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */} - - - + + +
)} diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts index 86a395f..e98dd90 100644 --- a/frontend/src/hooks/useDashboardData.ts +++ b/frontend/src/hooks/useDashboardData.ts @@ -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(); return useQuery({ queryKey: ["dashboard", "flow", dateFrom, dateTo, params], queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }), + enabled, }); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 38e3678..451feef 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -23,7 +23,7 @@ import type { DailyControlData, DeadStockItem, DrilldownData, - FlowDay, + FlowData, InTransitRow, InventoryRow, InventoryTransaction, @@ -1928,8 +1928,8 @@ export const apiV26 = { getData(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`), 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))}`), - flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> => - getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`), + flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise => + getData(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`), dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise => getData(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`), workload: async (): Promise<{ projects: WorkloadProject[] }> => diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 5f93ec3..5e59d73 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -283,6 +283,20 @@ export interface DeadStockItem { } // — سری زمانی جریان روزانه — +// دسته‌های نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات +// (انتقال/شمارش/یافت‌شده که در چهار سبد نمایشی جا نمی‌گیرند) +export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other"; + +// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایه‌ی شکست چندبعدی +export interface FlowBreakdownItem { + category: FlowCategory; + transaction_type: string; + specs: Record | null; + brand: string | null; + warehouse: string; + quantity: number; // علامت‌دار مطابق دفتر (مثبت/منفی) +} + export interface FlowDay { date: string; // ISO (YYYY-MM-DD) inbound: number; @@ -290,6 +304,14 @@ export interface FlowDay { waste: number; returns: number; balance: number; + breakdown?: FlowBreakdownItem[]; +} + +export interface FlowData { + opening_balance: number; + // واحد کاتالوگی متریال انتخاب‌شده (فقط وقتی یک متریال فیلتر شده باشد) + unit: { code: string; name_fa: string | null } | null; + days: FlowDay[]; } // — کنترل روزانه —