Skip to content

Commit 29e7207

Browse files
authored
Feature/EF-183 Dashboard API (#70)
1 parent ed3b73c commit 29e7207

10 files changed

+349
-2
lines changed

package-lock.json

+11-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"@nestjs/passport": "^10.0.3",
3030
"@nestjs/platform-express": "^10.0.0",
3131
"@nestjs/swagger": "^7.2.0",
32-
"axios": "^1.6.7",
32+
"@types/moment": "^2.13.0",
33+
"axios": "^1.6.5",
3334
"bcrypt": "^5.1.1",
3435
"class-transformer": "^0.5.1",
3536
"class-validator": "^0.14.1",

src/analytic/analytic.module.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common'
2+
import { ProductModule } from '@product/product.module'
3+
import { CustomerModule } from '@customer/customer.module'
4+
import { AnalyticService } from '@analytic/services/analytic.service'
5+
import { CurrentAnalyticController } from '@analytic/controllers/current.controller'
6+
import { OrderModule } from '@order/order.module'
7+
import { PaymentModule } from '@payment/payment.module'
8+
import { StatisticAnalyticController } from './controllers/statistic.controller'
9+
10+
@Module({
11+
imports: [OrderModule, ProductModule, CustomerModule, PaymentModule],
12+
controllers: [StatisticAnalyticController, CurrentAnalyticController],
13+
providers: [AnalyticService],
14+
exports: [AnalyticService]
15+
})
16+
export class AnalyticModule {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
2+
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
3+
import * as _ from 'lodash'
4+
import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard'
5+
import { RolesGuard } from '@auth/guards/roles.guard'
6+
import { AnalyticPeriod, UserRole } from '@common/contracts/constant'
7+
import { Roles } from '@auth/decorators/roles.decorator'
8+
import { CurrentAnalyticResponseDto, QueryCurrentAnalyticDto } from '@analytic/dto/analytic.dto'
9+
import * as moment from 'moment'
10+
import { AnalyticService } from '@analytic/services/analytic.service'
11+
12+
@ApiTags('Analytic - Provider')
13+
@ApiBearerAuth()
14+
@UseGuards(JwtAuthGuard.ACCESS_TOKEN, RolesGuard)
15+
@Roles(UserRole.ADMIN, UserRole.STAFF)
16+
@Controller('analytics/current')
17+
export class CurrentAnalyticController {
18+
constructor(private readonly analyticService: AnalyticService) {}
19+
20+
@Get('orders')
21+
@ApiOperation({
22+
summary: 'View current order analytics in currentPeriod with previousPeriod'
23+
})
24+
@ApiOkResponse({ type: CurrentAnalyticResponseDto })
25+
getOrderCount(@Query() queryCurrentAnalyticDto: QueryCurrentAnalyticDto) {
26+
const current = moment()
27+
let unitOfTime
28+
switch (queryCurrentAnalyticDto.periodType) {
29+
case AnalyticPeriod.DAY:
30+
unitOfTime = 'day'
31+
break
32+
case AnalyticPeriod.MONTH:
33+
unitOfTime = 'month'
34+
break
35+
case AnalyticPeriod.YEAR:
36+
unitOfTime = 'year'
37+
break
38+
}
39+
const startOfCurrentPeriod = moment().startOf(unitOfTime)
40+
const startOfPreviousPeriod = moment().subtract(1, unitOfTime).startOf(unitOfTime)
41+
return this.analyticService.getOrderCount(current, startOfCurrentPeriod, startOfPreviousPeriod)
42+
}
43+
44+
@Get('sales')
45+
@ApiOperation({
46+
summary: 'View sales analytics in currentPeriod with previousPeriod'
47+
})
48+
@ApiOkResponse({ type: CurrentAnalyticResponseDto })
49+
async getSalesSum(@Query() queryCurrentAnalyticDto: QueryCurrentAnalyticDto) {
50+
const current = moment().toDate()
51+
let unitOfTime
52+
switch (queryCurrentAnalyticDto.periodType) {
53+
case AnalyticPeriod.DAY:
54+
unitOfTime = 'day'
55+
break
56+
case AnalyticPeriod.MONTH:
57+
unitOfTime = 'month'
58+
break
59+
case AnalyticPeriod.YEAR:
60+
unitOfTime = 'year'
61+
break
62+
}
63+
const startOfCurrentPeriod = moment().startOf(unitOfTime).toDate()
64+
const startOfPreviousPeriod = moment().subtract(1, unitOfTime).startOf(unitOfTime).toDate()
65+
return this.analyticService.getSalesSum(current, startOfCurrentPeriod, startOfPreviousPeriod)
66+
}
67+
68+
@Get('products')
69+
@ApiOperation({
70+
summary: 'View product analytics in currentPeriod with previousPeriod'
71+
})
72+
@ApiOkResponse({ type: CurrentAnalyticResponseDto })
73+
async getProductCount(@Query() queryCurrentAnalyticDto: QueryCurrentAnalyticDto) {
74+
const current = moment()
75+
let unitOfTime
76+
switch (queryCurrentAnalyticDto.periodType) {
77+
case AnalyticPeriod.DAY:
78+
unitOfTime = 'day'
79+
break
80+
case AnalyticPeriod.MONTH:
81+
unitOfTime = 'month'
82+
break
83+
case AnalyticPeriod.YEAR:
84+
unitOfTime = 'year'
85+
break
86+
}
87+
const startOfCurrentPeriod = moment().startOf(unitOfTime)
88+
const startOfPreviousPeriod = moment().subtract(1, unitOfTime).startOf(unitOfTime)
89+
return this.analyticService.getProductCount(current, startOfCurrentPeriod, startOfPreviousPeriod)
90+
}
91+
92+
@Get('customers')
93+
@ApiOperation({
94+
summary: 'View customer analytics in currentPeriod with previousPeriod'
95+
})
96+
@ApiOkResponse({ type: CurrentAnalyticResponseDto })
97+
async getCustomerCount(@Query() queryCurrentAnalyticDto: QueryCurrentAnalyticDto) {
98+
const current = moment()
99+
let unitOfTime
100+
switch (queryCurrentAnalyticDto.periodType) {
101+
case AnalyticPeriod.DAY:
102+
unitOfTime = 'day'
103+
break
104+
case AnalyticPeriod.MONTH:
105+
unitOfTime = 'month'
106+
break
107+
case AnalyticPeriod.YEAR:
108+
unitOfTime = 'year'
109+
break
110+
}
111+
const startOfCurrentPeriod = moment().startOf(unitOfTime)
112+
const startOfPreviousPeriod = moment().subtract(1, unitOfTime).startOf(unitOfTime)
113+
return this.analyticService.getCustomerCount(current, startOfCurrentPeriod, startOfPreviousPeriod)
114+
}
115+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
2+
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'
3+
import * as _ from 'lodash'
4+
import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard'
5+
import { RolesGuard } from '@auth/guards/roles.guard'
6+
import { UserRole } from '@common/contracts/constant'
7+
import { Roles } from '@auth/decorators/roles.decorator'
8+
import { QueryStatisticAnalyticDto, StatisticAnalyticResponseDto } from '@analytic/dto/analytic.dto'
9+
import { AnalyticService } from '@analytic/services/analytic.service'
10+
11+
@ApiTags('Analytic - Provider')
12+
@ApiBearerAuth()
13+
@UseGuards(JwtAuthGuard.ACCESS_TOKEN, RolesGuard)
14+
@Roles(UserRole.ADMIN, UserRole.STAFF)
15+
@Controller('analytics/statistic')
16+
export class StatisticAnalyticController {
17+
constructor(private readonly analyticService: AnalyticService) {}
18+
19+
@Get('sales')
20+
@ApiOperation({
21+
summary: 'View sales statistics'
22+
})
23+
@ApiOkResponse({ type: StatisticAnalyticResponseDto })
24+
async getSalesStatistics(@Query() queryStatisticAnalyticDto: QueryStatisticAnalyticDto) {
25+
return this.analyticService.getSalesStatistic(queryStatisticAnalyticDto)
26+
}
27+
}

src/analytic/dto/analytic.dto.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { AnalyticPeriod } from '@common/contracts/constant'
2+
import { ApiProperty } from '@nestjs/swagger'
3+
import { DataResponse } from '@src/common/contracts/openapi-builder'
4+
import { Type } from 'class-transformer'
5+
import { IsEnum, IsNumber, Max, Min } from 'class-validator'
6+
7+
export class QueryCurrentAnalyticDto {
8+
@ApiProperty({
9+
enum: AnalyticPeriod
10+
})
11+
@IsEnum(AnalyticPeriod)
12+
periodType: AnalyticPeriod
13+
}
14+
15+
export class CurrentAnalyticDto {
16+
@ApiProperty()
17+
previousTotal: number
18+
19+
@ApiProperty()
20+
total: number
21+
22+
@ApiProperty()
23+
percent: number
24+
}
25+
26+
export class CurrentAnalyticResponseDto extends DataResponse(CurrentAnalyticDto) {}
27+
28+
export class QueryStatisticAnalyticDto {
29+
@ApiProperty({
30+
type: Number,
31+
example: 2024
32+
})
33+
@Type(() => Number)
34+
@IsNumber()
35+
@Max(2050)
36+
@Min(2020)
37+
year: number
38+
}
39+
40+
export class StatisticAnalyticDto {
41+
statistic: number[]
42+
}
43+
44+
export class StatisticAnalyticResponseDto extends DataResponse(StatisticAnalyticDto) {}
+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { Injectable } from '@nestjs/common'
2+
import { OrderRepository } from '@order/repositories/order.repository'
3+
import { ProductRepository } from '@product/repositories/product.repository'
4+
import { CustomerRepository } from '@customer/repositories/customer.repository'
5+
import { PaymentRepository } from '@payment/repositories/payment.repository'
6+
import { OrderStatus, ProductStatus, Status, TransactionStatus } from '@common/contracts/constant'
7+
import type { Moment } from 'moment'
8+
import * as moment from 'moment'
9+
import { QueryStatisticAnalyticDto } from '@analytic/dto/analytic.dto'
10+
11+
@Injectable()
12+
export class AnalyticService {
13+
constructor(
14+
private readonly orderRepository: OrderRepository,
15+
private readonly productRepository: ProductRepository,
16+
private readonly customerRepository: CustomerRepository,
17+
private readonly paymentRepository: PaymentRepository
18+
) {}
19+
20+
public async getOrderCount(current: Moment, startOfCurrentPeriod: Moment, startOfPreviousPeriod: Moment) {
21+
const filter = {
22+
orderStatus: {
23+
$in: [OrderStatus.PENDING, OrderStatus.CONFIRMED, OrderStatus.DELIVERING, OrderStatus.COMPLETED]
24+
},
25+
transactionStatus: {
26+
$in: [TransactionStatus.CAPTURED, TransactionStatus.DRAFT, TransactionStatus.ERROR]
27+
}
28+
}
29+
const [total, previousTotal] = await Promise.all([
30+
this.orderRepository.model.countDocuments({
31+
...filter,
32+
createdAt: { $lte: current, $gte: startOfCurrentPeriod }
33+
}),
34+
this.orderRepository.model.countDocuments({
35+
...filter,
36+
createdAt: { $lte: startOfCurrentPeriod, $gte: startOfPreviousPeriod }
37+
})
38+
])
39+
const percent = previousTotal !== 0 ? Math.round(((total - previousTotal) / previousTotal) * 100 * 100) / 100 : 0
40+
return { total, previousTotal, percent }
41+
}
42+
43+
public async getSalesSum(current: Date, startOfCurrentPeriod: Date, startOfPreviousPeriod: Date) {
44+
const filter = {
45+
transactionStatus: TransactionStatus.CAPTURED
46+
}
47+
const [sumTotal, previousSumTotal] = await Promise.all([
48+
this.paymentRepository.model.aggregate([
49+
{ $match: { ...filter, createdAt: { $lte: current, $gte: startOfCurrentPeriod } } },
50+
{
51+
$group: { _id: null, amount: { $sum: '$amount' } }
52+
}
53+
]),
54+
this.paymentRepository.model.aggregate([
55+
{ $match: { ...filter, createdAt: { $lte: startOfCurrentPeriod, $gte: startOfPreviousPeriod } } },
56+
{
57+
$group: { _id: null, amount: { $sum: '$amount' } }
58+
}
59+
])
60+
])
61+
const total = sumTotal[0]?.amount || 0
62+
const previousTotal = previousSumTotal[0]?.amount || 0
63+
const percent = previousTotal !== 0 ? Math.round(((total - previousTotal) / previousTotal) * 100 * 100) / 100 : 0
64+
return { total, previousTotal, percent }
65+
}
66+
67+
public async getProductCount(current: Moment, startOfCurrentPeriod: Moment, startOfPreviousPeriod: Moment) {
68+
const filter = {
69+
status: { $ne: ProductStatus.DELETED }
70+
}
71+
const [total, previousTotal] = await Promise.all([
72+
this.productRepository.model.countDocuments({
73+
...filter,
74+
createdAt: { $lte: current, $gte: startOfCurrentPeriod }
75+
}),
76+
this.productRepository.model.countDocuments({
77+
...filter,
78+
createdAt: { $lte: startOfCurrentPeriod, $gte: startOfPreviousPeriod }
79+
})
80+
])
81+
const percent = previousTotal !== 0 ? Math.round(((total - previousTotal) / previousTotal) * 100 * 100) / 100 : 0
82+
return { total, previousTotal, percent }
83+
}
84+
85+
public async getCustomerCount(current: Moment, startOfCurrentPeriod: Moment, startOfPreviousPeriod: Moment) {
86+
const filter = {
87+
status: { $ne: Status.DELETED }
88+
}
89+
const [total, previousTotal] = await Promise.all([
90+
this.customerRepository.model.countDocuments({
91+
...filter,
92+
createdAt: { $lte: current, $gte: startOfCurrentPeriod }
93+
}),
94+
this.customerRepository.model.countDocuments({
95+
...filter,
96+
createdAt: { $lte: startOfCurrentPeriod, $gte: startOfPreviousPeriod }
97+
})
98+
])
99+
const percent = previousTotal !== 0 ? Math.round(((total - previousTotal) / previousTotal) * 100 * 100) / 100 : 0
100+
return { total, previousTotal, percent }
101+
}
102+
103+
public async getSalesStatistic(queryStatisticAnalyticDto: QueryStatisticAnalyticDto) {
104+
const year = queryStatisticAnalyticDto.year
105+
const filter = {
106+
transactionStatus: TransactionStatus.CAPTURED
107+
}
108+
const operations = []
109+
for (let i = 0; i < 12; i++) {
110+
const startOfMonth = moment([year, i]).startOf('month')
111+
const endOfMonth = startOfMonth.clone().endOf('month')
112+
operations.push(
113+
this.paymentRepository.model.aggregate([
114+
{ $match: { ...filter, createdAt: { $gte: startOfMonth.toDate(), $lte: endOfMonth.toDate() } } },
115+
{
116+
$group: { _id: null, amount: { $sum: '$amount' } }
117+
}
118+
])
119+
)
120+
}
121+
const result = await Promise.all(operations)
122+
const statistic = result.map((sumTotal) => sumTotal[0]?.amount || 0)
123+
return { statistic }
124+
}
125+
}

src/app.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { ProviderModule } from '@provider/provider.module'
2222
import { VisitShowroomBookingModule } from '@visit-showroom-booking/booking.module'
2323
import { ConsultantBookingModule } from '@consultant-booking/booking.module'
2424
import { TaskModule } from '@task/task.module'
25+
import { AnalyticModule } from '@analytic/analytic.module'
2526
import { PaymentModule } from '@payment/payment.module'
2627

2728
@Module({
@@ -119,6 +120,7 @@ import { PaymentModule } from '@payment/payment.module'
119120
VisitShowroomBookingModule,
120121
ConsultantBookingModule,
121122
TaskModule,
123+
AnalyticModule,
122124
PaymentModule
123125
],
124126
controllers: [AppController],

src/common/contracts/constant.ts

+6
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,9 @@ export enum TaskStatus {
8686
COMPLETED = 'COMPLETED',
8787
DELETED = 'DELETED'
8888
}
89+
90+
export enum AnalyticPeriod {
91+
DAY = 'DAY',
92+
MONTH = 'MONTH',
93+
YEAR = 'YEAR',
94+
}

0 commit comments

Comments
 (0)