diff --git a/src/common/utils/date-formatter.utils.ts b/src/common/utils/date-formatter.utils.ts new file mode 100644 index 0000000..ccf4d80 --- /dev/null +++ b/src/common/utils/date-formatter.utils.ts @@ -0,0 +1,82 @@ +import dayjs, { Dayjs } from 'dayjs' +import 'dayjs/locale/fa' +import relativeTime from 'dayjs/plugin/relativeTime' +import jalaliPlugin from 'jalaliday/dayjs' + +dayjs.extend(jalaliPlugin) +dayjs.extend(relativeTime) + +export function toJalali(date: string | number | Date | Dayjs): Date { + return dayjs(date).calendar('jalali').toDate() +} + +export function toGregorian(date: string | number | Date | Dayjs): Date { + return dayjs(date).calendar('gregory').toDate() +} + +export function getCurrentJalaliSeasonStart( + baseDate: string | number | Date | Dayjs = dayjs(), +): Date { + const date = dayjs(baseDate).calendar('jalali') + const month = date.month() + 1 + const seasonStartMonth = Math.floor((month - 1) / 3) * 3 + 1 + return date.month(seasonStartMonth - 1).startOf('month').startOf('day').toDate() +} + +export function getCurrentJalaliSeasonEnd( + baseDate: string | number | Date | Dayjs = dayjs(), +): Date { + return dayjs(getCurrentJalaliSeasonStart(baseDate)) + .add(2, 'month') + .endOf('month') + .endOf('day') + .toDate() +} + +export function getCurrentGregorianSeasonStart( + baseDate: string | number | Date | Dayjs = dayjs(), +): Date { + const date = dayjs(baseDate).calendar('gregory') + const month = date.month() + 1 + const seasonStartMonth = Math.floor((month - 1) / 3) * 3 + 1 + return date.month(seasonStartMonth - 1).startOf('month').startOf('day').toDate() +} + +export function getCurrentGregorianSeasonEnd( + baseDate: string | number | Date | Dayjs = dayjs(), +): Date { + return dayjs(getCurrentGregorianSeasonStart(baseDate)) + .add(2, 'month') + .endOf('month') + .endOf('day') + .toDate() +} + +export function getCurrentJalaliSeasonRange( + baseDate: string | number | Date | Dayjs = dayjs(), +): { start: Date; end: Date } { + return { + start: getCurrentJalaliSeasonStart(baseDate), + end: getCurrentJalaliSeasonEnd(baseDate), + } +} + +export function getCurrentGregorianSeasonRange( + baseDate: string | number | Date | Dayjs = dayjs(), +): { start: Date; end: Date } { + return { + start: getCurrentGregorianSeasonStart(baseDate), + end: getCurrentGregorianSeasonEnd(baseDate), + } +} + +export default { + toJalali, + toGregorian, + getCurrentJalaliSeasonStart, + getCurrentJalaliSeasonEnd, + getCurrentGregorianSeasonStart, + getCurrentGregorianSeasonEnd, + getCurrentJalaliSeasonRange, + getCurrentGregorianSeasonRange, +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 41505bb..c75a681 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,3 +1,4 @@ +export * from './date-formatter.utils' export * from './enum-translator.util' export * from './field-validator.util' export * from './http-client.util' diff --git a/src/common/utils/redisKeyMaker/pos.ts b/src/common/utils/redisKeyMaker/pos.ts index a71bd14..98436eb 100644 --- a/src/common/utils/redisKeyMaker/pos.ts +++ b/src/common/utils/redisKeyMaker/pos.ts @@ -14,4 +14,50 @@ export class PosKeyMaker { static goodListByGuildPattern(guildId: string): string { return `pos:goods:list:guildId:${guildId}:ba:*` } + + static statisticInvoicesPattern(businessActivityId: string): string { + return `pos:statistics:invoices:ba:${businessActivityId}:*` + } + static statisticInvoices( + businessActivityId: string, + posId: string, + from: string, + ): string { + return `pos:statistics:invoices:ba:${businessActivityId}:pos:${posId}:from:${from}:all` + } + static statisticInvoicesNotSent( + businessActivityId: string, + posId: string, + from: string, + ): string { + return `pos:statistics:invoices:ba:${businessActivityId}:pos:${posId}:from:${from}:not-sent` + } + static statisticInvoicesSuccess( + businessActivityId: string, + posId: string, + from: string, + ): string { + return `pos:statistics:invoices:ba:${businessActivityId}:pos:${posId}:from:${from}:success` + } + static statisticInvoicesFailure( + businessActivityId: string, + posId: string, + from: string, + ): string { + return `pos:statistics:invoices:ba:${businessActivityId}:pos:${posId}:from:${from}:failure` + } + static statisticInvoicesPending( + businessActivityId: string, + posId: string, + from: string, + ): string { + return `pos:statistics:invoices:ba:${businessActivityId}:pos:${posId}:from:${from}:pending` + } + static statisticInvoicesCredit( + businessActivityId: string, + posId: string, + from: string, + ): string { + return `pos:statistics:invoices:ba:${businessActivityId}:pos:${posId}:from:${from}:credit` + } } diff --git a/src/modules/pos/cache/pos-cache-invalidation.service.ts b/src/modules/pos/cache/pos-cache-invalidation.service.ts index 6af624e..a29a5fd 100644 --- a/src/modules/pos/cache/pos-cache-invalidation.service.ts +++ b/src/modules/pos/cache/pos-cache-invalidation.service.ts @@ -16,4 +16,72 @@ export class PosCacheInvalidationService { async invalidatePosGoodsByGuildPattern(guildId: string): Promise { this.redisService.deleteByPattern(PosKeyMaker.goodListByGuildPattern(guildId)) } + + async invalidatePosStatisticsInvoicesByBusinessActivityId( + businessActivityId: string, + ): Promise { + this.redisService.deleteByPattern( + PosKeyMaker.statisticInvoicesPattern(businessActivityId), + ) + } + + async invalidatePosStatisticsInvoicesAll( + businessActivityId: string, + posId: string, + from: string, + ): Promise { + this.redisService.delete( + PosKeyMaker.statisticInvoices(businessActivityId, posId, from), + ) + } + + async invalidatePosStatisticsInvoicesFailure( + businessActivityId: string, + posId: string, + from: string, + ): Promise { + this.redisService.delete( + PosKeyMaker.statisticInvoicesFailure(businessActivityId, posId, from), + ) + } + + async invalidatePosStatisticsInvoicesSuccess( + businessActivityId: string, + posId: string, + from: string, + ): Promise { + this.redisService.delete( + PosKeyMaker.statisticInvoicesSuccess(businessActivityId, posId, from), + ) + } + + async invalidatePosStatisticsInvoicesPending( + businessActivityId: string, + posId: string, + from: string, + ): Promise { + this.redisService.delete( + PosKeyMaker.statisticInvoicesPending(businessActivityId, posId, from), + ) + } + + async invalidatePosStatisticsInvoicesCredit( + businessActivityId: string, + posId: string, + from: string, + ): Promise { + this.redisService.delete( + PosKeyMaker.statisticInvoicesCredit(businessActivityId, posId, from), + ) + } + + async invalidatePosStatisticsInvoicesNotSent( + businessActivityId: string, + posId: string, + from: string, + ): Promise { + this.redisService.delete( + PosKeyMaker.statisticInvoicesNotSent(businessActivityId, posId, from), + ) + } } diff --git a/src/modules/pos/pos.module.ts b/src/modules/pos/pos.module.ts index 56d3b76..2764bef 100644 --- a/src/modules/pos/pos.module.ts +++ b/src/modules/pos/pos.module.ts @@ -8,6 +8,7 @@ import { PosController } from './pos.controller' import { PosMiddleware } from './pos.middleware' import { PosService } from './pos.service' import { PosSalesInvoicesModule } from './sales-invoices/sales-invoices.module' +import { StatisticsModule } from './statistics/statistics.module' @Module({ controllers: [PosController], @@ -18,6 +19,7 @@ import { PosSalesInvoicesModule } from './sales-invoices/sales-invoices.module' PosGoodsModule, PosSalesInvoicesModule, OwnedGoodsModule, + StatisticsModule, ], }) export class PosModule implements NestModule { diff --git a/src/modules/pos/statistics/statistics.controller.ts b/src/modules/pos/statistics/statistics.controller.ts new file mode 100644 index 0000000..40a6a3e --- /dev/null +++ b/src/modules/pos/statistics/statistics.controller.ts @@ -0,0 +1,20 @@ +import { PosInfo } from '@/common/decorators/posInfo.decorator' +import type { IPosPayload } from '@/common/models' +import { Controller, Get, Query } from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' +import { StatisticsService } from './statistics.service' + +@ApiTags('PosStatistics') +@Controller('pos/statistics') +export class StatisticsController { + constructor(private readonly service: StatisticsService) {} + + @Get('sale-invoices') + findAll(@PosInfo() PosInfo: IPosPayload, @Query('from_date') from_date?: string) { + return this.service.findAll( + PosInfo.pos_id, + PosInfo.business_id, + from_date ? new Date(from_date) : new Date(), + ) + } +} diff --git a/src/modules/pos/statistics/statistics.module.ts b/src/modules/pos/statistics/statistics.module.ts new file mode 100644 index 0000000..0cd8b6c --- /dev/null +++ b/src/modules/pos/statistics/statistics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StatisticsService } from './statistics.service'; +import { StatisticsController } from './statistics.controller'; +import { PrismaService } from '@/prisma/prisma.service'; + +@Module({ +controllers: [StatisticsController], +providers: [StatisticsService, PrismaService], +}) +export class StatisticsModule {} diff --git a/src/modules/pos/statistics/statistics.service.ts b/src/modules/pos/statistics/statistics.service.ts new file mode 100644 index 0000000..e7d7c3b --- /dev/null +++ b/src/modules/pos/statistics/statistics.service.ts @@ -0,0 +1,88 @@ +import { ResponseMapper } from '@/common/response/response-mapper' +import { getCurrentJalaliSeasonRange } from '@/common/utils' +import { TspProviderResponseStatus } from '@/generated/prisma/enums' +import { PrismaService } from '@/prisma/prisma.service' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class StatisticsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(posId: string, businessId: string, fromDate: Date = new Date()) { + const seasonDate = getCurrentJalaliSeasonRange(fromDate) + + const [item] = await this.prisma.$queryRaw< + Array<{ + all_amount_sum: number | null + all_count: number | null + success_amount_sum: number | null + success_count: number | null + failure_amount_sum: number | null + failure_count: number | null + pending_amount_sum: number | null + pending_count: number | null + not_sended_amount_sum: number | null + not_sended_count: number | null + }> + >` + SELECT + SUM(si.total_amount) AS all_amount_sum, + COUNT(*) AS all_count, + SUM(CASE WHEN la.status = ${TspProviderResponseStatus.SUCCESS} THEN si.total_amount ELSE 0 END) AS success_amount_sum, + SUM(CASE WHEN la.status = ${TspProviderResponseStatus.SUCCESS} THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN la.status = ${TspProviderResponseStatus.FAILURE} THEN si.total_amount ELSE 0 END) AS failure_amount_sum, + SUM(CASE WHEN la.status = ${TspProviderResponseStatus.FAILURE} THEN 1 ELSE 0 END) AS failure_count, + SUM(CASE WHEN la.status = ${TspProviderResponseStatus.QUEUED} THEN si.total_amount ELSE 0 END) AS pending_amount_sum, + SUM(CASE WHEN la.status = ${TspProviderResponseStatus.QUEUED} THEN 1 ELSE 0 END) AS pending_count, + SUM(CASE WHEN la.status IS NULL THEN si.total_amount ELSE 0 END) AS not_sended_amount_sum, + SUM(CASE WHEN la.status IS NULL THEN 1 ELSE 0 END) AS not_sended_count + FROM sales_invoices si + INNER JOIN poses p ON p.id = si.pos_id + LEFT JOIN ( + SELECT a1.invoice_id, a1.status + FROM sale_invoice_tsp_attempts a1 + INNER JOIN ( + SELECT invoice_id, MAX(attempt_no) AS max_attempt_no + FROM sale_invoice_tsp_attempts + GROUP BY invoice_id + ) latest + ON latest.invoice_id = a1.invoice_id + AND latest.max_attempt_no = a1.attempt_no + ) la ON la.invoice_id = si.id + WHERE si.invoice_date >= ${seasonDate.start} + AND si.invoice_date <= ${seasonDate.end} + AND si.pos_id = ${posId} + AND p.complex_id IN ( + SELECT c.id FROM complexes c WHERE c.business_activity_id = ${businessId} + ) + ` + + return ResponseMapper.single({ + all: { + total_amount: Number(item?.all_amount_sum || 0), + total_tax: Number(0), + count: Number(item?.all_count || 0), + }, + success: { + total_amount: Number(item?.success_amount_sum || 0), + total_tax: Number(0), + count: Number(item?.success_count || 0), + }, + failure: { + total_amount: Number(item?.failure_amount_sum || 0), + total_tax: Number(0), + count: Number(item?.failure_count || 0), + }, + pending: { + total_amount: Number(item?.pending_amount_sum || 0), + total_tax: Number(0), + count: Number(item?.pending_count || 0), + }, + notSended: { + total_amount: Number(item?.not_sended_amount_sum || 0), + total_tax: Number(0), + count: Number(item?.not_sended_count || 0), + }, + }) + } +}