Compare commits

..

2 Commits

Author SHA1 Message Date
ahasani 2c97b7302d feat(statistics): implement statistics module with controller and service for POS invoices 2026-05-23 18:09:41 +03:30
ahasani 2dc9480170 feat(invoices): add public invoices module with controller and service
refactor: remove unused trigger logs module and related files
refactor: simplify good snapshot handling in sale invoice creation
2026-05-21 21:35:36 +03:30
21 changed files with 497 additions and 86 deletions
+2 -4
View File
@@ -7,10 +7,9 @@ import { AuthModule } from './modules/auth/auth.module'
import { CatalogModule } from './modules/catalog/catalog.module' import { CatalogModule } from './modules/catalog/catalog.module'
import { ConsumerModule } from './modules/consumer/consumer.module' import { ConsumerModule } from './modules/consumer/consumer.module'
import { EnumsModule } from './modules/enums/enums.module' import { EnumsModule } from './modules/enums/enums.module'
import { PublicInvoicesModule } from './modules/invoices/invoices.module'
import { PartnerModule } from './modules/partners/partners.module' import { PartnerModule } from './modules/partners/partners.module'
import { PosModule } from './modules/pos/pos.module' import { PosModule } from './modules/pos/pos.module'
import { SalesInvoicePaymentsModule } from './modules/pos/sales-invoices/sales-invoice-payments/sales-invoice-payments.module'
import { TriggerLogsModule } from './modules/trigger-logs/trigger-logs.module'
import { UploaderModule } from './modules/uploader/uploader.module' import { UploaderModule } from './modules/uploader/uploader.module'
import { PrismaModule } from './prisma/prisma.module' import { PrismaModule } from './prisma/prisma.module'
import { RedisModule } from './redis/redis.module' import { RedisModule } from './redis/redis.module'
@@ -26,8 +25,7 @@ import { RedisModule } from './redis/redis.module'
ConsumerModule, ConsumerModule,
PosModule, PosModule,
PartnerModule, PartnerModule,
SalesInvoicePaymentsModule, PublicInvoicesModule,
TriggerLogsModule,
UploaderModule, UploaderModule,
ApplicationModule, ApplicationModule,
], ],
-8
View File
@@ -98,14 +98,6 @@ export const select: SalesInvoiceSelect = {
notes: true, notes: true,
payload: true, payload: true,
good_snapshot: true, good_snapshot: true,
good: {
select: {
name: true,
barcode: true,
image_url: true,
category: true,
},
},
}, },
}, },
payments: { payments: {
@@ -411,11 +411,7 @@ export class SharedSaleInvoiceCreateService {
sku_vat: goodsById.get(item.good_id!)?.sku.VAT || null, sku_vat: goodsById.get(item.good_id!)?.sku.VAT || null,
payload: item.payload ? JSON.parse(JSON.stringify(item.payload)) : undefined, payload: item.payload ? JSON.parse(JSON.stringify(item.payload)) : undefined,
good_snapshot: item.good_id good_snapshot: item.good_id
? JSON.parse( ? JSON.parse(JSON.stringify(goodsById.get(item.good_id) || null))
JSON.stringify({
good: goodsById.get(item.good_id) || null,
}),
)
: undefined, : undefined,
})), })),
}, },
+82
View File
@@ -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,
}
+1
View File
@@ -1,3 +1,4 @@
export * from './date-formatter.utils'
export * from './enum-translator.util' export * from './enum-translator.util'
export * from './field-validator.util' export * from './field-validator.util'
export * from './http-client.util' export * from './http-client.util'
+3
View File
@@ -3,6 +3,7 @@ import { EnumKeyMaker } from './enum'
import { GuildKeyMaker } from './guild' import { GuildKeyMaker } from './guild'
import { PartnerKeyMaker } from './partner' import { PartnerKeyMaker } from './partner'
import { PosKeyMaker } from './pos' import { PosKeyMaker } from './pos'
import { PublicSaleInvoiceKeyMaker } from './publicSaleInvoice'
// Keep backward-compatible static methods while separating key builders by domain. // Keep backward-compatible static methods while separating key builders by domain.
export class RedisKeyMaker extends PartnerKeyMaker { export class RedisKeyMaker extends PartnerKeyMaker {
@@ -25,6 +26,8 @@ export class RedisKeyMaker extends PartnerKeyMaker {
static enumsAll = EnumKeyMaker.enumsAll static enumsAll = EnumKeyMaker.enumsAll
static enumsValues = EnumKeyMaker.enumsValues static enumsValues = EnumKeyMaker.enumsValues
static publicSaleInvoice = PublicSaleInvoiceKeyMaker.invoice
} }
export { ConsumerKeyMaker, EnumKeyMaker, GuildKeyMaker, PartnerKeyMaker, PosKeyMaker } export { ConsumerKeyMaker, EnumKeyMaker, GuildKeyMaker, PartnerKeyMaker, PosKeyMaker }
+46
View File
@@ -14,4 +14,50 @@ export class PosKeyMaker {
static goodListByGuildPattern(guildId: string): string { static goodListByGuildPattern(guildId: string): string {
return `pos:goods:list:guildId:${guildId}:ba:*` 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`
}
} }
@@ -0,0 +1,5 @@
export class PublicSaleInvoiceKeyMaker {
static invoice(id: string): string {
return `publicSaleInvoices:${id}`
}
}
@@ -0,0 +1,16 @@
import { Public } from '@/common/decorators/public.decorator'
import { Controller, Get, Param } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { InvoicesService } from './invoices.service'
@ApiTags('PublicInvoices')
@Controller('public-invoices')
export class InvoicesController {
constructor(private readonly service: InvoicesService) {}
@Get(':id')
@Public()
findOne(@Param('id') id: string) {
return this.service.findOne(id)
}
}
+10
View File
@@ -0,0 +1,10 @@
import { PrismaService } from '@/prisma/prisma.service'
import { Module } from '@nestjs/common'
import { InvoicesController } from './invoices.controller'
import { InvoicesService } from './invoices.service'
@Module({
controllers: [InvoicesController],
providers: [InvoicesService, PrismaService],
})
export class PublicInvoicesModule {}
+143
View File
@@ -0,0 +1,143 @@
import { RedisKeyMaker, translateEnumValue } from '@/common/utils'
import { TspProviderResponseStatus } from '@/generated/prisma/enums'
import { SalesInvoiceSelect } from '@/generated/prisma/models'
import { PrismaService } from '@/prisma/prisma.service'
import { RedisService } from '@/redis/redis.service'
import { Injectable } from '@nestjs/common'
@Injectable()
export class InvoicesService {
constructor(
private readonly prisma: PrismaService,
private readonly redisService: RedisService,
) {}
private readonly summarySelect: SalesInvoiceSelect = {
id: true,
code: true,
invoice_date: true,
invoice_number: true,
notes: true,
tax_id: true,
total_amount: true,
type: true,
pos: {
select: {
name: true,
complex: {
select: {
name: true,
branch_code: true,
business_activity: {
select: {
name: true,
guild: {
select: {
name: true,
},
},
},
},
},
},
},
},
payments: {
select: {
amount: true,
paid_at: true,
payment_method: true,
terminal_info: {
select: {
stan: true,
rrn: true,
transaction_date_time: true,
customer_card_no: true,
},
},
},
},
unknown_customer: true,
customer: {
select: {
type: true,
legal: {
select: {
name: true,
economic_code: true,
postal_code: true,
registration_number: true,
},
},
individual: {
select: {
economic_code: true,
first_name: true,
last_name: true,
mobile_number: true,
national_id: true,
postal_code: true,
},
},
},
},
items: {
select: {
good_snapshot: true,
measure_unit_text: true,
notes: true,
quantity: true,
total_amount: true,
unit_price: true,
discount: true,
payload: true,
sku_code: true,
sku_vat: true,
},
},
}
private readonly select: SalesInvoiceSelect = {
...this.summarySelect,
tsp_attempts: {
orderBy: {
created_at: 'desc',
},
select: {
status: true,
},
take: 1,
},
}
private readonly where = () => ({})
private readonly invoiceMapper = (invoice: any) => {
const { tsp_attempts, type, ...rest } = invoice || {}
return {
...rest,
type: translateEnumValue('TspProviderRequestType', type),
status: translateEnumValue(
'TspProviderResponseStatus',
invoice.tsp_attempts?.[0]?.status || TspProviderResponseStatus.NOT_SEND,
),
}
}
async findOne(id: string) {
await this.redisService.delete(RedisKeyMaker.publicSaleInvoice(id))
return await this.redisService.getAndSet(
RedisKeyMaker.publicSaleInvoice(id),
'single',
async () => {
const item = await this.prisma.salesInvoice.findUnique({
where: { ...this.where(), id },
select: this.select,
})
return this.invoiceMapper(item)
},
60 * 60,
)
}
}
+68
View File
@@ -16,4 +16,72 @@ export class PosCacheInvalidationService {
async invalidatePosGoodsByGuildPattern(guildId: string): Promise<void> { async invalidatePosGoodsByGuildPattern(guildId: string): Promise<void> {
this.redisService.deleteByPattern(PosKeyMaker.goodListByGuildPattern(guildId)) this.redisService.deleteByPattern(PosKeyMaker.goodListByGuildPattern(guildId))
} }
async invalidatePosStatisticsInvoicesByBusinessActivityId(
businessActivityId: string,
): Promise<void> {
this.redisService.deleteByPattern(
PosKeyMaker.statisticInvoicesPattern(businessActivityId),
)
}
async invalidatePosStatisticsInvoicesAll(
businessActivityId: string,
posId: string,
from: string,
): Promise<void> {
this.redisService.delete(
PosKeyMaker.statisticInvoices(businessActivityId, posId, from),
)
}
async invalidatePosStatisticsInvoicesFailure(
businessActivityId: string,
posId: string,
from: string,
): Promise<void> {
this.redisService.delete(
PosKeyMaker.statisticInvoicesFailure(businessActivityId, posId, from),
)
}
async invalidatePosStatisticsInvoicesSuccess(
businessActivityId: string,
posId: string,
from: string,
): Promise<void> {
this.redisService.delete(
PosKeyMaker.statisticInvoicesSuccess(businessActivityId, posId, from),
)
}
async invalidatePosStatisticsInvoicesPending(
businessActivityId: string,
posId: string,
from: string,
): Promise<void> {
this.redisService.delete(
PosKeyMaker.statisticInvoicesPending(businessActivityId, posId, from),
)
}
async invalidatePosStatisticsInvoicesCredit(
businessActivityId: string,
posId: string,
from: string,
): Promise<void> {
this.redisService.delete(
PosKeyMaker.statisticInvoicesCredit(businessActivityId, posId, from),
)
}
async invalidatePosStatisticsInvoicesNotSent(
businessActivityId: string,
posId: string,
from: string,
): Promise<void> {
this.redisService.delete(
PosKeyMaker.statisticInvoicesNotSent(businessActivityId, posId, from),
)
}
} }
+2
View File
@@ -8,6 +8,7 @@ import { PosController } from './pos.controller'
import { PosMiddleware } from './pos.middleware' import { PosMiddleware } from './pos.middleware'
import { PosService } from './pos.service' import { PosService } from './pos.service'
import { PosSalesInvoicesModule } from './sales-invoices/sales-invoices.module' import { PosSalesInvoicesModule } from './sales-invoices/sales-invoices.module'
import { StatisticsModule } from './statistics/statistics.module'
@Module({ @Module({
controllers: [PosController], controllers: [PosController],
@@ -18,6 +19,7 @@ import { PosSalesInvoicesModule } from './sales-invoices/sales-invoices.module'
PosGoodsModule, PosGoodsModule,
PosSalesInvoicesModule, PosSalesInvoicesModule,
OwnedGoodsModule, OwnedGoodsModule,
StatisticsModule,
], ],
}) })
export class PosModule implements NestModule { export class PosModule implements NestModule {
@@ -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(),
)
}
}
@@ -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 {}
@@ -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),
},
})
}
}
@@ -1,14 +0,0 @@
import { ApiProperty, PartialType } from '@nestjs/swagger'
import { IsString } from 'class-validator'
export class CreateTriggerLogDto {
@ApiProperty()
@IsString()
message: string
@ApiProperty()
@IsString()
name: string
}
export class UpdateTriggerLogDto extends PartialType(CreateTriggerLogDto) {}
@@ -1,5 +0,0 @@
import type { TriggerLogsService } from '../trigger-logs.service'
export type TriggerLogsServiceCreateResponseDto = Awaited<ReturnType<TriggerLogsService['create']>>
export type TriggerLogsServiceFindAllResponseDto = Awaited<ReturnType<TriggerLogsService['findAll']>>
export type TriggerLogsServiceFindOneResponseDto = Awaited<ReturnType<TriggerLogsService['findOne']>>
@@ -1,22 +0,0 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common'
import { TriggerLogsService } from './trigger-logs.service'
@Controller('trigger-logs')
export class TriggerLogsController {
constructor(private readonly triggerLogsService: TriggerLogsService) {}
@Get()
findAll() {
return this.triggerLogsService.findAll()
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.triggerLogsService.findOne(+id)
}
@Post()
create(@Body() data: any) {
return this.triggerLogsService.create(data)
}
}
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common'
import { TriggerLogsController } from './trigger-logs.controller'
import { TriggerLogsService } from './trigger-logs.service'
@Module({
controllers: [TriggerLogsController],
providers: [TriggerLogsService],
})
export class TriggerLogsModule {}
@@ -1,19 +0,0 @@
import { Injectable } from '@nestjs/common'
@Injectable()
export class TriggerLogsService {
findAll() {
// TODO: Implement fetching all trigger logs
return []
}
findOne(id: number) {
// TODO: Implement fetching a single trigger log
return {}
}
create(data: any) {
// TODO: Implement trigger log creation
return data
}
}