@@ -148,7 +148,6 @@ class OrderRepository extends BaseRepository<IOrder> {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
console.log("matchQuery", matchQuery);
|
|
||||||
return this.model.aggregate([
|
return this.model.aggregate([
|
||||||
{
|
{
|
||||||
$match: matchQuery,
|
$match: matchQuery,
|
||||||
@@ -513,143 +512,6 @@ class OrderRepository extends BaseRepository<IOrder> {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrderChart(granularity: Gran = "daily", from?: string, to?: string) {
|
|
||||||
// تبدیل/نرمالایز تاریخها
|
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
||||||
const endDate = to ? new Date(to) : new Date();
|
|
||||||
endDate.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const defaultStart = new Date(Date.now() - 30 * MS_PER_DAY);
|
|
||||||
const startDate = from ? new Date(from) : defaultStart;
|
|
||||||
startDate.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
|
||||||
throw new Error("Invalid date format. Expected YYYY-MM-DD or ISO date.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate > endDate) {
|
|
||||||
// swap
|
|
||||||
const tmp = new Date(startDate);
|
|
||||||
(startDate as any) = new Date(endDate);
|
|
||||||
(endDate as any) = tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate granularity
|
|
||||||
const allowed: Record<Gran, "day" | "week" | "month"> = {
|
|
||||||
daily: "day",
|
|
||||||
weekly: "week",
|
|
||||||
monthly: "month",
|
|
||||||
};
|
|
||||||
const unit = allowed[granularity] ?? allowed["daily"];
|
|
||||||
|
|
||||||
const excludedOrderItemStatuses = [
|
|
||||||
OrderItemsStatus.cancelled_shop,
|
|
||||||
OrderItemsStatus.cancelled_system,
|
|
||||||
OrderItemsStatus.cancelled_user,
|
|
||||||
OrderItemsStatus.Returned,
|
|
||||||
];
|
|
||||||
|
|
||||||
const pipeline: any[] = [
|
|
||||||
{
|
|
||||||
$match: {
|
|
||||||
createdAt: { $gte: startDate, $lte: endDate },
|
|
||||||
orderStatus: {
|
|
||||||
$nin: [OrdersStatus.wait_payment, OrdersStatus.cancelled_system, OrdersStatus.Cancelled],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$lookup: {
|
|
||||||
from: "orderitems",
|
|
||||||
localField: "_id",
|
|
||||||
foreignField: "order",
|
|
||||||
as: "orderItems",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$addFields: {
|
|
||||||
orderItems: {
|
|
||||||
$filter: {
|
|
||||||
input: "$orderItems",
|
|
||||||
as: "item",
|
|
||||||
cond: {
|
|
||||||
$not: [{ $in: ["$$item.status", excludedOrderItemStatuses] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $unwind: "$orderItems" },
|
|
||||||
{
|
|
||||||
$addFields: {
|
|
||||||
lineTotal: { $ifNull: ["$orderItems.totalPaymentPrice", 0] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
$group: {
|
|
||||||
_id: {
|
|
||||||
period: {
|
|
||||||
$dateTrunc: {
|
|
||||||
date: "$createdAt",
|
|
||||||
unit: unit,
|
|
||||||
binSize: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
periodTotal: { $sum: "$lineTotal" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $sort: { "_id.period": 1 } },
|
|
||||||
];
|
|
||||||
|
|
||||||
const aggResults: Array<{ _id: { period: Date }; periodTotal: number }> = await this.model.aggregate(pipeline).exec();
|
|
||||||
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const r of aggResults) {
|
|
||||||
const d: Date = r._id.period;
|
|
||||||
const key = unit === "month" ? d.toISOString().slice(0, 7) : d.toISOString().slice(0, 10);
|
|
||||||
map.set(key, r.periodTotal ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper برای قدم زدن در دورهها
|
|
||||||
function formatKey(d: Date) {
|
|
||||||
return unit === "month" ? d.toISOString().slice(0, 7) : d.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
function addPeriod(d: Date) {
|
|
||||||
const nd = new Date(d);
|
|
||||||
if (unit === "day") nd.setDate(nd.getDate() + 1);
|
|
||||||
else if (unit === "week") nd.setDate(nd.getDate() + 7);
|
|
||||||
else nd.setMonth(nd.getMonth() + 1);
|
|
||||||
return nd;
|
|
||||||
}
|
|
||||||
|
|
||||||
const xAxis: string[] = [];
|
|
||||||
const yAxis: number[] = [];
|
|
||||||
|
|
||||||
let cursor = new Date(startDate);
|
|
||||||
if (unit === "month") cursor.setDate(1);
|
|
||||||
|
|
||||||
while (cursor <= endDate) {
|
|
||||||
const key = formatKey(cursor);
|
|
||||||
xAxis.push(key);
|
|
||||||
yAxis.push(map.get(key) ?? 0);
|
|
||||||
cursor = addPeriod(cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSales = yAxis.reduce((s, v) => s + v, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
chartType: "line",
|
|
||||||
title: "نمودار کل فروش",
|
|
||||||
unit: "تومان",
|
|
||||||
granularity,
|
|
||||||
xAxis,
|
|
||||||
yAxis,
|
|
||||||
totalSales,
|
|
||||||
_debug: { aggregatedPeriods: aggResults.length },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class OrderItemRepo extends BaseRepository<IOrderItem> {
|
class OrderItemRepo extends BaseRepository<IOrderItem> {
|
||||||
@@ -2406,13 +2268,32 @@ class OrderItemRepo extends BaseRepository<IOrderItem> {
|
|||||||
|
|
||||||
return salesData;
|
return salesData;
|
||||||
}
|
}
|
||||||
|
// Shared constants (place near top of file)
|
||||||
|
|
||||||
async getMonthlySalesReport() {
|
async getMonthlySalesReport() {
|
||||||
|
const EXCLUDED_ORDER_ITEM_STATUSES = [
|
||||||
|
OrderItemsStatus.cancelled_shop,
|
||||||
|
OrderItemsStatus.cancelled_system,
|
||||||
|
OrderItemsStatus.cancelled_user,
|
||||||
|
OrderItemsStatus.Returned,
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXCLUDED_ORDER_STATUSES = [OrdersStatus.wait_payment, OrdersStatus.cancelled_system, OrdersStatus.Cancelled];
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1);
|
const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1);
|
||||||
|
|
||||||
const salesData = await this.model.aggregate([
|
const salesData = await this.model.aggregate([
|
||||||
|
// filter items by month & item status first
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
createdAt: { $gte: startOfMonth, $lt: endOfMonth },
|
||||||
|
status: { $nin: EXCLUDED_ORDER_ITEM_STATUSES },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// join the parent order
|
||||||
{
|
{
|
||||||
$lookup: {
|
$lookup: {
|
||||||
from: "orders",
|
from: "orders",
|
||||||
@@ -2421,31 +2302,181 @@ class OrderItemRepo extends BaseRepository<IOrderItem> {
|
|||||||
as: "order",
|
as: "order",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ $unwind: "$order" },
|
||||||
|
|
||||||
|
// filter by parent order status
|
||||||
{
|
{
|
||||||
$match: {
|
$match: {
|
||||||
createdAt: {
|
"order.orderStatus": { $nin: EXCLUDED_ORDER_STATUSES },
|
||||||
$gte: startOfMonth,
|
|
||||||
$lt: endOfMonth,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
$nin: ["cancelled_shop", "cancelled_system", "Returned", OrdersStatus.wait_payment],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// aggregate totals and collect distinct order ids
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: null, // No need for grouping by date since it's filtered for the entire month
|
_id: null,
|
||||||
totalSales: { $sum: "$totalSellingPrice" },
|
totalSales: { $sum: "$totalSellingPrice" },
|
||||||
totalNetSales: { $sum: "$totalRetailPrice" },
|
totalNetSales: { $sum: "$totalRetailPrice" },
|
||||||
itemsSold: { $sum: "$itemsCount" },
|
itemsSold: { $sum: "$itemsCount" },
|
||||||
totalOrders: { $sum: 1 },
|
orderIds: { $addToSet: "$order._id" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// convert orderIds into a number
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
totalOrders: { $size: "$orderIds" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// optionally remove the array from results
|
||||||
|
{
|
||||||
|
$project: { orderIds: 0 },
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return salesData.length > 0 ? salesData[0] : null;
|
return salesData.length > 0 ? salesData[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOrderChart(granularity: Gran = "daily", from?: string, to?: string) {
|
||||||
|
const EXCLUDED_ORDER_ITEM_STATUSES = [
|
||||||
|
OrderItemsStatus.cancelled_shop,
|
||||||
|
OrderItemsStatus.cancelled_system,
|
||||||
|
OrderItemsStatus.cancelled_user,
|
||||||
|
OrderItemsStatus.Returned,
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXCLUDED_ORDER_STATUSES = [OrdersStatus.wait_payment, OrdersStatus.cancelled_system, OrdersStatus.Cancelled];
|
||||||
|
|
||||||
|
const DATE_TRUNC_TIMEZONE = "Asia/Tehran";
|
||||||
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// if user asked for monthly and did not give from/to, use calendar month
|
||||||
|
let startDate: Date;
|
||||||
|
let endDate: Date;
|
||||||
|
|
||||||
|
if (granularity === "monthly" && !from && !to) {
|
||||||
|
startDate = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 1);
|
||||||
|
} else {
|
||||||
|
endDate = to ? new Date(to) : new Date();
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
const defaultStart = new Date(Date.now() - 30 * MS_PER_DAY);
|
||||||
|
startDate = from ? new Date(from) : defaultStart;
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
throw new Error("Invalid date format. Expected YYYY-MM-DD or ISO date.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate > endDate) {
|
||||||
|
const tmp = startDate;
|
||||||
|
startDate = endDate;
|
||||||
|
endDate = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed: Record<Gran, "day" | "week" | "month"> = {
|
||||||
|
daily: "day",
|
||||||
|
weekly: "week",
|
||||||
|
monthly: "month",
|
||||||
|
};
|
||||||
|
const unit = allowed[granularity] ?? "day";
|
||||||
|
|
||||||
|
const pipeline: any[] = [
|
||||||
|
// 1. filter order items by date and item-status
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
createdAt: { $gte: startDate, $lte: endDate },
|
||||||
|
status: { $nin: EXCLUDED_ORDER_ITEM_STATUSES },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. join parent order and filter by order status
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "orders",
|
||||||
|
localField: "order",
|
||||||
|
foreignField: "_id",
|
||||||
|
as: "order",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $unwind: "$order" },
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
"order.orderStatus": { $nin: EXCLUDED_ORDER_STATUSES },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. group by truncated period (include timezone)
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: {
|
||||||
|
period: {
|
||||||
|
$dateTrunc: {
|
||||||
|
date: "$createdAt",
|
||||||
|
unit: unit,
|
||||||
|
binSize: 1,
|
||||||
|
timezone: DATE_TRUNC_TIMEZONE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
periodTotal: { $sum: "$totalSellingPrice" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{ $sort: { "_id.period": 1 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const aggResults: Array<{ _id: { period: Date }; periodTotal: number }> = await this.model.aggregate(pipeline).exec();
|
||||||
|
|
||||||
|
// build map and x/y arrays (same logic as you had)
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const r of aggResults) {
|
||||||
|
const d: Date = r._id.period;
|
||||||
|
const key = unit === "month" ? d.toISOString().slice(0, 7) : d.toISOString().slice(0, 10);
|
||||||
|
map.set(key, r.periodTotal ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKey(d: Date) {
|
||||||
|
return unit === "month" ? d.toISOString().slice(0, 7) : d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function addPeriod(d: Date) {
|
||||||
|
const nd = new Date(d);
|
||||||
|
if (unit === "day") nd.setDate(nd.getDate() + 1);
|
||||||
|
else if (unit === "week") nd.setDate(nd.getDate() + 7);
|
||||||
|
else nd.setMonth(nd.getMonth() + 1);
|
||||||
|
return nd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xAxis: string[] = [];
|
||||||
|
const yAxis: number[] = [];
|
||||||
|
|
||||||
|
let cursor = new Date(startDate);
|
||||||
|
if (unit === "month") cursor.setDate(1);
|
||||||
|
|
||||||
|
while (cursor <= endDate) {
|
||||||
|
const key = formatKey(cursor);
|
||||||
|
xAxis.push(key);
|
||||||
|
yAxis.push(map.get(key) ?? 0);
|
||||||
|
cursor = addPeriod(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSales = yAxis.reduce((s, v) => s + v, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chartType: "line",
|
||||||
|
title: "نمودار کل فروش",
|
||||||
|
unit: "تومان",
|
||||||
|
granularity,
|
||||||
|
xAxis,
|
||||||
|
yAxis,
|
||||||
|
totalSales,
|
||||||
|
_debug: { aggregatedPeriods: aggResults.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getAnnualSalesReport() {
|
async getAnnualSalesReport() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ class OrderService {
|
|||||||
//#######################################
|
//#######################################
|
||||||
//#######################################
|
//#######################################
|
||||||
async getSaleOrderChart(granularity?: Gran, from?: string, to?: string) {
|
async getSaleOrderChart(granularity?: Gran, from?: string, to?: string) {
|
||||||
return this.orderRepo.getOrderChart(granularity, from, to);
|
return this.orderItemRepo.getOrderChart(granularity, from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
//#######################################
|
//#######################################
|
||||||
|
|||||||
Reference in New Issue
Block a user