پنل: بخشهای «تک فروشی» داشبورد (کارت، نمودار روند و تفکیک عمده/تک) فقط برای مدیر اصلی نمایش داده میشود. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
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`).
|
|
* Retail («تک فروشی») figures are only built for the super admin; for anyone
|
|
* else they never leave the server.
|
|
*/
|
|
async overview(viewerIsSuperAdmin = false) {
|
|
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<string, { total: number; retail: number; wholesale: number }>();
|
|
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;
|
|
}
|
|
// Non-super admins get wholesale only: their `total` is the wholesale count
|
|
// and there is no `retail` field, so the retail number cannot be derived.
|
|
const ordersTrend = [...trendByDay.entries()].map(([date, v]) =>
|
|
viewerIsSuperAdmin ? { date, ...v } : { date, total: v.wholesale, wholesale: v.wholesale },
|
|
);
|
|
|
|
const statusLabels: Record<OrderStatus, string> = {
|
|
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<string, string> = { RETAIL: 'تک فروشی', WHOLESALE: 'عمده فروشی' };
|
|
const channelBreakdown = viewerIsSuperAdmin
|
|
? 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<string, { title: string; weight: Prisma.Decimal; qty: number }>();
|
|
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<string> }
|
|
>();
|
|
|
|
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<string>(),
|
|
};
|
|
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<OrderStatus, string> = {
|
|
SUSPENDED: 'معلق (سقف اعتبار)',
|
|
PLACED: 'ثبت شده',
|
|
ADMIN_APPROVED: 'تایید مدیر',
|
|
SOURCING: 'در حال تامین',
|
|
PREPARING: 'در حال آمادهسازی',
|
|
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
|
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
|
CANCELLED: 'لغو شده',
|
|
};
|
|
// legacy reads `statusColor` straight off the row
|
|
const colors: Record<OrderStatus, string> = {
|
|
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: [],
|
|
};
|
|
}
|
|
}
|