Files
tehran_taj/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts
T

270 lines
8.7 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/**
* 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.
*/
@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] },
};
/** 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,
{ customer: { referredByAgentId: 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: [],
};
}
}