import { Injectable } from '@nestjs/common'; import { OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { agentSalesWhere } from '../sales-agent/agent-orders'; /** * Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and * views/DashboardAnalytics.vue (`GET get-analytics-tak`). * * Legacy's ECommerce page is a Vuexy template with most of its English demo * cards COMMENTED OUT (Congratulations, Support Tracker, Product Orders, Sales * Stats, Activity Timeline — verified line by line). What is actually live: * - three 30-day stat cards: new customers, gold sold, order count * - "عملکرد کارشناسان فروش": agents ranked by grams sold * - "میزان فروش": sales volume * - "سفارشات هفته جاری": this week's orders * * So this returns exactly those figures rather than the template's demo series. * * The two dashboards (عمده/تک) were merged into one screen behind one rid * (dashboard.view) — see `overview()`, which returns the union of both legacy * payloads (nothing dropped) plus new chart aggregates built from data that * already exists (order channel, status, GoldPriceQuote, customer debt). */ @Injectable() export class AdminDashboardService { constructor(private readonly prisma: PrismaService) {} private daysAgo(n: number) { const d = new Date(); d.setDate(d.getDate() - n); return d; } /** Orders that have reached the debt-recognition point count as "sold". */ private static readonly SOLD: Prisma.OrderWhereInput = { status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] }, }; /** The single merged dashboard endpoint (`GET admin/dashboard/overview`). */ async overview() { const since30 = this.daysAgo(30); const since7 = this.daysAgo(7); const [ omde, tak, ordersLast30, statusGroups, channelGroups, topProductRows, latestGoldQuote, debtAgg, ] = await Promise.all([ this.omdeAnalytics(), this.takAnalytics(), // روند سفارش‌ها در ۳۰ روز گذشته — one row per order, bucketed by day below. this.prisma.order.findMany({ where: { createdAt: { gte: since30 } }, select: { createdAt: true, channel: true }, }), // دونات وضعیت سفارشات (۳۰ روز گذشته) this.prisma.order.groupBy({ by: ['status'], where: { createdAt: { gte: since30 } }, _count: true, }), // تفکیک عمده/تک (۳۰ روز گذشته) this.prisma.order.groupBy({ by: ['channel'], where: { createdAt: { gte: since30 } }, _count: true, _sum: { totalGoldWeightGrams: true }, }), // محصولات پرفروش (۳۰ روز گذشته) — by shipped weight, top 6. this.prisma.orderItem.findMany({ where: { order: { createdAt: { gte: since30 } } }, select: { quantity: true, computedWeightGrams: true, productVariant: { select: { product: { select: { id: true, title: true } } } }, }, }), // نرخ فعلی طلا — legacy's goldPrice widget (18 عیار / 750‰). this.prisma.goldPriceQuote.findFirst({ where: { karatPerMille: 750 }, orderBy: { quotedAt: 'desc' }, }), // مجموع بدهی مشتریان (گرم طلا) — cached balance, see Customer.goldDebtGrams. this.prisma.customer.aggregate({ _sum: { goldDebtGrams: true } }), ]); // Bucket the 30-day order list into day-strings client-side: Prisma has no // portable "group by day" across the sqlite/postgres split this project // supports, and 30 days of order rows is tiny. const trendByDay = new Map(); for (let i = 29; i >= 0; i--) { trendByDay.set(this.dayKey(this.daysAgo(i)), { total: 0, retail: 0, wholesale: 0 }); } for (const o of ordersLast30) { const key = this.dayKey(o.createdAt); const bucket = trendByDay.get(key); if (!bucket) continue; // safety net; every key in range was pre-seeded above bucket.total += 1; if (o.channel === 'WHOLESALE') bucket.wholesale += 1; else bucket.retail += 1; } const ordersTrend = [...trendByDay.entries()].map(([date, v]) => ({ date, ...v })); const statusLabels: Record = { SUSPENDED: 'معلق (سقف اعتبار)', PLACED: 'ثبت شده', ADMIN_APPROVED: 'تایید مدیر', SOURCING: 'در حال تامین', PREPARING: 'در حال آماده‌سازی', DELIVERED_TO_AGENT: 'تحویل به نماینده', DELIVERED_TO_CUSTOMER: 'تحویل به مشتری', CANCELLED: 'لغو شده', }; const statusBreakdown = statusGroups.map((g) => ({ status: g.status, label: statusLabels[g.status], count: g._count, })); const channelLabels: Record = { RETAIL: 'تک فروشی', WHOLESALE: 'عمده فروشی' }; const channelBreakdown = channelGroups.map((g) => ({ channel: g.channel, label: channelLabels[g.channel] ?? g.channel, count: g._count, grams: g._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0), })); const productTotals = new Map(); for (const it of topProductRows) { const p = it.productVariant.product; const row = productTotals.get(p.id) ?? { title: p.title, weight: new Prisma.Decimal(0), qty: 0 }; row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity)); row.qty += it.quantity; productTotals.set(p.id, row); } const topProducts = [...productTotals.values()] .sort((a, b) => Number(b.weight) - Number(a.weight)) .slice(0, 6); return { // ── everything the two legacy dashboards returned, unchanged ────── ...omde, takLastOrder: tak.lastOrder, // ── new chart data ───────────────────────────────────────────── ordersTrend, statusBreakdown, channelBreakdown, topProducts, goldPricePerGram: latestGoldQuote?.pricePerGramRial ?? null, goldPriceQuotedAt: latestGoldQuote?.quotedAt ?? null, totalCustomerDebtGrams: debtAgg._sum.goldDebtGrams ?? new Prisma.Decimal(0), }; } private dayKey(d: Date): string { return d.toISOString().slice(0, 10); } /** Legacy `GET get-analytics` — the wholesale dashboard. */ async omdeAnalytics() { const since30 = this.daysAgo(30); const since7 = this.daysAgo(7); const [customerCount, gramsAgg, orderCount, lastOrderRows, agents, monthAgg] = await Promise.all([ // تعداد مشتریان ثبت نامی در 30 روز گذشته this.prisma.customer.count({ where: { createdAt: { gte: since30 } } }), // طلای فروش رفته در 30 روز گذشته this.prisma.order.aggregate({ where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] }, _sum: { totalGoldWeightGrams: true }, }), // تعداد سفارشات 30 روز گذشته this.prisma.order.count({ where: { createdAt: { gte: since30 } } }), // سفارشات هفته جاری this.prisma.order.findMany({ where: { createdAt: { gte: since7 } }, orderBy: { createdAt: 'desc' }, take: 50, include: { customer: true, branch: true }, }), // عملکرد کارشناسان فروش this.prisma.adminUser.findMany({ where: { role: 'SALES_EXPERT', isActive: true }, select: { id: true, fullName: true }, }), // میزان فروش (same 30-day window, kept separate for clarity) this.prisma.order.aggregate({ where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] }, _sum: { totalGoldWeightGrams: true }, _count: true, }), ]); // Grams sold per agent, through the customers they referred. The view sorts // this itself by grams_sell, so the shape matters more than the order. const reagent = await Promise.all( agents.map(async (a) => { const agg = await this.prisma.order.aggregate({ where: { AND: [ AdminDashboardService.SOLD, agentSalesWhere(a.id), { createdAt: { gte: since30 } }, ], }, _sum: { totalGoldWeightGrams: true }, _count: true, }); return { id: a.id, name: a.fullName, grams_sell: agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0), order_count: agg._count, }; }), ); return { customerCount, allgram: gramsAgg._sum.totalGoldWeightGrams ?? 0, ordercount: orderCount, reagent, lastOrder: lastOrderRows.map((o) => this.orderRow(o)), sumLastOrder: monthAgg._count, sumLastOrderMonth: monthAgg._sum.totalGoldWeightGrams ?? 0, }; } /** Legacy `GET get-analytics-tak` — the retail dashboard. */ async takAnalytics() { const since7 = this.daysAgo(7); const rows = await this.prisma.order.findMany({ where: { createdAt: { gte: since7 } }, orderBy: { createdAt: 'desc' }, take: 50, include: { customer: true, branch: true }, }); // The retail view reads only `model.lastOrder`; the rest of its cards are // the Vuexy demo series and are not rendered from server data. return { lastOrder: rows.map((o) => this.orderRow(o)) }; } /** * Legacy `report/order_item` — "گزارش تجمیعی سفارشات و اسناد مالی": order * lines aggregated over a date range. */ async orderItemReport(from?: string, to?: string) { const where = this.dateWhere(from, to); const orders = await this.prisma.order.findMany({ where, include: { customer: true, items: { include: { productVariant: { include: { product: true } } } }, }, orderBy: { createdAt: 'desc' }, }); const rows = orders.flatMap((o) => o.items.map((it) => ({ order_id: o.id, order_number: o.orderNumber, customer: o.customer.fullName, product: it.productVariant.product.title, variant: it.productVariant.title, qty: it.quantity, w: it.computedWeightGrams, total_w: it.computedWeightGrams.mul(it.quantity), caj: o.createdAt, })), ); const totalWeight = rows.reduce( (s, r) => s.add(r.total_w), new Prisma.Decimal(0), ); return { rows, summary: { order_count: orders.length, item_count: rows.length, total_weight: totalWeight, }, }; } /** Legacy `report/product` — per-product sales over a date range. */ async productReport(from?: string, to?: string) { const where = this.dateWhere(from, to); const orders = await this.prisma.order.findMany({ where, include: { items: { include: { productVariant: { include: { product: true } } } } }, }); const byProduct = new Map< string, { id: string; title: string; qty: number; weight: Prisma.Decimal; orders: Set } >(); for (const o of orders) { for (const it of o.items) { const p = it.productVariant.product; const row = byProduct.get(p.id) ?? { id: p.id, title: p.title, qty: 0, weight: new Prisma.Decimal(0), orders: new Set(), }; row.qty += it.quantity; row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity)); row.orders.add(o.id); byProduct.set(p.id, row); } } const rows = [...byProduct.values()] .map((r) => ({ id: r.id, title: r.title, total: r.qty, w: r.weight, order_count: r.orders.size, })) .sort((a, b) => Number(b.w) - Number(a.w)); return { rows, summary: { product_count: rows.length } }; } /** Shared date filter for both reports; an open end stays open. */ private dateWhere(from?: string, to?: string): Prisma.OrderWhereInput { if (!from && !to) return {}; const createdAt: Prisma.DateTimeFilter = {}; if (from) createdAt.gte = new Date(from); if (to) { // Include the whole end day, not just its midnight. const end = new Date(to); end.setHours(23, 59, 59, 999); createdAt.lte = end; } return { createdAt }; } /** The row shape both dashboards' tables read. */ private orderRow( o: Prisma.OrderGetPayload<{ include: { customer: true; branch: true } }>, ) { const labels: Record = { SUSPENDED: 'معلق (سقف اعتبار)', PLACED: 'ثبت شده', ADMIN_APPROVED: 'تایید مدیر', SOURCING: 'در حال تامین', PREPARING: 'در حال آماده‌سازی', DELIVERED_TO_AGENT: 'تحویل به نماینده', DELIVERED_TO_CUSTOMER: 'تحویل به مشتری', CANCELLED: 'لغو شده', }; // legacy reads `statusColor` straight off the row const colors: Record = { SUSPENDED: 'warning', PLACED: 'primary', ADMIN_APPROVED: 'primary', SOURCING: 'warning', PREPARING: 'warning', DELIVERED_TO_AGENT: 'success', DELIVERED_TO_CUSTOMER: 'success', CANCELLED: 'danger', }; return { id: o.id, name: o.orderNumber, customer: o.customer.fullName, w: o.totalGoldWeightGrams, sts_str: labels[o.status], statusColor: colors[o.status], caj: o.createdAt, // legacy rendered a parent/children tree for split orders; the redesigned // schema has no such relation, so both are empty rather than faked. parent: null, children: [], }; } }