diff --git a/apps/e-commerce/src/e-commerce.module.ts b/apps/e-commerce/src/e-commerce.module.ts index 8cdd2330..c649680e 100644 --- a/apps/e-commerce/src/e-commerce.module.ts +++ b/apps/e-commerce/src/e-commerce.module.ts @@ -57,6 +57,7 @@ import { VariationPriceModule } from './admin/variation-price/variation-price.mo import { TorobProductModule } from './torob-product/torob-product.module'; import { AdminSaleModule } from './report/admin-sale/admin-sale.module'; import { PersianDateMonthModule } from './persiandate/bymonth/persian-date-month.module'; +import { VendorSaleModule } from './report/vendor-sale/vendor-sale.module'; @Module({ imports: [ @@ -115,6 +116,7 @@ import { PersianDateMonthModule } from './persiandate/bymonth/persian-date-month VariationPriceModule, TorobProductModule, AdminSaleModule, + VendorSaleModule, PersianDateMonthModule, ], providers: [ diff --git a/apps/e-commerce/src/report/vendor-sale/dto/get-vendor-sale.dto.ts b/apps/e-commerce/src/report/vendor-sale/dto/get-vendor-sale.dto.ts new file mode 100644 index 00000000..bbd12d76 --- /dev/null +++ b/apps/e-commerce/src/report/vendor-sale/dto/get-vendor-sale.dto.ts @@ -0,0 +1,8 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { ListFilter } from '@rahino/query-filter'; +import { VendorSaleDto } from './vendor-sale-dto'; + +export class GetVendorSaleDto extends IntersectionType( + VendorSaleDto, + ListFilter, +) {} diff --git a/apps/e-commerce/src/report/vendor-sale/dto/index.ts b/apps/e-commerce/src/report/vendor-sale/dto/index.ts new file mode 100644 index 00000000..09d41351 --- /dev/null +++ b/apps/e-commerce/src/report/vendor-sale/dto/index.ts @@ -0,0 +1 @@ +export * from './get-vendor-sale.dto'; diff --git a/apps/e-commerce/src/report/vendor-sale/dto/vendor-sale-dto.ts b/apps/e-commerce/src/report/vendor-sale/dto/vendor-sale-dto.ts new file mode 100644 index 00000000..e6dbeb82 --- /dev/null +++ b/apps/e-commerce/src/report/vendor-sale/dto/vendor-sale-dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { I18nTranslations } from 'apps/main/src/generated/i18n.generated'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage } from 'nestjs-i18n'; + +export class VendorSaleDto { + @ApiProperty({ + required: true, + type: IsString, + description: 'beginDate', + }) + @IsString() + beginDate: string; + + @ApiProperty({ + required: true, + type: IsString, + description: 'endDate', + }) + @IsString() + endDate: string; + + @ApiProperty({ + required: true, + type: IsNumber, + description: 'vendorId', + }) + @IsInt({ + message: i18nValidationMessage('validation.NUMBER'), + }) + @Type(() => Number) + vendorId: number; +} diff --git a/apps/e-commerce/src/report/vendor-sale/vendor-sale.controller.ts b/apps/e-commerce/src/report/vendor-sale/vendor-sale.controller.ts new file mode 100644 index 00000000..0af531b2 --- /dev/null +++ b/apps/e-commerce/src/report/vendor-sale/vendor-sale.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { CheckPermission } from '@rahino/permission-checker/decorator'; +import { PermissionGuard } from '@rahino/permission-checker/guard'; +import { JsonResponseTransformInterceptor } from '@rahino/response/interceptor'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; + +import { JwtGuard } from '@rahino/auth/guard'; +import { VendorSaleService } from './vendor-sale.service'; +import { GetVendorSaleDto } from './dto'; +import { GetUser } from '@rahino/auth/decorator'; +import { User } from '@rahino/database/models/core/user.entity'; + +@ApiTags('Report-VendorSales') +@UseGuards(JwtGuard, PermissionGuard) +@UseInterceptors(JsonResponseTransformInterceptor) +@ApiBearerAuth() +@Controller({ + path: '/api/ecommerce/report/vendorSales', + version: ['1'], +}) +export class VendorSaleController { + constructor(private service: VendorSaleService) {} + + @ApiOperation({ description: 'show all vendor sales' }) + @CheckPermission({ permissionSymbol: 'ecommerce.report.vendorsales.getall' }) + @Get('/') + @ApiQuery({ + name: 'filter', + type: GetVendorSaleDto, + style: 'deepObject', + explode: true, + }) + @HttpCode(HttpStatus.OK) + async findAll(@GetUser() user: User, @Query() filter: GetVendorSaleDto) { + return await this.service.findAll(user, filter); + } + + @ApiOperation({ description: 'show total vendor sales' }) + @CheckPermission({ permissionSymbol: 'ecommerce.report.vendorsales.getall' }) + @Get('/total') + @ApiQuery({ + name: 'filter', + type: GetVendorSaleDto, + style: 'deepObject', + explode: true, + }) + @HttpCode(HttpStatus.OK) + async total(@GetUser() user: User, @Query() filter: GetVendorSaleDto) { + return await this.service.total(user, filter); + } +} diff --git a/apps/e-commerce/src/report/vendor-sale/vendor-sale.module.ts b/apps/e-commerce/src/report/vendor-sale/vendor-sale.module.ts new file mode 100644 index 00000000..18a619d5 --- /dev/null +++ b/apps/e-commerce/src/report/vendor-sale/vendor-sale.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { VendorSaleService } from './vendor-sale.service'; +import { VendorSaleController } from './vendor-sale.controller'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { User } from '@rahino/database/models/core/user.entity'; +import { Permission } from '@rahino/database/models/core/permission.entity'; +import { ECOrderDetail } from '@rahino/database/models/ecommerce-eav/ec-order-detail.entity'; +import { PersianDate } from '@rahino/database/models/core/view/persiandate.entity'; +import { AdminSaleQueryBuilderModule } from '../admin-sale-query-builder/admin-sale-query-builder.module'; +import { UserVendorModule } from '@rahino/ecommerce/user/vendor/user-vendor.module'; + +@Module({ + imports: [ + AdminSaleQueryBuilderModule, + SequelizeModule.forFeature([User, Permission, ECOrderDetail, PersianDate]), + UserVendorModule, + ], + controllers: [VendorSaleController], + providers: [VendorSaleService], +}) +export class VendorSaleModule {} diff --git a/apps/e-commerce/src/report/vendor-sale/vendor-sale.service.ts b/apps/e-commerce/src/report/vendor-sale/vendor-sale.service.ts new file mode 100644 index 00000000..bd40509a --- /dev/null +++ b/apps/e-commerce/src/report/vendor-sale/vendor-sale.service.ts @@ -0,0 +1,270 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { GetVendorSaleDto } from './dto'; +import { InjectModel } from '@nestjs/sequelize'; +import { PersianDate } from '@rahino/database/models/core/view/persiandate.entity'; +import { QueryOptionsBuilder } from '@rahino/query-filter/sequelize-query-builder'; +import { I18nContext, I18nService } from 'nestjs-i18n'; +import { I18nTranslations } from 'apps/main/src/generated/i18n.generated'; +import { AdminSaleQueryBuilderService } from '../admin-sale-query-builder/admin-sale-query-builder.service'; +import { ECOrderDetail } from '@rahino/database/models/ecommerce-eav/ec-order-detail.entity'; +import { Sequelize } from 'sequelize'; +import { User } from '@rahino/database/models/core/user.entity'; +import { UserVendorService } from '@rahino/ecommerce/user/vendor/user-vendor.service'; + +@Injectable() +export class VendorSaleService { + constructor( + @InjectModel(PersianDate) + private readonly persianDateRepository: typeof PersianDate, + @InjectModel(ECOrderDetail) + private readonly orderDetailRepository: typeof ECOrderDetail, + private readonly i18n: I18nService, + private readonly adminSaleQueryBuilder: AdminSaleQueryBuilderService, + private readonly userVendorService: UserVendorService, + ) {} + + async findAll(user: User, filter: GetVendorSaleDto) { + const isAccessToVendor = await this.userVendorService.isAccessToVendor( + user, + filter.vendorId, + ); + if (!isAccessToVendor) { + throw new ForbiddenException( + this.i18n.t('ecommerce.dont_access_to_this_vendor', { + lang: I18nContext.current().lang, + }), + ); + } + const isValidBeginDate = await this.isValidDate(filter.beginDate); + if (!isValidBeginDate) { + throw new BadRequestException( + this.i18n.t('ecommerce.date_is_invalid', { + lang: I18nContext.current().lang, + }), + ); + } + const isValidEndDate = await this.isValidDate(filter.endDate); + if (!isValidEndDate) { + throw new BadRequestException( + this.i18n.t('ecommerce.date_is_invalid', { + lang: I18nContext.current().lang, + }), + ); + } + + let queryBuilder = this.adminSaleQueryBuilder + .init(false) + .nonDeleted() + .onlyPaid() + .addBeginDate(filter.beginDate) + .addEndDate(filter.endDate); + + if (filter.vendorId) { + queryBuilder = queryBuilder.onlyVendor(filter.vendorId); + } + + const count = await this.orderDetailRepository.count(queryBuilder.build()); + + queryBuilder = queryBuilder + .attributes([ + 'id', + 'orderId', + 'orderDetailStatusId', + 'vendorId', + 'productId', + 'inventoryId', + 'qty', + [ + Sequelize.fn('isnull', Sequelize.col('inventoryPrice.buyPrice'), 0), + 'buyPrice', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.col('ECOrderDetail.productPrice'), + 0, + ), + 'unitPrice', + ], + [ + Sequelize.literal('isnull(ECOrderDetail.productPrice, 0) * qty'), + 'productPrice', + ], + 'discountFee', + 'totalPrice', + 'commissionAmount', + [ + Sequelize.literal( + 'isnull(ECOrderDetail.totalPrice, 0) - isnull(ECOrderDetail.commissionAmount, 0)', + ), + 'vendorRevenue', + ], + [ + Sequelize.literal( + 'isnull(ECOrderDetail.totalPrice, 0) - isnull(inventoryPrice.buyPrice, 0) * qty - isnull(ECOrderDetail.commissionAmount, 0)', + ), + 'profitAmount', + ], + ]) + .includeProduct() + .includeInventory() + .includeVendor() + .includeInventoryPrice() + .offset(filter.offset) + .limit(filter.limit); + + return { + result: await this.orderDetailRepository.findAll(queryBuilder.build()), + total: count, + }; + } + + async total(user: User, filter: GetVendorSaleDto) { + const isAccessToVendor = await this.userVendorService.isAccessToVendor( + user, + filter.vendorId, + ); + if (!isAccessToVendor) { + throw new ForbiddenException( + this.i18n.t('ecommerce.dont_access_to_this_vendor', { + lang: I18nContext.current().lang, + }), + ); + } + const isValidBeginDate = await this.isValidDate(filter.beginDate); + if (!isValidBeginDate) { + throw new BadRequestException( + this.i18n.t('ecommerce.date_is_invalid', { + lang: I18nContext.current().lang, + }), + ); + } + const isValidEndDate = await this.isValidDate(filter.endDate); + if (!isValidEndDate) { + throw new BadRequestException( + this.i18n.t('ecommerce.date_is_invalid', { + lang: I18nContext.current().lang, + }), + ); + } + + let queryBuilder = this.adminSaleQueryBuilder + .init(true) + .nonDeleted() + .onlyPaid() + .addBeginDate(filter.beginDate) + .addEndDate(filter.endDate); + + if (filter.vendorId) { + queryBuilder = queryBuilder.onlyVendor(filter.vendorId); + } + + queryBuilder = queryBuilder + .attributes([ + [ + Sequelize.literal('count(distinct ECOrderDetail.orderId)'), + 'cntOrder', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.fn('sum', Sequelize.col('ECOrderDetail.qty')), + 0, + ), + 'qty', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.fn( + 'sum', + Sequelize.literal('isnull(inventoryPrice.buyPrice, 0) * qty'), + ), + 0, + ), + 'buyPrice', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.fn( + 'sum', + Sequelize.literal('isnull(ECOrderDetail.productPrice, 0) * qty'), + ), + 0, + ), + 'productPrice', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.fn('sum', Sequelize.col('ECOrderDetail.discountFee')), + 0, + ), + 'discountFee', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.fn('sum', Sequelize.col('ECOrderDetail.totalPrice')), + 0, + ), + 'totalPrice', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.fn( + 'sum', + Sequelize.col('ECOrderDetail.commissionAmount'), + ), + 0, + ), + 'commissionAmount', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.literal( + 'sum(isnull(ECOrderDetail.totalPrice, 0) - isnull(ECOrderDetail.commissionAmount, 0))', + ), + 0, + ), + 'vendorRevenue', + ], + [ + Sequelize.fn( + 'isnull', + Sequelize.literal( + 'sum(isnull(ECOrderDetail.totalPrice, 0) - isnull(inventoryPrice.buyPrice, 0) * qty - isnull(ECOrderDetail.commissionAmount, 0))', + ), + 0, + ), + 'profitAmount', + ], + ]) + .includeInventoryPrice() + .rawQuery(true); + + const findOptions = queryBuilder.build(); + findOptions.order = null; + findOptions.offset = null; + findOptions.limit = null; + + return { + result: await this.orderDetailRepository.findOne(findOptions), + }; + } + + async isValidDate(date: string) { + const findDate = await this.persianDateRepository.findOne( + new QueryOptionsBuilder().filter({ GregorianDate: date }).build(), + ); + if (!findDate) return false; + return true; + } +} diff --git a/apps/main/src/generated/i18n.generated.ts b/apps/main/src/generated/i18n.generated.ts index a5db1061..df7feb2e 100644 --- a/apps/main/src/generated/i18n.generated.ts +++ b/apps/main/src/generated/i18n.generated.ts @@ -22,6 +22,7 @@ export type I18nTranslations = { "address_floor": string; "address_postalcode": string; "date_is_invalid": string; + "dont_access_to_this_vendor": string; }; "validation": { "NOT_EMPTY": string; diff --git a/apps/main/src/i18n/en/ecommerce.json b/apps/main/src/i18n/en/ecommerce.json index 1539c1d0..e917b31b 100644 --- a/apps/main/src/i18n/en/ecommerce.json +++ b/apps/main/src/i18n/en/ecommerce.json @@ -6,5 +6,6 @@ "address_plaque": "you should enter plaque", "address_floor": "you should enter floor", "address_postalcode": "you should enter postal code", - "date_is_invalid": "date is invalid" + "date_is_invalid": "date is invalid", + "dont_access_to_this_vendor": "you don't access to this vendor" } diff --git a/apps/main/src/i18n/fa/ecommerce.json b/apps/main/src/i18n/fa/ecommerce.json index e49dfefa..de896a63 100644 --- a/apps/main/src/i18n/fa/ecommerce.json +++ b/apps/main/src/i18n/fa/ecommerce.json @@ -6,5 +6,6 @@ "address_plaque": "یک پلاک وارد بفرمایید", "address_floor": "یک طبقه وارد بفرمایید", "address_postalcode": "یک کد پستی وارد بفرمایید", - "date_is_invalid": "تاریخ معتبر نیست" + "date_is_invalid": "تاریخ معتبر نیست", + "dont_access_to_this_vendor": "شما به این فروشگاه دسترسی ندارید" }