feat(sales,commissions): Add Sales and Commissions backend modules
Sales Module (SAAS-018): - Entities: PipelineStage, Lead, Opportunity, Activity - Services: LeadsService, OpportunitiesService, ActivitiesService, PipelineService, SalesDashboardService - Controllers: 25 endpoints for leads, opportunities, activities, pipeline, dashboard - DTOs: Complete CRUD and query DTOs - Integration with DDL functions: convert_lead_to_opportunity, update_opportunity_stage, calculate_lead_score Commissions Module (SAAS-020): - Entities: CommissionScheme, CommissionAssignment, CommissionPeriod, CommissionEntry - Services: SchemesService, AssignmentsService, EntriesService, PeriodsService, CommissionsDashboardService - Controllers: 25 endpoints for schemes, assignments, entries, periods, dashboard - DTOs: Complete CRUD and query DTOs - Integration with DDL functions: calculate_commission, close_period, get_user_earnings Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b49a051d85
commit
eb6a83daba
@ -28,6 +28,8 @@ import { OnboardingModule } from '@modules/onboarding/onboarding.module';
|
||||
import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module';
|
||||
import { AnalyticsModule } from '@modules/analytics/analytics.module';
|
||||
import { ReportsModule } from '@modules/reports/reports.module';
|
||||
import { SalesModule } from '@modules/sales/sales.module';
|
||||
import { CommissionsModule } from '@modules/commissions/commissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -88,6 +90,8 @@ import { ReportsModule } from '@modules/reports/reports.module';
|
||||
WhatsAppModule,
|
||||
AnalyticsModule,
|
||||
ReportsModule,
|
||||
SalesModule,
|
||||
CommissionsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
58
src/modules/commissions/commissions.module.ts
Normal file
58
src/modules/commissions/commissions.module.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
CommissionSchemeEntity,
|
||||
CommissionAssignmentEntity,
|
||||
CommissionPeriodEntity,
|
||||
CommissionEntryEntity,
|
||||
} from './entities';
|
||||
|
||||
import {
|
||||
SchemesService,
|
||||
AssignmentsService,
|
||||
EntriesService,
|
||||
PeriodsService,
|
||||
CommissionsDashboardService,
|
||||
} from './services';
|
||||
|
||||
import {
|
||||
SchemesController,
|
||||
AssignmentsController,
|
||||
EntriesController,
|
||||
PeriodsController,
|
||||
CommissionsDashboardController,
|
||||
} from './controllers';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
CommissionSchemeEntity,
|
||||
CommissionAssignmentEntity,
|
||||
CommissionPeriodEntity,
|
||||
CommissionEntryEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
SchemesController,
|
||||
AssignmentsController,
|
||||
EntriesController,
|
||||
PeriodsController,
|
||||
CommissionsDashboardController,
|
||||
],
|
||||
providers: [
|
||||
SchemesService,
|
||||
AssignmentsService,
|
||||
EntriesService,
|
||||
PeriodsService,
|
||||
CommissionsDashboardService,
|
||||
],
|
||||
exports: [
|
||||
SchemesService,
|
||||
AssignmentsService,
|
||||
EntriesService,
|
||||
PeriodsService,
|
||||
CommissionsDashboardService,
|
||||
],
|
||||
})
|
||||
export class CommissionsModule {}
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { AssignmentsService } from '../services';
|
||||
import {
|
||||
CreateAssignmentDto,
|
||||
UpdateAssignmentDto,
|
||||
AssignmentListQueryDto,
|
||||
AssignmentResponseDto,
|
||||
PaginatedAssignmentsDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('commissions/assignments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AssignmentsController {
|
||||
constructor(private readonly assignmentsService: AssignmentsService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: AssignmentListQueryDto,
|
||||
): Promise<PaginatedAssignmentsDto> {
|
||||
return this.assignmentsService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
async findMyAssignments(@CurrentUser() user: RequestUser): Promise<AssignmentResponseDto[]> {
|
||||
return this.assignmentsService.findActiveForUser(user.tenant_id, user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<AssignmentResponseDto> {
|
||||
return this.assignmentsService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateAssignmentDto,
|
||||
): Promise<AssignmentResponseDto> {
|
||||
return this.assignmentsService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateAssignmentDto,
|
||||
): Promise<AssignmentResponseDto> {
|
||||
return this.assignmentsService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.assignmentsService.remove(user.tenant_id, id);
|
||||
return { message: 'Commission assignment deleted successfully' };
|
||||
}
|
||||
}
|
||||
57
src/modules/commissions/controllers/dashboard.controller.ts
Normal file
57
src/modules/commissions/controllers/dashboard.controller.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { CommissionsDashboardService } from '../services';
|
||||
import {
|
||||
CommissionsDashboardDto,
|
||||
UserEarningsDto,
|
||||
EntriesByStatusDto,
|
||||
EntriesBySchemeDto,
|
||||
EntriesByUserDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('commissions/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CommissionsDashboardController {
|
||||
constructor(private readonly dashboardService: CommissionsDashboardService) {}
|
||||
|
||||
@Get()
|
||||
async getSummary(@CurrentUser() user: RequestUser): Promise<CommissionsDashboardDto> {
|
||||
return this.dashboardService.getDashboardSummary(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('my-earnings')
|
||||
async getMyEarnings(@CurrentUser() user: RequestUser): Promise<UserEarningsDto> {
|
||||
return this.dashboardService.getUserEarnings(user.tenant_id, user.id);
|
||||
}
|
||||
|
||||
@Get('entries-by-status')
|
||||
async getEntriesByStatus(@CurrentUser() user: RequestUser): Promise<EntriesByStatusDto[]> {
|
||||
return this.dashboardService.getEntriesByStatus(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('entries-by-scheme')
|
||||
async getEntriesByScheme(@CurrentUser() user: RequestUser): Promise<EntriesBySchemeDto[]> {
|
||||
return this.dashboardService.getEntriesByScheme(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('entries-by-user')
|
||||
async getEntriesByUser(@CurrentUser() user: RequestUser): Promise<EntriesByUserDto[]> {
|
||||
return this.dashboardService.getEntriesByUser(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('top-earners')
|
||||
async getTopEarners(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<EntriesByUserDto[]> {
|
||||
return this.dashboardService.getTopEarners(user.tenant_id, limit || 10);
|
||||
}
|
||||
}
|
||||
118
src/modules/commissions/controllers/entries.controller.ts
Normal file
118
src/modules/commissions/controllers/entries.controller.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { EntriesService } from '../services';
|
||||
import {
|
||||
CreateEntryDto,
|
||||
UpdateEntryDto,
|
||||
ApproveEntryDto,
|
||||
RejectEntryDto,
|
||||
EntryListQueryDto,
|
||||
EntryResponseDto,
|
||||
PaginatedEntriesDto,
|
||||
CalculateCommissionDto,
|
||||
CalculateCommissionResponseDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('commissions/entries')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class EntriesController {
|
||||
constructor(private readonly entriesService: EntriesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: EntryListQueryDto,
|
||||
): Promise<PaginatedEntriesDto> {
|
||||
return this.entriesService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
async findMyEntries(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: EntryListQueryDto,
|
||||
): Promise<PaginatedEntriesDto> {
|
||||
return this.entriesService.findAll(user.tenant_id, { ...query, userId: user.id });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<EntryResponseDto> {
|
||||
return this.entriesService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
return this.entriesService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
return this.entriesService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
async approve(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
return this.entriesService.approve(user.tenant_id, id, user.id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
async reject(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
return this.entriesService.reject(user.tenant_id, id, user.id, dto);
|
||||
}
|
||||
|
||||
@Post('calculate')
|
||||
async calculateCommission(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CalculateCommissionDto,
|
||||
): Promise<CalculateCommissionResponseDto> {
|
||||
return this.entriesService.calculateCommission(user.tenant_id, dto);
|
||||
}
|
||||
|
||||
@Post('bulk-approve')
|
||||
async bulkApprove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() body: { entryIds: string[] },
|
||||
): Promise<{ approved: number }> {
|
||||
const approved = await this.entriesService.bulkApprove(
|
||||
user.tenant_id,
|
||||
body.entryIds,
|
||||
user.id,
|
||||
);
|
||||
return { approved };
|
||||
}
|
||||
}
|
||||
5
src/modules/commissions/controllers/index.ts
Normal file
5
src/modules/commissions/controllers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './schemes.controller';
|
||||
export * from './assignments.controller';
|
||||
export * from './entries.controller';
|
||||
export * from './periods.controller';
|
||||
export * from './dashboard.controller';
|
||||
116
src/modules/commissions/controllers/periods.controller.ts
Normal file
116
src/modules/commissions/controllers/periods.controller.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { PeriodsService } from '../services';
|
||||
import {
|
||||
CreatePeriodDto,
|
||||
UpdatePeriodDto,
|
||||
ClosePeriodDto,
|
||||
MarkPaidDto,
|
||||
PeriodListQueryDto,
|
||||
PeriodResponseDto,
|
||||
PaginatedPeriodsDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('commissions/periods')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PeriodsController {
|
||||
constructor(private readonly periodsService: PeriodsService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: PeriodListQueryDto,
|
||||
): Promise<PaginatedPeriodsDto> {
|
||||
return this.periodsService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get('current')
|
||||
async getCurrentPeriod(@CurrentUser() user: RequestUser): Promise<PeriodResponseDto | null> {
|
||||
return this.periodsService.getCurrentPeriod(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<PeriodResponseDto> {
|
||||
return this.periodsService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreatePeriodDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
return this.periodsService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePeriodDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
return this.periodsService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/close')
|
||||
async close(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ClosePeriodDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
return this.periodsService.close(user.tenant_id, id, user.id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/mark-paid')
|
||||
async markPaid(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: MarkPaidDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
return this.periodsService.markAsPaid(user.tenant_id, id, user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.periodsService.remove(user.tenant_id, id);
|
||||
return { message: 'Commission period deleted successfully' };
|
||||
}
|
||||
|
||||
@Post(':id/assign-entries')
|
||||
async assignEntries(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { entryIds: string[] },
|
||||
): Promise<{ assigned: number }> {
|
||||
const assigned = await this.periodsService.assignEntriesToPeriod(
|
||||
user.tenant_id,
|
||||
id,
|
||||
body.entryIds,
|
||||
);
|
||||
return { assigned };
|
||||
}
|
||||
}
|
||||
93
src/modules/commissions/controllers/schemes.controller.ts
Normal file
93
src/modules/commissions/controllers/schemes.controller.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { SchemesService } from '../services';
|
||||
import {
|
||||
CreateSchemeDto,
|
||||
UpdateSchemeDto,
|
||||
SchemeListQueryDto,
|
||||
SchemeResponseDto,
|
||||
PaginatedSchemesDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('commissions/schemes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SchemesController {
|
||||
constructor(private readonly schemesService: SchemesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: SchemeListQueryDto,
|
||||
): Promise<PaginatedSchemesDto> {
|
||||
return this.schemesService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<SchemeResponseDto> {
|
||||
return this.schemesService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateSchemeDto,
|
||||
): Promise<SchemeResponseDto> {
|
||||
return this.schemesService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSchemeDto,
|
||||
): Promise<SchemeResponseDto> {
|
||||
return this.schemesService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.schemesService.remove(user.tenant_id, id);
|
||||
return { message: 'Commission scheme deleted successfully' };
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
async activate(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<SchemeResponseDto> {
|
||||
return this.schemesService.activate(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
async deactivate(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<SchemeResponseDto> {
|
||||
return this.schemesService.deactivate(user.tenant_id, id);
|
||||
}
|
||||
}
|
||||
98
src/modules/commissions/dto/assignment.dto.ts
Normal file
98
src/modules/commissions/dto/assignment.dto.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import {
|
||||
IsUUID,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAssignmentDto {
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@IsUUID()
|
||||
schemeId: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startsAt?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endsAt?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
customRate?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAssignmentDto {
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startsAt?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endsAt?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
customRate?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class AssignmentResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
schemeId: string;
|
||||
startsAt: Date;
|
||||
endsAt: Date | null;
|
||||
customRate: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
createdBy: string | null;
|
||||
scheme?: any;
|
||||
user?: any;
|
||||
}
|
||||
|
||||
export class AssignmentListQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
schemeId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedAssignmentsDto {
|
||||
items: AssignmentResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
60
src/modules/commissions/dto/dashboard.dto.ts
Normal file
60
src/modules/commissions/dto/dashboard.dto.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { EntryStatus, PeriodStatus } from '../entities';
|
||||
|
||||
export class CommissionsDashboardDto {
|
||||
totalSchemes: number;
|
||||
totalActiveAssignments: number;
|
||||
pendingCommissions: number;
|
||||
pendingAmount: number;
|
||||
approvedAmount: number;
|
||||
paidAmount: number;
|
||||
currentPeriod: string | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export class UserEarningsDto {
|
||||
totalPending: number;
|
||||
totalApproved: number;
|
||||
totalPaid: number;
|
||||
totalEntries: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export class EntriesByStatusDto {
|
||||
status: EntryStatus;
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class EntriesBySchemeDto {
|
||||
schemeId: string;
|
||||
schemeName: string;
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class EntriesByUserDto {
|
||||
userId: string;
|
||||
userName: string;
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class PeriodSummaryDto {
|
||||
periodId: string;
|
||||
periodName: string;
|
||||
status: PeriodStatus;
|
||||
totalEntries: number;
|
||||
totalAmount: number;
|
||||
entriesByStatus: EntriesByStatusDto[];
|
||||
}
|
||||
|
||||
export class CommissionsTimelineDto {
|
||||
date: string;
|
||||
entriesCreated: number;
|
||||
entriesApproved: number;
|
||||
entriesPaid: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
155
src/modules/commissions/dto/entry.dto.ts
Normal file
155
src/modules/commissions/dto/entry.dto.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { EntryStatus } from '../entities';
|
||||
|
||||
export class CreateEntryDto {
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@IsUUID()
|
||||
schemeId: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignmentId?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
referenceType: string;
|
||||
|
||||
@IsUUID()
|
||||
referenceId: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
baseAmount: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateEntryDto {
|
||||
@IsEnum(EntryStatus)
|
||||
@IsOptional()
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
periodId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class ApproveEntryDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class RejectEntryDto {
|
||||
@IsString()
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export class EntryResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
schemeId: string;
|
||||
assignmentId: string | null;
|
||||
referenceType: string;
|
||||
referenceId: string;
|
||||
baseAmount: number;
|
||||
rateApplied: number;
|
||||
commissionAmount: number;
|
||||
currency: string;
|
||||
status: EntryStatus;
|
||||
periodId: string | null;
|
||||
paidAt: Date | null;
|
||||
paymentReference: string | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
approvedBy: string | null;
|
||||
approvedAt: Date | null;
|
||||
scheme?: any;
|
||||
period?: any;
|
||||
}
|
||||
|
||||
export class EntryListQueryDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
userId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
schemeId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
periodId?: string;
|
||||
|
||||
@IsEnum(EntryStatus)
|
||||
@IsOptional()
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
referenceType?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedEntriesDto {
|
||||
items: EntryResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class CalculateCommissionDto {
|
||||
@IsUUID()
|
||||
schemeId: string;
|
||||
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export class CalculateCommissionResponseDto {
|
||||
rateApplied: number;
|
||||
commissionAmount: number;
|
||||
}
|
||||
5
src/modules/commissions/dto/index.ts
Normal file
5
src/modules/commissions/dto/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './scheme.dto';
|
||||
export * from './assignment.dto';
|
||||
export * from './entry.dto';
|
||||
export * from './period.dto';
|
||||
export * from './dashboard.dto';
|
||||
97
src/modules/commissions/dto/period.dto.ts
Normal file
97
src/modules/commissions/dto/period.dto.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { PeriodStatus } from '../entities';
|
||||
|
||||
export class CreatePeriodDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsDateString()
|
||||
startsAt: string;
|
||||
|
||||
@IsDateString()
|
||||
endsAt: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class UpdatePeriodDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
startsAt?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
endsAt?: string;
|
||||
}
|
||||
|
||||
export class ClosePeriodDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class MarkPaidDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
paymentReference?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
paymentNotes?: string;
|
||||
}
|
||||
|
||||
export class PeriodResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
startsAt: Date;
|
||||
endsAt: Date;
|
||||
totalEntries: number;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: PeriodStatus;
|
||||
closedAt: Date | null;
|
||||
closedBy: string | null;
|
||||
paidAt: Date | null;
|
||||
paidBy: string | null;
|
||||
paymentReference: string | null;
|
||||
paymentNotes: string | null;
|
||||
createdAt: Date;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export class PeriodListQueryDto {
|
||||
@IsEnum(PeriodStatus)
|
||||
@IsOptional()
|
||||
status?: PeriodStatus;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedPeriodsDto {
|
||||
items: PeriodResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
192
src/modules/commissions/dto/scheme.dto.ts
Normal file
192
src/modules/commissions/dto/scheme.dto.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { SchemeType, AppliesTo } from '../entities';
|
||||
|
||||
export class TierDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
from: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
to?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export class CreateSchemeDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(SchemeType)
|
||||
@IsOptional()
|
||||
type?: SchemeType;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
rate?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
fixedAmount?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
tiers?: TierDto[];
|
||||
|
||||
@IsEnum(AppliesTo)
|
||||
@IsOptional()
|
||||
appliesTo?: AppliesTo;
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
@IsOptional()
|
||||
productIds?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
@IsOptional()
|
||||
categoryIds?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
minAmount?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
maxAmount?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateSchemeDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(SchemeType)
|
||||
@IsOptional()
|
||||
type?: SchemeType;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
rate?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
fixedAmount?: number;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
tiers?: TierDto[];
|
||||
|
||||
@IsEnum(AppliesTo)
|
||||
@IsOptional()
|
||||
appliesTo?: AppliesTo;
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
@IsOptional()
|
||||
productIds?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
@IsOptional()
|
||||
categoryIds?: string[];
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
minAmount?: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
maxAmount?: number;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SchemeResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: SchemeType;
|
||||
rate: number;
|
||||
fixedAmount: number;
|
||||
tiers: TierDto[];
|
||||
appliesTo: AppliesTo;
|
||||
productIds: string[];
|
||||
categoryIds: string[];
|
||||
minAmount: number;
|
||||
maxAmount: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
assignmentsCount?: number;
|
||||
}
|
||||
|
||||
export class SchemeListQueryDto {
|
||||
@IsEnum(SchemeType)
|
||||
@IsOptional()
|
||||
type?: SchemeType;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedSchemesDto {
|
||||
items: SchemeResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { CommissionSchemeEntity } from './commission-scheme.entity';
|
||||
|
||||
@Entity({ schema: 'commissions', name: 'assignments' })
|
||||
export class CommissionAssignmentEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'scheme_id', type: 'uuid' })
|
||||
schemeId: string;
|
||||
|
||||
@Column({ name: 'starts_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
startsAt: Date;
|
||||
|
||||
@Column({ name: 'ends_at', type: 'timestamptz', nullable: true })
|
||||
endsAt: Date;
|
||||
|
||||
@Column({ name: 'custom_rate', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
customRate: number;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@ManyToOne(() => CommissionSchemeEntity, (scheme) => scheme.assignments)
|
||||
@JoinColumn({ name: 'scheme_id' })
|
||||
scheme: CommissionSchemeEntity;
|
||||
}
|
||||
103
src/modules/commissions/entities/commission-entry.entity.ts
Normal file
103
src/modules/commissions/entities/commission-entry.entity.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { CommissionSchemeEntity } from './commission-scheme.entity';
|
||||
import { CommissionAssignmentEntity } from './commission-assignment.entity';
|
||||
import { CommissionPeriodEntity } from './commission-period.entity';
|
||||
|
||||
export enum EntryStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
PAID = 'paid',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'commissions', name: 'entries' })
|
||||
export class CommissionEntryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'scheme_id', type: 'uuid' })
|
||||
schemeId: string;
|
||||
|
||||
@Column({ name: 'assignment_id', type: 'uuid', nullable: true })
|
||||
assignmentId: string;
|
||||
|
||||
@Column({ name: 'reference_type', length: 50 })
|
||||
referenceType: string;
|
||||
|
||||
@Column({ name: 'reference_id', type: 'uuid' })
|
||||
referenceId: string;
|
||||
|
||||
@Column({ name: 'base_amount', type: 'decimal', precision: 15, scale: 2 })
|
||||
baseAmount: number;
|
||||
|
||||
@Column({ name: 'rate_applied', type: 'decimal', precision: 5, scale: 2 })
|
||||
rateApplied: number;
|
||||
|
||||
@Column({ name: 'commission_amount', type: 'decimal', precision: 15, scale: 2 })
|
||||
commissionAmount: number;
|
||||
|
||||
@Column({ length: 3, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: EntryStatus,
|
||||
enumName: 'entry_status',
|
||||
default: EntryStatus.PENDING,
|
||||
})
|
||||
status: EntryStatus;
|
||||
|
||||
@Column({ name: 'period_id', type: 'uuid', nullable: true })
|
||||
periodId: string;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt: Date;
|
||||
|
||||
@Column({ name: 'payment_reference', length: 255, nullable: true })
|
||||
paymentReference: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
||||
approvedBy: string;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||
approvedAt: Date;
|
||||
|
||||
@ManyToOne(() => CommissionSchemeEntity, (scheme) => scheme.entries)
|
||||
@JoinColumn({ name: 'scheme_id' })
|
||||
scheme: CommissionSchemeEntity;
|
||||
|
||||
@ManyToOne(() => CommissionAssignmentEntity)
|
||||
@JoinColumn({ name: 'assignment_id' })
|
||||
assignment: CommissionAssignmentEntity;
|
||||
|
||||
@ManyToOne(() => CommissionPeriodEntity, (period) => period.entries)
|
||||
@JoinColumn({ name: 'period_id' })
|
||||
period: CommissionPeriodEntity;
|
||||
}
|
||||
76
src/modules/commissions/entities/commission-period.entity.ts
Normal file
76
src/modules/commissions/entities/commission-period.entity.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum PeriodStatus {
|
||||
OPEN = 'open',
|
||||
CLOSED = 'closed',
|
||||
PROCESSING = 'processing',
|
||||
PAID = 'paid',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'commissions', name: 'periods' })
|
||||
export class CommissionPeriodEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'starts_at', type: 'timestamptz' })
|
||||
startsAt: Date;
|
||||
|
||||
@Column({ name: 'ends_at', type: 'timestamptz' })
|
||||
endsAt: Date;
|
||||
|
||||
@Column({ name: 'total_entries', type: 'int', default: 0 })
|
||||
totalEntries: number;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalAmount: number;
|
||||
|
||||
@Column({ length: 3, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PeriodStatus,
|
||||
enumName: 'period_status',
|
||||
default: PeriodStatus.OPEN,
|
||||
})
|
||||
status: PeriodStatus;
|
||||
|
||||
@Column({ name: 'closed_at', type: 'timestamptz', nullable: true })
|
||||
closedAt: Date;
|
||||
|
||||
@Column({ name: 'closed_by', type: 'uuid', nullable: true })
|
||||
closedBy: string;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt: Date;
|
||||
|
||||
@Column({ name: 'paid_by', type: 'uuid', nullable: true })
|
||||
paidBy: string;
|
||||
|
||||
@Column({ name: 'payment_reference', length: 255, nullable: true })
|
||||
paymentReference: string | null;
|
||||
|
||||
@Column({ name: 'payment_notes', type: 'text', nullable: true })
|
||||
paymentNotes: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@OneToMany('CommissionEntryEntity', 'period')
|
||||
entries: any[];
|
||||
}
|
||||
94
src/modules/commissions/entities/commission-scheme.entity.ts
Normal file
94
src/modules/commissions/entities/commission-scheme.entity.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum SchemeType {
|
||||
PERCENTAGE = 'percentage',
|
||||
FIXED = 'fixed',
|
||||
TIERED = 'tiered',
|
||||
}
|
||||
|
||||
export enum AppliesTo {
|
||||
ALL = 'all',
|
||||
PRODUCTS = 'products',
|
||||
CATEGORIES = 'categories',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'commissions', name: 'schemes' })
|
||||
export class CommissionSchemeEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SchemeType,
|
||||
enumName: 'scheme_type',
|
||||
default: SchemeType.PERCENTAGE,
|
||||
})
|
||||
type: SchemeType;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, default: 0 })
|
||||
rate: number;
|
||||
|
||||
@Column({ name: 'fixed_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
fixedAmount: number;
|
||||
|
||||
@Column({ type: 'jsonb', default: [] })
|
||||
tiers: any[];
|
||||
|
||||
@Column({
|
||||
name: 'applies_to',
|
||||
type: 'enum',
|
||||
enum: AppliesTo,
|
||||
enumName: 'applies_to',
|
||||
default: AppliesTo.ALL,
|
||||
})
|
||||
appliesTo: AppliesTo;
|
||||
|
||||
@Column({ name: 'product_ids', type: 'uuid', array: true, default: [] })
|
||||
productIds: string[];
|
||||
|
||||
@Column({ name: 'category_ids', type: 'uuid', array: true, default: [] })
|
||||
categoryIds: string[];
|
||||
|
||||
@Column({ name: 'min_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
minAmount: number;
|
||||
|
||||
@Column({ name: 'max_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
maxAmount: number;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@OneToMany('CommissionAssignmentEntity', 'scheme')
|
||||
assignments: any[];
|
||||
|
||||
@OneToMany('CommissionEntryEntity', 'scheme')
|
||||
entries: any[];
|
||||
}
|
||||
4
src/modules/commissions/entities/index.ts
Normal file
4
src/modules/commissions/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './commission-scheme.entity';
|
||||
export * from './commission-assignment.entity';
|
||||
export * from './commission-period.entity';
|
||||
export * from './commission-entry.entity';
|
||||
5
src/modules/commissions/index.ts
Normal file
5
src/modules/commissions/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './commissions.module';
|
||||
export * from './entities';
|
||||
export * from './dto';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
172
src/modules/commissions/services/assignments.service.ts
Normal file
172
src/modules/commissions/services/assignments.service.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CommissionAssignmentEntity, CommissionSchemeEntity } from '../entities';
|
||||
import {
|
||||
CreateAssignmentDto,
|
||||
UpdateAssignmentDto,
|
||||
AssignmentResponseDto,
|
||||
AssignmentListQueryDto,
|
||||
PaginatedAssignmentsDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class AssignmentsService {
|
||||
private readonly logger = new Logger(AssignmentsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CommissionAssignmentEntity)
|
||||
private readonly assignmentRepo: Repository<CommissionAssignmentEntity>,
|
||||
@InjectRepository(CommissionSchemeEntity)
|
||||
private readonly schemeRepo: Repository<CommissionSchemeEntity>,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateAssignmentDto): Promise<AssignmentResponseDto> {
|
||||
// Verify scheme exists
|
||||
const scheme = await this.schemeRepo.findOne({
|
||||
where: { id: dto.schemeId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!scheme) {
|
||||
throw new NotFoundException('Commission scheme not found');
|
||||
}
|
||||
|
||||
const assignment = this.assignmentRepo.create({
|
||||
tenantId,
|
||||
userId: dto.userId,
|
||||
schemeId: dto.schemeId,
|
||||
startsAt: dto.startsAt ? new Date(dto.startsAt) : new Date(),
|
||||
endsAt: dto.endsAt ? new Date(dto.endsAt) : undefined,
|
||||
customRate: dto.customRate,
|
||||
isActive: dto.isActive ?? true,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const saved = await this.assignmentRepo.save(assignment);
|
||||
this.logger.log(`Commission assignment created: ${saved.id} for user ${dto.userId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: AssignmentListQueryDto): Promise<PaginatedAssignmentsDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.assignmentRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.scheme', 'scheme')
|
||||
.where('a.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (query.userId) {
|
||||
qb.andWhere('a.user_id = :userId', { userId: query.userId });
|
||||
}
|
||||
|
||||
if (query.schemeId) {
|
||||
qb.andWhere('a.scheme_id = :schemeId', { schemeId: query.schemeId });
|
||||
}
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('a.is_active = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
|
||||
qb.orderBy('a.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, assignmentId: string): Promise<AssignmentResponseDto> {
|
||||
const assignment = await this.assignmentRepo.findOne({
|
||||
where: { id: assignmentId, tenantId },
|
||||
relations: ['scheme'],
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new NotFoundException('Commission assignment not found');
|
||||
}
|
||||
|
||||
return this.toResponse(assignment);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
assignmentId: string,
|
||||
dto: UpdateAssignmentDto,
|
||||
): Promise<AssignmentResponseDto> {
|
||||
const assignment = await this.assignmentRepo.findOne({
|
||||
where: { id: assignmentId, tenantId },
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new NotFoundException('Commission assignment not found');
|
||||
}
|
||||
|
||||
Object.assign(assignment, {
|
||||
startsAt: dto.startsAt ? new Date(dto.startsAt) : assignment.startsAt,
|
||||
endsAt: dto.endsAt ? new Date(dto.endsAt) : assignment.endsAt,
|
||||
customRate: dto.customRate ?? assignment.customRate,
|
||||
isActive: dto.isActive ?? assignment.isActive,
|
||||
});
|
||||
|
||||
const saved = await this.assignmentRepo.save(assignment);
|
||||
this.logger.log(`Commission assignment updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, assignmentId: string): Promise<void> {
|
||||
const assignment = await this.assignmentRepo.findOne({
|
||||
where: { id: assignmentId, tenantId },
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new NotFoundException('Commission assignment not found');
|
||||
}
|
||||
|
||||
await this.assignmentRepo.remove(assignment);
|
||||
this.logger.log(`Commission assignment deleted: ${assignmentId}`);
|
||||
}
|
||||
|
||||
async findActiveForUser(tenantId: string, userId: string): Promise<AssignmentResponseDto[]> {
|
||||
const assignments = await this.assignmentRepo.find({
|
||||
where: {
|
||||
tenantId,
|
||||
userId,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['scheme'],
|
||||
});
|
||||
|
||||
return assignments
|
||||
.filter((a) => {
|
||||
const now = new Date();
|
||||
return a.startsAt <= now && (!a.endsAt || a.endsAt > now);
|
||||
})
|
||||
.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
private toResponse(assignment: CommissionAssignmentEntity): AssignmentResponseDto {
|
||||
return {
|
||||
id: assignment.id,
|
||||
tenantId: assignment.tenantId,
|
||||
userId: assignment.userId,
|
||||
schemeId: assignment.schemeId,
|
||||
startsAt: assignment.startsAt,
|
||||
endsAt: assignment.endsAt,
|
||||
customRate: assignment.customRate ? Number(assignment.customRate) : null,
|
||||
isActive: assignment.isActive,
|
||||
createdAt: assignment.createdAt,
|
||||
createdBy: assignment.createdBy,
|
||||
scheme: assignment.scheme,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,183 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
CommissionSchemeEntity,
|
||||
CommissionAssignmentEntity,
|
||||
CommissionEntryEntity,
|
||||
CommissionPeriodEntity,
|
||||
EntryStatus,
|
||||
PeriodStatus,
|
||||
} from '../entities';
|
||||
import {
|
||||
CommissionsDashboardDto,
|
||||
UserEarningsDto,
|
||||
EntriesByStatusDto,
|
||||
EntriesBySchemeDto,
|
||||
EntriesByUserDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class CommissionsDashboardService {
|
||||
private readonly logger = new Logger(CommissionsDashboardService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CommissionSchemeEntity)
|
||||
private readonly schemeRepo: Repository<CommissionSchemeEntity>,
|
||||
@InjectRepository(CommissionAssignmentEntity)
|
||||
private readonly assignmentRepo: Repository<CommissionAssignmentEntity>,
|
||||
@InjectRepository(CommissionEntryEntity)
|
||||
private readonly entryRepo: Repository<CommissionEntryEntity>,
|
||||
@InjectRepository(CommissionPeriodEntity)
|
||||
private readonly periodRepo: Repository<CommissionPeriodEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getDashboardSummary(tenantId: string): Promise<CommissionsDashboardDto> {
|
||||
// Total schemes
|
||||
const totalSchemes = await this.schemeRepo.count({
|
||||
where: { tenantId, isActive: true, deletedAt: null as any },
|
||||
});
|
||||
|
||||
// Total active assignments
|
||||
const totalActiveAssignments = await this.assignmentRepo.count({
|
||||
where: { tenantId, isActive: true },
|
||||
});
|
||||
|
||||
// Entries by status
|
||||
const entriesStats = await this.entryRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('e.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.addSelect('COALESCE(SUM(e.commission_amount), 0)', 'total')
|
||||
.where('e.tenant_id = :tenantId', { tenantId })
|
||||
.groupBy('e.status')
|
||||
.getRawMany();
|
||||
|
||||
const statsMap = new Map(entriesStats.map((s) => [s.status, s]));
|
||||
|
||||
const pending = statsMap.get(EntryStatus.PENDING) || { count: 0, total: 0 };
|
||||
const approved = statsMap.get(EntryStatus.APPROVED) || { count: 0, total: 0 };
|
||||
const paid = statsMap.get(EntryStatus.PAID) || { count: 0, total: 0 };
|
||||
|
||||
// Current period
|
||||
const currentPeriod = await this.periodRepo.findOne({
|
||||
where: { tenantId, status: PeriodStatus.OPEN },
|
||||
order: { startsAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
totalSchemes,
|
||||
totalActiveAssignments,
|
||||
pendingCommissions: pending.count,
|
||||
pendingAmount: Number(pending.total),
|
||||
approvedAmount: Number(approved.total),
|
||||
paidAmount: Number(paid.total),
|
||||
currentPeriod: currentPeriod?.name || null,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
async getUserEarnings(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<UserEarningsDto> {
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT * FROM commissions.get_user_earnings($1, $2, $3, $4)`,
|
||||
[userId, tenantId, startDate || null, endDate || null],
|
||||
);
|
||||
|
||||
const earnings = result[0] || {
|
||||
total_pending: 0,
|
||||
total_approved: 0,
|
||||
total_paid: 0,
|
||||
total_entries: 0,
|
||||
currency: 'USD',
|
||||
};
|
||||
|
||||
return {
|
||||
totalPending: Number(earnings.total_pending),
|
||||
totalApproved: Number(earnings.total_approved),
|
||||
totalPaid: Number(earnings.total_paid),
|
||||
totalEntries: Number(earnings.total_entries),
|
||||
currency: earnings.currency,
|
||||
};
|
||||
}
|
||||
|
||||
async getEntriesByStatus(tenantId: string): Promise<EntriesByStatusDto[]> {
|
||||
const result = await this.entryRepo
|
||||
.createQueryBuilder('e')
|
||||
.select('e.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.addSelect('COALESCE(SUM(e.commission_amount), 0)', 'totalAmount')
|
||||
.where('e.tenant_id = :tenantId', { tenantId })
|
||||
.groupBy('e.status')
|
||||
.getRawMany();
|
||||
|
||||
const total = result.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
return result.map((r) => ({
|
||||
status: r.status,
|
||||
count: r.count,
|
||||
totalAmount: Number(r.totalAmount),
|
||||
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getEntriesByScheme(tenantId: string): Promise<EntriesBySchemeDto[]> {
|
||||
const result = await this.entryRepo
|
||||
.createQueryBuilder('e')
|
||||
.innerJoin('e.scheme', 's')
|
||||
.select('e.scheme_id', 'schemeId')
|
||||
.addSelect('s.name', 'schemeName')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.addSelect('COALESCE(SUM(e.commission_amount), 0)', 'totalAmount')
|
||||
.where('e.tenant_id = :tenantId', { tenantId })
|
||||
.groupBy('e.scheme_id')
|
||||
.addGroupBy('s.name')
|
||||
.getRawMany();
|
||||
|
||||
const total = result.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
return result.map((r) => ({
|
||||
schemeId: r.schemeId,
|
||||
schemeName: r.schemeName,
|
||||
count: r.count,
|
||||
totalAmount: Number(r.totalAmount),
|
||||
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getEntriesByUser(tenantId: string): Promise<EntriesByUserDto[]> {
|
||||
const result = await this.dataSource.query(`
|
||||
SELECT
|
||||
e.user_id as "userId",
|
||||
COALESCE(u.first_name || ' ' || u.last_name, u.email) as "userName",
|
||||
COUNT(*)::int as count,
|
||||
COALESCE(SUM(e.commission_amount), 0) as "totalAmount"
|
||||
FROM commissions.entries e
|
||||
LEFT JOIN users.users u ON e.user_id = u.id
|
||||
WHERE e.tenant_id = $1
|
||||
GROUP BY e.user_id, u.first_name, u.last_name, u.email
|
||||
ORDER BY "totalAmount" DESC
|
||||
`, [tenantId]);
|
||||
|
||||
const total = result.reduce((sum: number, r: any) => sum + r.count, 0);
|
||||
|
||||
return result.map((r: any) => ({
|
||||
userId: r.userId,
|
||||
userName: r.userName,
|
||||
count: r.count,
|
||||
totalAmount: Number(r.totalAmount),
|
||||
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopEarners(tenantId: string, limit: number = 10): Promise<EntriesByUserDto[]> {
|
||||
const users = await this.getEntriesByUser(tenantId);
|
||||
return users.slice(0, limit);
|
||||
}
|
||||
}
|
||||
288
src/modules/commissions/services/entries.service.ts
Normal file
288
src/modules/commissions/services/entries.service.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { CommissionEntryEntity, EntryStatus } from '../entities';
|
||||
import {
|
||||
CreateEntryDto,
|
||||
UpdateEntryDto,
|
||||
ApproveEntryDto,
|
||||
RejectEntryDto,
|
||||
EntryResponseDto,
|
||||
EntryListQueryDto,
|
||||
PaginatedEntriesDto,
|
||||
CalculateCommissionDto,
|
||||
CalculateCommissionResponseDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class EntriesService {
|
||||
private readonly logger = new Logger(EntriesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CommissionEntryEntity)
|
||||
private readonly entryRepo: Repository<CommissionEntryEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateEntryDto): Promise<EntryResponseDto> {
|
||||
// Calculate commission using database function
|
||||
const calcResult = await this.dataSource.query(
|
||||
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
|
||||
[dto.schemeId, dto.userId, dto.baseAmount, tenantId],
|
||||
);
|
||||
|
||||
const { rate_applied, commission_amount } = calcResult[0] || { rate_applied: 0, commission_amount: 0 };
|
||||
|
||||
if (commission_amount === 0) {
|
||||
throw new BadRequestException('Commission calculation resulted in zero amount. Check scheme configuration.');
|
||||
}
|
||||
|
||||
const entry = this.entryRepo.create({
|
||||
tenantId,
|
||||
userId: dto.userId,
|
||||
schemeId: dto.schemeId,
|
||||
assignmentId: dto.assignmentId,
|
||||
referenceType: dto.referenceType,
|
||||
referenceId: dto.referenceId,
|
||||
baseAmount: dto.baseAmount,
|
||||
rateApplied: rate_applied,
|
||||
commissionAmount: commission_amount,
|
||||
currency: dto.currency ?? 'USD',
|
||||
notes: dto.notes,
|
||||
metadata: dto.metadata ?? {},
|
||||
});
|
||||
|
||||
const saved = await this.entryRepo.save(entry);
|
||||
this.logger.log(`Commission entry created: ${saved.id} for user ${dto.userId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: EntryListQueryDto): Promise<PaginatedEntriesDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.entryRepo
|
||||
.createQueryBuilder('e')
|
||||
.leftJoinAndSelect('e.scheme', 'scheme')
|
||||
.leftJoinAndSelect('e.period', 'period')
|
||||
.where('e.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (query.userId) {
|
||||
qb.andWhere('e.user_id = :userId', { userId: query.userId });
|
||||
}
|
||||
|
||||
if (query.schemeId) {
|
||||
qb.andWhere('e.scheme_id = :schemeId', { schemeId: query.schemeId });
|
||||
}
|
||||
|
||||
if (query.periodId) {
|
||||
qb.andWhere('e.period_id = :periodId', { periodId: query.periodId });
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('e.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
if (query.referenceType) {
|
||||
qb.andWhere('e.reference_type = :referenceType', { referenceType: query.referenceType });
|
||||
}
|
||||
|
||||
qb.orderBy('e.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((e) => this.toResponse(e)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, entryId: string): Promise<EntryResponseDto> {
|
||||
const entry = await this.entryRepo.findOne({
|
||||
where: { id: entryId, tenantId },
|
||||
relations: ['scheme', 'period'],
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException('Commission entry not found');
|
||||
}
|
||||
|
||||
return this.toResponse(entry);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
entryId: string,
|
||||
dto: UpdateEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
const entry = await this.entryRepo.findOne({
|
||||
where: { id: entryId, tenantId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException('Commission entry not found');
|
||||
}
|
||||
|
||||
Object.assign(entry, {
|
||||
status: dto.status ?? entry.status,
|
||||
periodId: dto.periodId ?? entry.periodId,
|
||||
notes: dto.notes ?? entry.notes,
|
||||
metadata: dto.metadata ?? entry.metadata,
|
||||
});
|
||||
|
||||
const saved = await this.entryRepo.save(entry);
|
||||
this.logger.log(`Commission entry updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async approve(
|
||||
tenantId: string,
|
||||
entryId: string,
|
||||
approvedBy: string,
|
||||
dto: ApproveEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
const entry = await this.entryRepo.findOne({
|
||||
where: { id: entryId, tenantId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException('Commission entry not found');
|
||||
}
|
||||
|
||||
if (entry.status !== EntryStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending entries can be approved');
|
||||
}
|
||||
|
||||
entry.status = EntryStatus.APPROVED;
|
||||
entry.approvedBy = approvedBy;
|
||||
entry.approvedAt = new Date();
|
||||
entry.notes = dto.notes ?? entry.notes;
|
||||
|
||||
const saved = await this.entryRepo.save(entry);
|
||||
this.logger.log(`Commission entry approved: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async reject(
|
||||
tenantId: string,
|
||||
entryId: string,
|
||||
rejectedBy: string,
|
||||
dto: RejectEntryDto,
|
||||
): Promise<EntryResponseDto> {
|
||||
const entry = await this.entryRepo.findOne({
|
||||
where: { id: entryId, tenantId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException('Commission entry not found');
|
||||
}
|
||||
|
||||
if (entry.status !== EntryStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending entries can be rejected');
|
||||
}
|
||||
|
||||
entry.status = EntryStatus.REJECTED;
|
||||
entry.approvedBy = rejectedBy;
|
||||
entry.approvedAt = new Date();
|
||||
entry.notes = dto.notes;
|
||||
|
||||
const saved = await this.entryRepo.save(entry);
|
||||
this.logger.log(`Commission entry rejected: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async calculateCommission(
|
||||
tenantId: string,
|
||||
dto: CalculateCommissionDto,
|
||||
): Promise<CalculateCommissionResponseDto> {
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
|
||||
[dto.schemeId, dto.userId, dto.amount, tenantId],
|
||||
);
|
||||
|
||||
const { rate_applied, commission_amount } = result[0] || { rate_applied: 0, commission_amount: 0 };
|
||||
|
||||
return {
|
||||
rateApplied: Number(rate_applied),
|
||||
commissionAmount: Number(commission_amount),
|
||||
};
|
||||
}
|
||||
|
||||
async markAsPaid(tenantId: string, entryId: string, paymentReference: string): Promise<EntryResponseDto> {
|
||||
const entry = await this.entryRepo.findOne({
|
||||
where: { id: entryId, tenantId },
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
throw new NotFoundException('Commission entry not found');
|
||||
}
|
||||
|
||||
if (entry.status !== EntryStatus.APPROVED) {
|
||||
throw new BadRequestException('Only approved entries can be marked as paid');
|
||||
}
|
||||
|
||||
entry.status = EntryStatus.PAID;
|
||||
entry.paidAt = new Date();
|
||||
entry.paymentReference = paymentReference;
|
||||
|
||||
const saved = await this.entryRepo.save(entry);
|
||||
this.logger.log(`Commission entry marked as paid: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async bulkApprove(tenantId: string, entryIds: string[], approvedBy: string): Promise<number> {
|
||||
const result = await this.entryRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
status: EntryStatus.APPROVED,
|
||||
approvedBy,
|
||||
approvedAt: new Date(),
|
||||
})
|
||||
.where('id IN (:...entryIds)', { entryIds })
|
||||
.andWhere('tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('status = :status', { status: EntryStatus.PENDING })
|
||||
.execute();
|
||||
|
||||
this.logger.log(`Bulk approved ${result.affected} commission entries`);
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
private toResponse(entry: CommissionEntryEntity): EntryResponseDto {
|
||||
return {
|
||||
id: entry.id,
|
||||
tenantId: entry.tenantId,
|
||||
userId: entry.userId,
|
||||
schemeId: entry.schemeId,
|
||||
assignmentId: entry.assignmentId,
|
||||
referenceType: entry.referenceType,
|
||||
referenceId: entry.referenceId,
|
||||
baseAmount: Number(entry.baseAmount),
|
||||
rateApplied: Number(entry.rateApplied),
|
||||
commissionAmount: Number(entry.commissionAmount),
|
||||
currency: entry.currency,
|
||||
status: entry.status,
|
||||
periodId: entry.periodId,
|
||||
paidAt: entry.paidAt,
|
||||
paymentReference: entry.paymentReference,
|
||||
notes: entry.notes,
|
||||
metadata: entry.metadata,
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
approvedBy: entry.approvedBy,
|
||||
approvedAt: entry.approvedAt,
|
||||
scheme: entry.scheme,
|
||||
period: entry.period,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
src/modules/commissions/services/index.ts
Normal file
5
src/modules/commissions/services/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './schemes.service';
|
||||
export * from './assignments.service';
|
||||
export * from './entries.service';
|
||||
export * from './periods.service';
|
||||
export * from './commissions-dashboard.service';
|
||||
263
src/modules/commissions/services/periods.service.ts
Normal file
263
src/modules/commissions/services/periods.service.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { CommissionPeriodEntity, PeriodStatus, CommissionEntryEntity, EntryStatus } from '../entities';
|
||||
import {
|
||||
CreatePeriodDto,
|
||||
UpdatePeriodDto,
|
||||
ClosePeriodDto,
|
||||
MarkPaidDto,
|
||||
PeriodResponseDto,
|
||||
PeriodListQueryDto,
|
||||
PaginatedPeriodsDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class PeriodsService {
|
||||
private readonly logger = new Logger(PeriodsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CommissionPeriodEntity)
|
||||
private readonly periodRepo: Repository<CommissionPeriodEntity>,
|
||||
@InjectRepository(CommissionEntryEntity)
|
||||
private readonly entryRepo: Repository<CommissionEntryEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreatePeriodDto): Promise<PeriodResponseDto> {
|
||||
const period = this.periodRepo.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
startsAt: new Date(dto.startsAt),
|
||||
endsAt: new Date(dto.endsAt),
|
||||
currency: dto.currency ?? 'USD',
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const saved = await this.periodRepo.save(period);
|
||||
this.logger.log(`Commission period created: ${saved.id} for tenant ${tenantId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: PeriodListQueryDto): Promise<PaginatedPeriodsDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.periodRepo
|
||||
.createQueryBuilder('p')
|
||||
.where('p.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('p.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
qb.orderBy('p.starts_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((p) => this.toResponse(p)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, periodId: string): Promise<PeriodResponseDto> {
|
||||
const period = await this.periodRepo.findOne({
|
||||
where: { id: periodId, tenantId },
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
throw new NotFoundException('Commission period not found');
|
||||
}
|
||||
|
||||
return this.toResponse(period);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
periodId: string,
|
||||
dto: UpdatePeriodDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
const period = await this.periodRepo.findOne({
|
||||
where: { id: periodId, tenantId },
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
throw new NotFoundException('Commission period not found');
|
||||
}
|
||||
|
||||
if (period.status !== PeriodStatus.OPEN) {
|
||||
throw new BadRequestException('Only open periods can be updated');
|
||||
}
|
||||
|
||||
Object.assign(period, {
|
||||
name: dto.name ?? period.name,
|
||||
startsAt: dto.startsAt ? new Date(dto.startsAt) : period.startsAt,
|
||||
endsAt: dto.endsAt ? new Date(dto.endsAt) : period.endsAt,
|
||||
});
|
||||
|
||||
const saved = await this.periodRepo.save(period);
|
||||
this.logger.log(`Commission period updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async close(
|
||||
tenantId: string,
|
||||
periodId: string,
|
||||
userId: string,
|
||||
dto: ClosePeriodDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
const period = await this.periodRepo.findOne({
|
||||
where: { id: periodId, tenantId },
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
throw new NotFoundException('Commission period not found');
|
||||
}
|
||||
|
||||
if (period.status !== PeriodStatus.OPEN) {
|
||||
throw new BadRequestException('Only open periods can be closed');
|
||||
}
|
||||
|
||||
// Use database function to close period
|
||||
await this.dataSource.query(
|
||||
`SELECT commissions.close_period($1, $2)`,
|
||||
[periodId, userId],
|
||||
);
|
||||
|
||||
// Reload the period
|
||||
const updated = await this.periodRepo.findOne({ where: { id: periodId } });
|
||||
this.logger.log(`Commission period closed: ${periodId}`);
|
||||
|
||||
return this.toResponse(updated!);
|
||||
}
|
||||
|
||||
async markAsPaid(
|
||||
tenantId: string,
|
||||
periodId: string,
|
||||
userId: string,
|
||||
dto: MarkPaidDto,
|
||||
): Promise<PeriodResponseDto> {
|
||||
const period = await this.periodRepo.findOne({
|
||||
where: { id: periodId, tenantId },
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
throw new NotFoundException('Commission period not found');
|
||||
}
|
||||
|
||||
if (period.status !== PeriodStatus.CLOSED && period.status !== PeriodStatus.PROCESSING) {
|
||||
throw new BadRequestException('Only closed or processing periods can be marked as paid');
|
||||
}
|
||||
|
||||
// Mark all approved entries in this period as paid
|
||||
await this.entryRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({
|
||||
status: EntryStatus.PAID,
|
||||
paidAt: new Date(),
|
||||
paymentReference: dto.paymentReference,
|
||||
})
|
||||
.where('period_id = :periodId', { periodId })
|
||||
.andWhere('status = :status', { status: EntryStatus.APPROVED })
|
||||
.execute();
|
||||
|
||||
// Update period status
|
||||
period.status = PeriodStatus.PAID;
|
||||
period.paidAt = new Date();
|
||||
period.paidBy = userId;
|
||||
period.paymentReference = dto.paymentReference ?? null;
|
||||
period.paymentNotes = dto.paymentNotes ?? null;
|
||||
|
||||
const saved = await this.periodRepo.save(period);
|
||||
this.logger.log(`Commission period marked as paid: ${periodId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, periodId: string): Promise<void> {
|
||||
const period = await this.periodRepo.findOne({
|
||||
where: { id: periodId, tenantId },
|
||||
});
|
||||
|
||||
if (!period) {
|
||||
throw new NotFoundException('Commission period not found');
|
||||
}
|
||||
|
||||
if (period.status !== PeriodStatus.OPEN) {
|
||||
throw new BadRequestException('Only open periods can be deleted');
|
||||
}
|
||||
|
||||
// Check if there are entries in this period
|
||||
const entriesCount = await this.entryRepo.count({
|
||||
where: { periodId, tenantId },
|
||||
});
|
||||
|
||||
if (entriesCount > 0) {
|
||||
throw new BadRequestException(`Cannot delete period with ${entriesCount} entries`);
|
||||
}
|
||||
|
||||
await this.periodRepo.remove(period);
|
||||
this.logger.log(`Commission period deleted: ${periodId}`);
|
||||
}
|
||||
|
||||
async getCurrentPeriod(tenantId: string): Promise<PeriodResponseDto | null> {
|
||||
const now = new Date();
|
||||
const period = await this.periodRepo.findOne({
|
||||
where: {
|
||||
tenantId,
|
||||
status: PeriodStatus.OPEN,
|
||||
},
|
||||
order: { startsAt: 'DESC' },
|
||||
});
|
||||
|
||||
return period ? this.toResponse(period) : null;
|
||||
}
|
||||
|
||||
async assignEntriesToPeriod(
|
||||
tenantId: string,
|
||||
periodId: string,
|
||||
entryIds: string[],
|
||||
): Promise<number> {
|
||||
const result = await this.entryRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ periodId })
|
||||
.where('id IN (:...entryIds)', { entryIds })
|
||||
.andWhere('tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('period_id IS NULL')
|
||||
.execute();
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
private toResponse(period: CommissionPeriodEntity): PeriodResponseDto {
|
||||
return {
|
||||
id: period.id,
|
||||
tenantId: period.tenantId,
|
||||
name: period.name,
|
||||
startsAt: period.startsAt,
|
||||
endsAt: period.endsAt,
|
||||
totalEntries: period.totalEntries,
|
||||
totalAmount: Number(period.totalAmount),
|
||||
currency: period.currency,
|
||||
status: period.status,
|
||||
closedAt: period.closedAt,
|
||||
closedBy: period.closedBy,
|
||||
paidAt: period.paidAt,
|
||||
paidBy: period.paidBy,
|
||||
paymentReference: period.paymentReference,
|
||||
paymentNotes: period.paymentNotes,
|
||||
createdAt: period.createdAt,
|
||||
createdBy: period.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
195
src/modules/commissions/services/schemes.service.ts
Normal file
195
src/modules/commissions/services/schemes.service.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CommissionSchemeEntity } from '../entities';
|
||||
import {
|
||||
CreateSchemeDto,
|
||||
UpdateSchemeDto,
|
||||
SchemeResponseDto,
|
||||
SchemeListQueryDto,
|
||||
PaginatedSchemesDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class SchemesService {
|
||||
private readonly logger = new Logger(SchemesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CommissionSchemeEntity)
|
||||
private readonly schemeRepo: Repository<CommissionSchemeEntity>,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateSchemeDto): Promise<SchemeResponseDto> {
|
||||
const scheme = this.schemeRepo.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
type: dto.type,
|
||||
rate: dto.rate ?? 0,
|
||||
fixedAmount: dto.fixedAmount ?? 0,
|
||||
tiers: dto.tiers ?? [],
|
||||
appliesTo: dto.appliesTo,
|
||||
productIds: dto.productIds ?? [],
|
||||
categoryIds: dto.categoryIds ?? [],
|
||||
minAmount: dto.minAmount ?? 0,
|
||||
maxAmount: dto.maxAmount,
|
||||
isActive: dto.isActive ?? true,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const saved = await this.schemeRepo.save(scheme);
|
||||
this.logger.log(`Commission scheme created: ${saved.id} for tenant ${tenantId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: SchemeListQueryDto): Promise<PaginatedSchemesDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.schemeRepo
|
||||
.createQueryBuilder('s')
|
||||
.where('s.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('s.deleted_at IS NULL');
|
||||
|
||||
if (query.type) {
|
||||
qb.andWhere('s.type = :type', { type: query.type });
|
||||
}
|
||||
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('s.is_active = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
qb.andWhere('(s.name ILIKE :search OR s.description ILIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
qb.orderBy('s.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((s) => this.toResponse(s)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, schemeId: string): Promise<SchemeResponseDto> {
|
||||
const scheme = await this.schemeRepo.findOne({
|
||||
where: { id: schemeId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!scheme) {
|
||||
throw new NotFoundException('Commission scheme not found');
|
||||
}
|
||||
|
||||
return this.toResponse(scheme);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
schemeId: string,
|
||||
dto: UpdateSchemeDto,
|
||||
): Promise<SchemeResponseDto> {
|
||||
const scheme = await this.schemeRepo.findOne({
|
||||
where: { id: schemeId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!scheme) {
|
||||
throw new NotFoundException('Commission scheme not found');
|
||||
}
|
||||
|
||||
Object.assign(scheme, {
|
||||
name: dto.name ?? scheme.name,
|
||||
description: dto.description ?? scheme.description,
|
||||
type: dto.type ?? scheme.type,
|
||||
rate: dto.rate ?? scheme.rate,
|
||||
fixedAmount: dto.fixedAmount ?? scheme.fixedAmount,
|
||||
tiers: dto.tiers ?? scheme.tiers,
|
||||
appliesTo: dto.appliesTo ?? scheme.appliesTo,
|
||||
productIds: dto.productIds ?? scheme.productIds,
|
||||
categoryIds: dto.categoryIds ?? scheme.categoryIds,
|
||||
minAmount: dto.minAmount ?? scheme.minAmount,
|
||||
maxAmount: dto.maxAmount ?? scheme.maxAmount,
|
||||
isActive: dto.isActive ?? scheme.isActive,
|
||||
});
|
||||
|
||||
const saved = await this.schemeRepo.save(scheme);
|
||||
this.logger.log(`Commission scheme updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, schemeId: string): Promise<void> {
|
||||
const scheme = await this.schemeRepo.findOne({
|
||||
where: { id: schemeId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!scheme) {
|
||||
throw new NotFoundException('Commission scheme not found');
|
||||
}
|
||||
|
||||
scheme.deletedAt = new Date();
|
||||
await this.schemeRepo.save(scheme);
|
||||
this.logger.log(`Commission scheme soft-deleted: ${schemeId}`);
|
||||
}
|
||||
|
||||
async activate(tenantId: string, schemeId: string): Promise<SchemeResponseDto> {
|
||||
const scheme = await this.schemeRepo.findOne({
|
||||
where: { id: schemeId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!scheme) {
|
||||
throw new NotFoundException('Commission scheme not found');
|
||||
}
|
||||
|
||||
scheme.isActive = true;
|
||||
const saved = await this.schemeRepo.save(scheme);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async deactivate(tenantId: string, schemeId: string): Promise<SchemeResponseDto> {
|
||||
const scheme = await this.schemeRepo.findOne({
|
||||
where: { id: schemeId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!scheme) {
|
||||
throw new NotFoundException('Commission scheme not found');
|
||||
}
|
||||
|
||||
scheme.isActive = false;
|
||||
const saved = await this.schemeRepo.save(scheme);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
private toResponse(scheme: CommissionSchemeEntity): SchemeResponseDto {
|
||||
return {
|
||||
id: scheme.id,
|
||||
tenantId: scheme.tenantId,
|
||||
name: scheme.name,
|
||||
description: scheme.description,
|
||||
type: scheme.type,
|
||||
rate: Number(scheme.rate),
|
||||
fixedAmount: Number(scheme.fixedAmount),
|
||||
tiers: scheme.tiers,
|
||||
appliesTo: scheme.appliesTo,
|
||||
productIds: scheme.productIds,
|
||||
categoryIds: scheme.categoryIds,
|
||||
minAmount: Number(scheme.minAmount),
|
||||
maxAmount: scheme.maxAmount ? Number(scheme.maxAmount) : null,
|
||||
isActive: scheme.isActive,
|
||||
createdAt: scheme.createdAt,
|
||||
updatedAt: scheme.updatedAt,
|
||||
createdBy: scheme.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
src/modules/sales/controllers/activities.controller.ts
Normal file
92
src/modules/sales/controllers/activities.controller.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { ActivitiesService } from '../services';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
UpdateActivityDto,
|
||||
CompleteActivityDto,
|
||||
ActivityListQueryDto,
|
||||
ActivityResponseDto,
|
||||
PaginatedActivitiesDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('sales/activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivitiesController {
|
||||
constructor(private readonly activitiesService: ActivitiesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: ActivityListQueryDto,
|
||||
): Promise<PaginatedActivitiesDto> {
|
||||
return this.activitiesService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get('upcoming')
|
||||
async getUpcoming(@CurrentUser() user: RequestUser): Promise<ActivityResponseDto[]> {
|
||||
return this.activitiesService.getUpcoming(user.tenant_id, user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ActivityResponseDto> {
|
||||
return this.activitiesService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateActivityDto,
|
||||
): Promise<ActivityResponseDto> {
|
||||
return this.activitiesService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateActivityDto,
|
||||
): Promise<ActivityResponseDto> {
|
||||
return this.activitiesService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/complete')
|
||||
async complete(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CompleteActivityDto,
|
||||
): Promise<ActivityResponseDto> {
|
||||
return this.activitiesService.complete(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.activitiesService.remove(user.tenant_id, id);
|
||||
return { message: 'Activity deleted successfully' };
|
||||
}
|
||||
}
|
||||
57
src/modules/sales/controllers/dashboard.controller.ts
Normal file
57
src/modules/sales/controllers/dashboard.controller.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { SalesDashboardService } from '../services';
|
||||
import {
|
||||
SalesDashboardDto,
|
||||
LeadsByStatusDto,
|
||||
LeadsBySourceDto,
|
||||
OpportunitiesByStageDto,
|
||||
ConversionFunnelDto,
|
||||
SalesPerformanceDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('sales/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SalesDashboardController {
|
||||
constructor(private readonly dashboardService: SalesDashboardService) {}
|
||||
|
||||
@Get()
|
||||
async getSummary(@CurrentUser() user: RequestUser): Promise<SalesDashboardDto> {
|
||||
return this.dashboardService.getDashboardSummary(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('leads-by-status')
|
||||
async getLeadsByStatus(@CurrentUser() user: RequestUser): Promise<LeadsByStatusDto[]> {
|
||||
return this.dashboardService.getLeadsByStatus(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('leads-by-source')
|
||||
async getLeadsBySource(@CurrentUser() user: RequestUser): Promise<LeadsBySourceDto[]> {
|
||||
return this.dashboardService.getLeadsBySource(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('opportunities-by-stage')
|
||||
async getOpportunitiesByStage(
|
||||
@CurrentUser() user: RequestUser,
|
||||
): Promise<OpportunitiesByStageDto[]> {
|
||||
return this.dashboardService.getOpportunitiesByStage(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('conversion-funnel')
|
||||
async getConversionFunnel(@CurrentUser() user: RequestUser): Promise<ConversionFunnelDto[]> {
|
||||
return this.dashboardService.getConversionFunnel(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('sales-performance')
|
||||
async getSalesPerformance(@CurrentUser() user: RequestUser): Promise<SalesPerformanceDto[]> {
|
||||
return this.dashboardService.getSalesPerformance(user.tenant_id);
|
||||
}
|
||||
}
|
||||
5
src/modules/sales/controllers/index.ts
Normal file
5
src/modules/sales/controllers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './leads.controller';
|
||||
export * from './opportunities.controller';
|
||||
export * from './activities.controller';
|
||||
export * from './pipeline.controller';
|
||||
export * from './dashboard.controller';
|
||||
104
src/modules/sales/controllers/leads.controller.ts
Normal file
104
src/modules/sales/controllers/leads.controller.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { LeadsService } from '../services';
|
||||
import {
|
||||
CreateLeadDto,
|
||||
UpdateLeadDto,
|
||||
ConvertLeadDto,
|
||||
LeadListQueryDto,
|
||||
LeadResponseDto,
|
||||
PaginatedLeadsDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('sales/leads')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LeadsController {
|
||||
constructor(private readonly leadsService: LeadsService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: LeadListQueryDto,
|
||||
): Promise<PaginatedLeadsDto> {
|
||||
return this.leadsService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<LeadResponseDto> {
|
||||
return this.leadsService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateLeadDto,
|
||||
): Promise<LeadResponseDto> {
|
||||
return this.leadsService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateLeadDto,
|
||||
): Promise<LeadResponseDto> {
|
||||
return this.leadsService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.leadsService.remove(user.tenant_id, id);
|
||||
return { message: 'Lead deleted successfully' };
|
||||
}
|
||||
|
||||
@Post(':id/convert')
|
||||
async convert(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ConvertLeadDto,
|
||||
): Promise<{ opportunityId: string }> {
|
||||
return this.leadsService.convert(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/calculate-score')
|
||||
async calculateScore(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ score: number }> {
|
||||
return this.leadsService.calculateScore(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Put(':id/assign/:userId')
|
||||
async assign(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
): Promise<LeadResponseDto> {
|
||||
return this.leadsService.assignTo(user.tenant_id, id, userId);
|
||||
}
|
||||
}
|
||||
102
src/modules/sales/controllers/opportunities.controller.ts
Normal file
102
src/modules/sales/controllers/opportunities.controller.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { OpportunitiesService } from '../services';
|
||||
import {
|
||||
CreateOpportunityDto,
|
||||
UpdateOpportunityDto,
|
||||
UpdateOpportunityStageDto,
|
||||
OpportunityListQueryDto,
|
||||
OpportunityResponseDto,
|
||||
PaginatedOpportunitiesDto,
|
||||
PipelineSummaryDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('sales/opportunities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OpportunitiesController {
|
||||
constructor(private readonly opportunitiesService: OpportunitiesService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() query: OpportunityListQueryDto,
|
||||
): Promise<PaginatedOpportunitiesDto> {
|
||||
return this.opportunitiesService.findAll(user.tenant_id, query);
|
||||
}
|
||||
|
||||
@Get('pipeline-summary')
|
||||
async getPipelineSummary(@CurrentUser() user: RequestUser): Promise<PipelineSummaryDto[]> {
|
||||
return this.opportunitiesService.getPipelineSummary(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
return this.opportunitiesService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateOpportunityDto,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
return this.opportunitiesService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateOpportunityDto,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
return this.opportunitiesService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Put(':id/stage')
|
||||
async updateStage(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateOpportunityStageDto,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
return this.opportunitiesService.updateStage(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.opportunitiesService.remove(user.tenant_id, id);
|
||||
return { message: 'Opportunity deleted successfully' };
|
||||
}
|
||||
|
||||
@Put(':id/assign/:userId')
|
||||
async assign(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
return this.opportunitiesService.assignTo(user.tenant_id, id, userId);
|
||||
}
|
||||
}
|
||||
86
src/modules/sales/controllers/pipeline.controller.ts
Normal file
86
src/modules/sales/controllers/pipeline.controller.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { PipelineService } from '../services';
|
||||
import {
|
||||
CreatePipelineStageDto,
|
||||
UpdatePipelineStageDto,
|
||||
ReorderStagesDto,
|
||||
PipelineStageResponseDto,
|
||||
} from '../dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Controller('sales/pipeline')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PipelineController {
|
||||
constructor(private readonly pipelineService: PipelineService) {}
|
||||
|
||||
@Get('stages')
|
||||
async findAll(@CurrentUser() user: RequestUser): Promise<PipelineStageResponseDto[]> {
|
||||
return this.pipelineService.findAll(user.tenant_id);
|
||||
}
|
||||
|
||||
@Get('stages/:id')
|
||||
async findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<PipelineStageResponseDto> {
|
||||
return this.pipelineService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post('stages')
|
||||
async create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreatePipelineStageDto,
|
||||
): Promise<PipelineStageResponseDto> {
|
||||
return this.pipelineService.create(user.tenant_id, dto);
|
||||
}
|
||||
|
||||
@Put('stages/:id')
|
||||
async update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePipelineStageDto,
|
||||
): Promise<PipelineStageResponseDto> {
|
||||
return this.pipelineService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete('stages/:id')
|
||||
async remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ message: string }> {
|
||||
await this.pipelineService.remove(user.tenant_id, id);
|
||||
return { message: 'Pipeline stage deleted successfully' };
|
||||
}
|
||||
|
||||
@Post('stages/reorder')
|
||||
async reorder(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: ReorderStagesDto,
|
||||
): Promise<PipelineStageResponseDto[]> {
|
||||
return this.pipelineService.reorder(user.tenant_id, dto);
|
||||
}
|
||||
|
||||
@Post('initialize-defaults')
|
||||
async initializeDefaults(@CurrentUser() user: RequestUser): Promise<{ message: string }> {
|
||||
await this.pipelineService.initializeDefaults(user.tenant_id);
|
||||
return { message: 'Default pipeline stages initialized' };
|
||||
}
|
||||
}
|
||||
232
src/modules/sales/dto/activity.dto.ts
Normal file
232
src/modules/sales/dto/activity.dto.ts
Normal file
@ -0,0 +1,232 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
IsArray,
|
||||
MaxLength,
|
||||
Min,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ActivityType, ActivityStatus } from '../entities';
|
||||
|
||||
export class CreateActivityDto {
|
||||
@IsEnum(ActivityType)
|
||||
type: ActivityType;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
subject: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
leadId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
opportunityId?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
dueDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
dueTime?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
durationMinutes?: number;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
@IsOptional()
|
||||
callDirection?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsOptional()
|
||||
callRecordingUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
location?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsOptional()
|
||||
meetingUrl?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
attendees?: any[];
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
reminderAt?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateActivityDto {
|
||||
@IsEnum(ActivityType)
|
||||
@IsOptional()
|
||||
type?: ActivityType;
|
||||
|
||||
@IsEnum(ActivityStatus)
|
||||
@IsOptional()
|
||||
status?: ActivityStatus;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
subject?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
dueDate?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
dueTime?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
durationMinutes?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
outcome?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
@IsOptional()
|
||||
callDirection?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsOptional()
|
||||
callRecordingUrl?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
location?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsOptional()
|
||||
meetingUrl?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
attendees?: any[];
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
reminderAt?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
reminderSent?: boolean;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class CompleteActivityDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
export class ActivityResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
type: ActivityType;
|
||||
status: ActivityStatus;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
leadId: string | null;
|
||||
opportunityId: string | null;
|
||||
dueDate: Date | null;
|
||||
dueTime: string | null;
|
||||
durationMinutes: number | null;
|
||||
completedAt: Date | null;
|
||||
outcome: string | null;
|
||||
assignedTo: string | null;
|
||||
createdBy: string | null;
|
||||
callDirection: string | null;
|
||||
callRecordingUrl: string | null;
|
||||
location: string | null;
|
||||
meetingUrl: string | null;
|
||||
attendees: any[];
|
||||
reminderAt: Date | null;
|
||||
reminderSent: boolean;
|
||||
customFields: Record<string, any>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lead?: any;
|
||||
opportunity?: any;
|
||||
}
|
||||
|
||||
export class ActivityListQueryDto {
|
||||
@IsEnum(ActivityType)
|
||||
@IsOptional()
|
||||
type?: ActivityType;
|
||||
|
||||
@IsEnum(ActivityStatus)
|
||||
@IsOptional()
|
||||
status?: ActivityStatus;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
leadId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
opportunityId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedActivitiesDto {
|
||||
items: ActivityResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
57
src/modules/sales/dto/dashboard.dto.ts
Normal file
57
src/modules/sales/dto/dashboard.dto.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { OpportunityStage, LeadStatus, LeadSource } from '../entities';
|
||||
|
||||
export class SalesDashboardDto {
|
||||
totalLeads: number;
|
||||
totalOpportunities: number;
|
||||
totalPipelineValue: number;
|
||||
wonDeals: number;
|
||||
conversionRate: number;
|
||||
averageDealSize: number;
|
||||
activitiesThisWeek: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export class LeadsByStatusDto {
|
||||
status: LeadStatus;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class LeadsBySourceDto {
|
||||
source: LeadSource;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class OpportunitiesByStageDto {
|
||||
stage: OpportunityStage;
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export class ConversionFunnelDto {
|
||||
stage: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
conversionFromPrevious: number;
|
||||
}
|
||||
|
||||
export class SalesPerformanceDto {
|
||||
userId: string;
|
||||
userName: string;
|
||||
leadsAssigned: number;
|
||||
opportunitiesWon: number;
|
||||
totalRevenue: number;
|
||||
conversionRate: number;
|
||||
activitiesCompleted: number;
|
||||
}
|
||||
|
||||
export class TimelineActivityDto {
|
||||
date: string;
|
||||
leadsCreated: number;
|
||||
opportunitiesCreated: number;
|
||||
activitiesCompleted: number;
|
||||
dealsWon: number;
|
||||
dealsLost: number;
|
||||
}
|
||||
5
src/modules/sales/dto/index.ts
Normal file
5
src/modules/sales/dto/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './lead.dto';
|
||||
export * from './opportunity.dto';
|
||||
export * from './activity.dto';
|
||||
export * from './pipeline.dto';
|
||||
export * from './dashboard.dto';
|
||||
273
src/modules/sales/dto/lead.dto.ts
Normal file
273
src/modules/sales/dto/lead.dto.ts
Normal file
@ -0,0 +1,273 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
IsNumber,
|
||||
} from 'class-validator';
|
||||
import { LeadStatus, LeadSource } from '../entities';
|
||||
|
||||
export class CreateLeadDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
firstName: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lastName: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@IsOptional()
|
||||
company?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
@IsOptional()
|
||||
jobTitle?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
website?: string;
|
||||
|
||||
@IsEnum(LeadSource)
|
||||
@IsOptional()
|
||||
source?: LeadSource;
|
||||
|
||||
@IsEnum(LeadStatus)
|
||||
@IsOptional()
|
||||
status?: LeadStatus;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
score?: number;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
addressLine1?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
addressLine2?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
city?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
state?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsOptional()
|
||||
postalCode?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
country?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateLeadDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
firstName?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
lastName?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@IsOptional()
|
||||
phone?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@IsOptional()
|
||||
company?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
@IsOptional()
|
||||
jobTitle?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
website?: string;
|
||||
|
||||
@IsEnum(LeadSource)
|
||||
@IsOptional()
|
||||
source?: LeadSource;
|
||||
|
||||
@IsEnum(LeadStatus)
|
||||
@IsOptional()
|
||||
status?: LeadStatus;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
score?: number;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
addressLine1?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
addressLine2?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
city?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
state?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsOptional()
|
||||
postalCode?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
country?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class ConvertLeadDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
opportunityName?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
amount?: number;
|
||||
|
||||
@IsOptional()
|
||||
expectedCloseDate?: string;
|
||||
}
|
||||
|
||||
export class LeadResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
company: string | null;
|
||||
jobTitle: string | null;
|
||||
website: string | null;
|
||||
source: LeadSource;
|
||||
status: LeadStatus;
|
||||
score: number;
|
||||
assignedTo: string | null;
|
||||
notes: string | null;
|
||||
convertedAt: Date | null;
|
||||
convertedToOpportunityId: string | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
customFields: Record<string, any>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
export class LeadListQueryDto {
|
||||
@IsEnum(LeadStatus)
|
||||
@IsOptional()
|
||||
status?: LeadStatus;
|
||||
|
||||
@IsEnum(LeadSource)
|
||||
@IsOptional()
|
||||
source?: LeadSource;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedLeadsDto {
|
||||
items: LeadResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
241
src/modules/sales/dto/opportunity.dto.ts
Normal file
241
src/modules/sales/dto/opportunity.dto.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import {
|
||||
IsString,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
IsNumber,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { OpportunityStage } from '../entities';
|
||||
|
||||
export class CreateOpportunityDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
leadId?: string;
|
||||
|
||||
@IsEnum(OpportunityStage)
|
||||
@IsOptional()
|
||||
stage?: OpportunityStage;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
stageId?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
amount?: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
probability?: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
expectedCloseDate?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@IsOptional()
|
||||
contactName?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
contactEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@IsOptional()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@IsOptional()
|
||||
companyName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateOpportunityDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(OpportunityStage)
|
||||
@IsOptional()
|
||||
stage?: OpportunityStage;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
stageId?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
amount?: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(3)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
probability?: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
expectedCloseDate?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsOptional()
|
||||
lostReason?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@IsOptional()
|
||||
contactName?: string;
|
||||
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
contactEmail?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
@IsOptional()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@IsOptional()
|
||||
companyName?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
customFields?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class UpdateOpportunityStageDto {
|
||||
@IsEnum(OpportunityStage)
|
||||
stage: OpportunityStage;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class OpportunityResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
leadId: string | null;
|
||||
stage: OpportunityStage;
|
||||
stageId: string | null;
|
||||
amount: number;
|
||||
currency: string;
|
||||
probability: number;
|
||||
expectedCloseDate: Date | null;
|
||||
actualCloseDate: Date | null;
|
||||
assignedTo: string | null;
|
||||
wonAt: Date | null;
|
||||
lostAt: Date | null;
|
||||
lostReason: string | null;
|
||||
contactName: string | null;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
companyName: string | null;
|
||||
notes: string | null;
|
||||
customFields: Record<string, any>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
lead?: any;
|
||||
pipelineStage?: any;
|
||||
}
|
||||
|
||||
export class OpportunityListQueryDto {
|
||||
@IsEnum(OpportunityStage)
|
||||
@IsOptional()
|
||||
stage?: OpportunityStage;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
stageId?: string;
|
||||
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
assignedTo?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class PaginatedOpportunitiesDto {
|
||||
items: OpportunityResponseDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class PipelineSummaryDto {
|
||||
stage: OpportunityStage;
|
||||
count: number;
|
||||
totalAmount: number;
|
||||
avgProbability: number;
|
||||
}
|
||||
85
src/modules/sales/dto/pipeline.dto.ts
Normal file
85
src/modules/sales/dto/pipeline.dto.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
IsBoolean,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePipelineStageDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isWon?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isLost?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdatePipelineStageDto {
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(7)
|
||||
@IsOptional()
|
||||
color?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isWon?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isLost?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ReorderStagesDto {
|
||||
@IsString({ each: true })
|
||||
stageIds: string[];
|
||||
}
|
||||
|
||||
export class PipelineStageResponseDto {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
isWon: boolean;
|
||||
isLost: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
opportunityCount?: number;
|
||||
totalAmount?: number;
|
||||
}
|
||||
123
src/modules/sales/entities/activity.entity.ts
Normal file
123
src/modules/sales/entities/activity.entity.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { LeadEntity } from './lead.entity';
|
||||
import { OpportunityEntity } from './opportunity.entity';
|
||||
|
||||
export enum ActivityType {
|
||||
CALL = 'call',
|
||||
MEETING = 'meeting',
|
||||
TASK = 'task',
|
||||
EMAIL = 'email',
|
||||
NOTE = 'note',
|
||||
}
|
||||
|
||||
export enum ActivityStatus {
|
||||
PENDING = 'pending',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'sales', name: 'activities' })
|
||||
export class ActivityEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ActivityType,
|
||||
enumName: 'activity_type',
|
||||
})
|
||||
type: ActivityType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ActivityStatus,
|
||||
enumName: 'activity_status',
|
||||
default: ActivityStatus.PENDING,
|
||||
})
|
||||
status: ActivityStatus;
|
||||
|
||||
@Column({ length: 255 })
|
||||
subject: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ name: 'lead_id', type: 'uuid', nullable: true })
|
||||
leadId: string;
|
||||
|
||||
@Column({ name: 'opportunity_id', type: 'uuid', nullable: true })
|
||||
opportunityId: string;
|
||||
|
||||
@Column({ name: 'due_date', type: 'timestamptz', nullable: true })
|
||||
dueDate: Date;
|
||||
|
||||
@Column({ name: 'due_time', type: 'time', nullable: true })
|
||||
dueTime: string;
|
||||
|
||||
@Column({ name: 'duration_minutes', type: 'int', nullable: true })
|
||||
durationMinutes: number;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
outcome: string;
|
||||
|
||||
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
|
||||
assignedTo: string;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@Column({ name: 'call_direction', length: 10, nullable: true })
|
||||
callDirection: string;
|
||||
|
||||
@Column({ name: 'call_recording_url', length: 500, nullable: true })
|
||||
callRecordingUrl: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
location: string;
|
||||
|
||||
@Column({ name: 'meeting_url', length: 500, nullable: true })
|
||||
meetingUrl: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: [] })
|
||||
attendees: any[];
|
||||
|
||||
@Column({ name: 'reminder_at', type: 'timestamptz', nullable: true })
|
||||
reminderAt: Date;
|
||||
|
||||
@Column({ name: 'reminder_sent', default: false })
|
||||
reminderSent: boolean;
|
||||
|
||||
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
|
||||
customFields: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@ManyToOne(() => LeadEntity, (lead) => lead.activities)
|
||||
@JoinColumn({ name: 'lead_id' })
|
||||
lead: LeadEntity;
|
||||
|
||||
@ManyToOne(() => OpportunityEntity, (opportunity) => opportunity.activities)
|
||||
@JoinColumn({ name: 'opportunity_id' })
|
||||
opportunity: OpportunityEntity;
|
||||
}
|
||||
4
src/modules/sales/entities/index.ts
Normal file
4
src/modules/sales/entities/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './pipeline-stage.entity';
|
||||
export * from './lead.entity';
|
||||
export * from './opportunity.entity';
|
||||
export * from './activity.entity';
|
||||
128
src/modules/sales/entities/lead.entity.ts
Normal file
128
src/modules/sales/entities/lead.entity.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum LeadStatus {
|
||||
NEW = 'new',
|
||||
CONTACTED = 'contacted',
|
||||
QUALIFIED = 'qualified',
|
||||
UNQUALIFIED = 'unqualified',
|
||||
CONVERTED = 'converted',
|
||||
}
|
||||
|
||||
export enum LeadSource {
|
||||
WEBSITE = 'website',
|
||||
REFERRAL = 'referral',
|
||||
COLD_CALL = 'cold_call',
|
||||
EVENT = 'event',
|
||||
ADVERTISEMENT = 'advertisement',
|
||||
SOCIAL_MEDIA = 'social_media',
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'sales', name: 'leads' })
|
||||
export class LeadEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'first_name', length: 100 })
|
||||
firstName: string;
|
||||
|
||||
@Column({ name: 'last_name', length: 100 })
|
||||
lastName: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
email: string;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
phone: string;
|
||||
|
||||
@Column({ length: 200, nullable: true })
|
||||
company: string;
|
||||
|
||||
@Column({ name: 'job_title', length: 150, nullable: true })
|
||||
jobTitle: string;
|
||||
|
||||
@Column({ length: 255, nullable: true })
|
||||
website: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: LeadSource,
|
||||
enumName: 'lead_source',
|
||||
default: LeadSource.OTHER,
|
||||
})
|
||||
source: LeadSource;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: LeadStatus,
|
||||
enumName: 'lead_status',
|
||||
default: LeadStatus.NEW,
|
||||
})
|
||||
status: LeadStatus;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
score: number;
|
||||
|
||||
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
|
||||
assignedTo: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'converted_at', type: 'timestamptz', nullable: true })
|
||||
convertedAt: Date;
|
||||
|
||||
@Column({ name: 'converted_to_opportunity_id', type: 'uuid', nullable: true })
|
||||
convertedToOpportunityId: string;
|
||||
|
||||
@Column({ name: 'address_line1', length: 255, nullable: true })
|
||||
addressLine1: string;
|
||||
|
||||
@Column({ name: 'address_line2', length: 255, nullable: true })
|
||||
addressLine2: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
city: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
state: string;
|
||||
|
||||
@Column({ name: 'postal_code', length: 20, nullable: true })
|
||||
postalCode: string;
|
||||
|
||||
@Column({ length: 100, nullable: true })
|
||||
country: string;
|
||||
|
||||
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
|
||||
customFields: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@OneToMany('ActivityEntity', 'lead')
|
||||
activities: any[];
|
||||
|
||||
@OneToMany('OpportunityEntity', 'lead')
|
||||
opportunities: any[];
|
||||
}
|
||||
118
src/modules/sales/entities/opportunity.entity.ts
Normal file
118
src/modules/sales/entities/opportunity.entity.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { LeadEntity } from './lead.entity';
|
||||
import { PipelineStageEntity } from './pipeline-stage.entity';
|
||||
|
||||
export enum OpportunityStage {
|
||||
PROSPECTING = 'prospecting',
|
||||
QUALIFICATION = 'qualification',
|
||||
PROPOSAL = 'proposal',
|
||||
NEGOTIATION = 'negotiation',
|
||||
CLOSED_WON = 'closed_won',
|
||||
CLOSED_LOST = 'closed_lost',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'sales', name: 'opportunities' })
|
||||
export class OpportunityEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ name: 'lead_id', type: 'uuid', nullable: true })
|
||||
leadId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: OpportunityStage,
|
||||
enumName: 'opportunity_stage',
|
||||
default: OpportunityStage.PROSPECTING,
|
||||
})
|
||||
stage: OpportunityStage;
|
||||
|
||||
@Column({ name: 'stage_id', type: 'uuid', nullable: true })
|
||||
stageId: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
amount: number;
|
||||
|
||||
@Column({ length: 3, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
probability: number;
|
||||
|
||||
@Column({ name: 'expected_close_date', type: 'date', nullable: true })
|
||||
expectedCloseDate: Date;
|
||||
|
||||
@Column({ name: 'actual_close_date', type: 'date', nullable: true })
|
||||
actualCloseDate: Date;
|
||||
|
||||
@Column({ name: 'assigned_to', type: 'uuid', nullable: true })
|
||||
assignedTo: string;
|
||||
|
||||
@Column({ name: 'won_at', type: 'timestamptz', nullable: true })
|
||||
wonAt: Date;
|
||||
|
||||
@Column({ name: 'lost_at', type: 'timestamptz', nullable: true })
|
||||
lostAt: Date;
|
||||
|
||||
@Column({ name: 'lost_reason', length: 500, nullable: true })
|
||||
lostReason: string;
|
||||
|
||||
@Column({ name: 'contact_name', length: 200, nullable: true })
|
||||
contactName: string;
|
||||
|
||||
@Column({ name: 'contact_email', length: 255, nullable: true })
|
||||
contactEmail: string;
|
||||
|
||||
@Column({ name: 'contact_phone', length: 50, nullable: true })
|
||||
contactPhone: string;
|
||||
|
||||
@Column({ name: 'company_name', length: 200, nullable: true })
|
||||
companyName: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'custom_fields', type: 'jsonb', default: {} })
|
||||
customFields: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@Column({ name: 'deleted_at', type: 'timestamptz', nullable: true })
|
||||
deletedAt: Date;
|
||||
|
||||
@ManyToOne(() => LeadEntity, (lead) => lead.opportunities)
|
||||
@JoinColumn({ name: 'lead_id' })
|
||||
lead: LeadEntity;
|
||||
|
||||
@ManyToOne(() => PipelineStageEntity, (stage) => stage.opportunities)
|
||||
@JoinColumn({ name: 'stage_id' })
|
||||
pipelineStage: PipelineStageEntity;
|
||||
|
||||
@OneToMany('ActivityEntity', 'opportunity')
|
||||
activities: any[];
|
||||
}
|
||||
46
src/modules/sales/entities/pipeline-stage.entity.ts
Normal file
46
src/modules/sales/entities/pipeline-stage.entity.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'sales', name: 'pipeline_stages' })
|
||||
export class PipelineStageEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
position: number;
|
||||
|
||||
@Column({ length: 7, default: '#3B82F6' })
|
||||
color: string;
|
||||
|
||||
@Column({ name: 'is_won', default: false })
|
||||
isWon: boolean;
|
||||
|
||||
@Column({ name: 'is_lost', default: false })
|
||||
isLost: boolean;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@OneToMany('OpportunityEntity', 'pipelineStage')
|
||||
opportunities: any[];
|
||||
}
|
||||
5
src/modules/sales/index.ts
Normal file
5
src/modules/sales/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './sales.module';
|
||||
export * from './entities';
|
||||
export * from './dto';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
58
src/modules/sales/sales.module.ts
Normal file
58
src/modules/sales/sales.module.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
PipelineStageEntity,
|
||||
LeadEntity,
|
||||
OpportunityEntity,
|
||||
ActivityEntity,
|
||||
} from './entities';
|
||||
|
||||
import {
|
||||
LeadsService,
|
||||
OpportunitiesService,
|
||||
ActivitiesService,
|
||||
PipelineService,
|
||||
SalesDashboardService,
|
||||
} from './services';
|
||||
|
||||
import {
|
||||
LeadsController,
|
||||
OpportunitiesController,
|
||||
ActivitiesController,
|
||||
PipelineController,
|
||||
SalesDashboardController,
|
||||
} from './controllers';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
PipelineStageEntity,
|
||||
LeadEntity,
|
||||
OpportunityEntity,
|
||||
ActivityEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
LeadsController,
|
||||
OpportunitiesController,
|
||||
ActivitiesController,
|
||||
PipelineController,
|
||||
SalesDashboardController,
|
||||
],
|
||||
providers: [
|
||||
LeadsService,
|
||||
OpportunitiesService,
|
||||
ActivitiesService,
|
||||
PipelineService,
|
||||
SalesDashboardService,
|
||||
],
|
||||
exports: [
|
||||
LeadsService,
|
||||
OpportunitiesService,
|
||||
ActivitiesService,
|
||||
PipelineService,
|
||||
SalesDashboardService,
|
||||
],
|
||||
})
|
||||
export class SalesModule {}
|
||||
243
src/modules/sales/services/activities.service.ts
Normal file
243
src/modules/sales/services/activities.service.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ActivityEntity, ActivityStatus } from '../entities';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
UpdateActivityDto,
|
||||
CompleteActivityDto,
|
||||
ActivityResponseDto,
|
||||
ActivityListQueryDto,
|
||||
PaginatedActivitiesDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class ActivitiesService {
|
||||
private readonly logger = new Logger(ActivitiesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ActivityEntity)
|
||||
private readonly activityRepo: Repository<ActivityEntity>,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateActivityDto): Promise<ActivityResponseDto> {
|
||||
if (!dto.leadId && !dto.opportunityId) {
|
||||
throw new BadRequestException('Either leadId or opportunityId must be provided');
|
||||
}
|
||||
|
||||
const activity = this.activityRepo.create({
|
||||
tenantId,
|
||||
type: dto.type,
|
||||
subject: dto.subject,
|
||||
description: dto.description,
|
||||
leadId: dto.leadId,
|
||||
opportunityId: dto.opportunityId,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||
dueTime: dto.dueTime,
|
||||
durationMinutes: dto.durationMinutes,
|
||||
assignedTo: dto.assignedTo,
|
||||
callDirection: dto.callDirection,
|
||||
callRecordingUrl: dto.callRecordingUrl,
|
||||
location: dto.location,
|
||||
meetingUrl: dto.meetingUrl,
|
||||
attendees: dto.attendees ?? [],
|
||||
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : undefined,
|
||||
customFields: dto.customFields ?? {},
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const saved = await this.activityRepo.save(activity);
|
||||
this.logger.log(`Activity created: ${saved.id} for tenant ${tenantId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: ActivityListQueryDto): Promise<PaginatedActivitiesDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.activityRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.lead', 'lead')
|
||||
.leftJoinAndSelect('a.opportunity', 'opportunity')
|
||||
.where('a.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('a.deleted_at IS NULL');
|
||||
|
||||
if (query.type) {
|
||||
qb.andWhere('a.type = :type', { type: query.type });
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('a.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
if (query.leadId) {
|
||||
qb.andWhere('a.lead_id = :leadId', { leadId: query.leadId });
|
||||
}
|
||||
|
||||
if (query.opportunityId) {
|
||||
qb.andWhere('a.opportunity_id = :opportunityId', { opportunityId: query.opportunityId });
|
||||
}
|
||||
|
||||
if (query.assignedTo) {
|
||||
qb.andWhere('a.assigned_to = :assignedTo', { assignedTo: query.assignedTo });
|
||||
}
|
||||
|
||||
qb.orderBy('a.due_date', 'ASC').addOrderBy('a.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, activityId: string): Promise<ActivityResponseDto> {
|
||||
const activity = await this.activityRepo.findOne({
|
||||
where: { id: activityId, tenantId, deletedAt: null as any },
|
||||
relations: ['lead', 'opportunity'],
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
throw new NotFoundException('Activity not found');
|
||||
}
|
||||
|
||||
return this.toResponse(activity);
|
||||
}
|
||||
|
||||
async update(tenantId: string, activityId: string, dto: UpdateActivityDto): Promise<ActivityResponseDto> {
|
||||
const activity = await this.activityRepo.findOne({
|
||||
where: { id: activityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
throw new NotFoundException('Activity not found');
|
||||
}
|
||||
|
||||
Object.assign(activity, {
|
||||
type: dto.type ?? activity.type,
|
||||
status: dto.status ?? activity.status,
|
||||
subject: dto.subject ?? activity.subject,
|
||||
description: dto.description ?? activity.description,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : activity.dueDate,
|
||||
dueTime: dto.dueTime ?? activity.dueTime,
|
||||
durationMinutes: dto.durationMinutes ?? activity.durationMinutes,
|
||||
outcome: dto.outcome ?? activity.outcome,
|
||||
assignedTo: dto.assignedTo ?? activity.assignedTo,
|
||||
callDirection: dto.callDirection ?? activity.callDirection,
|
||||
callRecordingUrl: dto.callRecordingUrl ?? activity.callRecordingUrl,
|
||||
location: dto.location ?? activity.location,
|
||||
meetingUrl: dto.meetingUrl ?? activity.meetingUrl,
|
||||
attendees: dto.attendees ?? activity.attendees,
|
||||
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : activity.reminderAt,
|
||||
reminderSent: dto.reminderSent ?? activity.reminderSent,
|
||||
customFields: dto.customFields ?? activity.customFields,
|
||||
});
|
||||
|
||||
const saved = await this.activityRepo.save(activity);
|
||||
this.logger.log(`Activity updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async complete(
|
||||
tenantId: string,
|
||||
activityId: string,
|
||||
dto: CompleteActivityDto,
|
||||
): Promise<ActivityResponseDto> {
|
||||
const activity = await this.activityRepo.findOne({
|
||||
where: { id: activityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
throw new NotFoundException('Activity not found');
|
||||
}
|
||||
|
||||
if (activity.status === ActivityStatus.COMPLETED) {
|
||||
throw new BadRequestException('Activity is already completed');
|
||||
}
|
||||
|
||||
activity.status = ActivityStatus.COMPLETED;
|
||||
activity.completedAt = new Date();
|
||||
activity.outcome = dto.outcome ?? activity.outcome;
|
||||
|
||||
const saved = await this.activityRepo.save(activity);
|
||||
this.logger.log(`Activity completed: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, activityId: string): Promise<void> {
|
||||
const activity = await this.activityRepo.findOne({
|
||||
where: { id: activityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
throw new NotFoundException('Activity not found');
|
||||
}
|
||||
|
||||
activity.deletedAt = new Date();
|
||||
await this.activityRepo.save(activity);
|
||||
this.logger.log(`Activity soft-deleted: ${activityId}`);
|
||||
}
|
||||
|
||||
async getUpcoming(tenantId: string, userId?: string, days: number = 7): Promise<ActivityResponseDto[]> {
|
||||
const qb = this.activityRepo
|
||||
.createQueryBuilder('a')
|
||||
.leftJoinAndSelect('a.lead', 'lead')
|
||||
.leftJoinAndSelect('a.opportunity', 'opportunity')
|
||||
.where('a.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('a.deleted_at IS NULL')
|
||||
.andWhere('a.status = :status', { status: ActivityStatus.PENDING })
|
||||
.andWhere('a.due_date <= :endDate', {
|
||||
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
if (userId) {
|
||||
qb.andWhere('a.assigned_to = :userId', { userId });
|
||||
}
|
||||
|
||||
qb.orderBy('a.due_date', 'ASC').take(50);
|
||||
|
||||
const activities = await qb.getMany();
|
||||
return activities.map((a) => this.toResponse(a));
|
||||
}
|
||||
|
||||
private toResponse(activity: ActivityEntity): ActivityResponseDto {
|
||||
return {
|
||||
id: activity.id,
|
||||
tenantId: activity.tenantId,
|
||||
type: activity.type,
|
||||
status: activity.status,
|
||||
subject: activity.subject,
|
||||
description: activity.description,
|
||||
leadId: activity.leadId,
|
||||
opportunityId: activity.opportunityId,
|
||||
dueDate: activity.dueDate,
|
||||
dueTime: activity.dueTime,
|
||||
durationMinutes: activity.durationMinutes,
|
||||
completedAt: activity.completedAt,
|
||||
outcome: activity.outcome,
|
||||
assignedTo: activity.assignedTo,
|
||||
createdBy: activity.createdBy,
|
||||
callDirection: activity.callDirection,
|
||||
callRecordingUrl: activity.callRecordingUrl,
|
||||
location: activity.location,
|
||||
meetingUrl: activity.meetingUrl,
|
||||
attendees: activity.attendees,
|
||||
reminderAt: activity.reminderAt,
|
||||
reminderSent: activity.reminderSent,
|
||||
customFields: activity.customFields,
|
||||
createdAt: activity.createdAt,
|
||||
updatedAt: activity.updatedAt,
|
||||
lead: activity.lead,
|
||||
opportunity: activity.opportunity,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
src/modules/sales/services/index.ts
Normal file
5
src/modules/sales/services/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './leads.service';
|
||||
export * from './opportunities.service';
|
||||
export * from './activities.service';
|
||||
export * from './pipeline.service';
|
||||
export * from './sales-dashboard.service';
|
||||
265
src/modules/sales/services/leads.service.ts
Normal file
265
src/modules/sales/services/leads.service.ts
Normal file
@ -0,0 +1,265 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { LeadEntity, LeadStatus } from '../entities';
|
||||
import {
|
||||
CreateLeadDto,
|
||||
UpdateLeadDto,
|
||||
ConvertLeadDto,
|
||||
LeadResponseDto,
|
||||
LeadListQueryDto,
|
||||
PaginatedLeadsDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class LeadsService {
|
||||
private readonly logger = new Logger(LeadsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LeadEntity)
|
||||
private readonly leadRepo: Repository<LeadEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateLeadDto): Promise<LeadResponseDto> {
|
||||
const lead = this.leadRepo.create({
|
||||
tenantId,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
email: dto.email,
|
||||
phone: dto.phone,
|
||||
company: dto.company,
|
||||
jobTitle: dto.jobTitle,
|
||||
website: dto.website,
|
||||
source: dto.source,
|
||||
status: dto.status,
|
||||
score: dto.score ?? 0,
|
||||
assignedTo: dto.assignedTo,
|
||||
notes: dto.notes,
|
||||
addressLine1: dto.addressLine1,
|
||||
addressLine2: dto.addressLine2,
|
||||
city: dto.city,
|
||||
state: dto.state,
|
||||
postalCode: dto.postalCode,
|
||||
country: dto.country,
|
||||
customFields: dto.customFields ?? {},
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const saved = await this.leadRepo.save(lead);
|
||||
this.logger.log(`Lead created: ${saved.id} for tenant ${tenantId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: LeadListQueryDto): Promise<PaginatedLeadsDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.leadRepo
|
||||
.createQueryBuilder('l')
|
||||
.where('l.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('l.deleted_at IS NULL');
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('l.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
if (query.source) {
|
||||
qb.andWhere('l.source = :source', { source: query.source });
|
||||
}
|
||||
|
||||
if (query.assignedTo) {
|
||||
qb.andWhere('l.assigned_to = :assignedTo', { assignedTo: query.assignedTo });
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(l.first_name ILIKE :search OR l.last_name ILIKE :search OR l.email ILIKE :search OR l.company ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
qb.orderBy('l.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((l) => this.toResponse(l)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, leadId: string): Promise<LeadResponseDto> {
|
||||
const lead = await this.leadRepo.findOne({
|
||||
where: { id: leadId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead not found');
|
||||
}
|
||||
|
||||
return this.toResponse(lead);
|
||||
}
|
||||
|
||||
async update(tenantId: string, leadId: string, dto: UpdateLeadDto): Promise<LeadResponseDto> {
|
||||
const lead = await this.leadRepo.findOne({
|
||||
where: { id: leadId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead not found');
|
||||
}
|
||||
|
||||
Object.assign(lead, {
|
||||
firstName: dto.firstName ?? lead.firstName,
|
||||
lastName: dto.lastName ?? lead.lastName,
|
||||
email: dto.email ?? lead.email,
|
||||
phone: dto.phone ?? lead.phone,
|
||||
company: dto.company ?? lead.company,
|
||||
jobTitle: dto.jobTitle ?? lead.jobTitle,
|
||||
website: dto.website ?? lead.website,
|
||||
source: dto.source ?? lead.source,
|
||||
status: dto.status ?? lead.status,
|
||||
score: dto.score ?? lead.score,
|
||||
assignedTo: dto.assignedTo ?? lead.assignedTo,
|
||||
notes: dto.notes ?? lead.notes,
|
||||
addressLine1: dto.addressLine1 ?? lead.addressLine1,
|
||||
addressLine2: dto.addressLine2 ?? lead.addressLine2,
|
||||
city: dto.city ?? lead.city,
|
||||
state: dto.state ?? lead.state,
|
||||
postalCode: dto.postalCode ?? lead.postalCode,
|
||||
country: dto.country ?? lead.country,
|
||||
customFields: dto.customFields ?? lead.customFields,
|
||||
});
|
||||
|
||||
const saved = await this.leadRepo.save(lead);
|
||||
this.logger.log(`Lead updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, leadId: string): Promise<void> {
|
||||
const lead = await this.leadRepo.findOne({
|
||||
where: { id: leadId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead not found');
|
||||
}
|
||||
|
||||
lead.deletedAt = new Date();
|
||||
await this.leadRepo.save(lead);
|
||||
this.logger.log(`Lead soft-deleted: ${leadId}`);
|
||||
}
|
||||
|
||||
async convert(
|
||||
tenantId: string,
|
||||
leadId: string,
|
||||
dto: ConvertLeadDto,
|
||||
): Promise<{ opportunityId: string }> {
|
||||
const lead = await this.leadRepo.findOne({
|
||||
where: { id: leadId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead not found');
|
||||
}
|
||||
|
||||
if (lead.status === LeadStatus.CONVERTED) {
|
||||
throw new BadRequestException('Lead is already converted');
|
||||
}
|
||||
|
||||
// Call the database function to convert lead to opportunity
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT sales.convert_lead_to_opportunity($1, $2, $3, $4) as opportunity_id`,
|
||||
[
|
||||
leadId,
|
||||
dto.opportunityName || null,
|
||||
dto.amount || 0,
|
||||
dto.expectedCloseDate || null,
|
||||
],
|
||||
);
|
||||
|
||||
const opportunityId = result[0]?.opportunity_id;
|
||||
this.logger.log(`Lead ${leadId} converted to opportunity ${opportunityId}`);
|
||||
|
||||
return { opportunityId };
|
||||
}
|
||||
|
||||
async calculateScore(tenantId: string, leadId: string): Promise<{ score: number }> {
|
||||
const lead = await this.leadRepo.findOne({
|
||||
where: { id: leadId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead not found');
|
||||
}
|
||||
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT sales.calculate_lead_score($1) as score`,
|
||||
[leadId],
|
||||
);
|
||||
|
||||
const score = result[0]?.score ?? 0;
|
||||
|
||||
// Update lead score
|
||||
lead.score = score;
|
||||
await this.leadRepo.save(lead);
|
||||
|
||||
return { score };
|
||||
}
|
||||
|
||||
async assignTo(tenantId: string, leadId: string, userId: string): Promise<LeadResponseDto> {
|
||||
const lead = await this.leadRepo.findOne({
|
||||
where: { id: leadId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!lead) {
|
||||
throw new NotFoundException('Lead not found');
|
||||
}
|
||||
|
||||
lead.assignedTo = userId;
|
||||
const saved = await this.leadRepo.save(lead);
|
||||
|
||||
this.logger.log(`Lead ${leadId} assigned to user ${userId}`);
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
private toResponse(lead: LeadEntity): LeadResponseDto {
|
||||
return {
|
||||
id: lead.id,
|
||||
tenantId: lead.tenantId,
|
||||
firstName: lead.firstName,
|
||||
lastName: lead.lastName,
|
||||
fullName: `${lead.firstName} ${lead.lastName}`,
|
||||
email: lead.email,
|
||||
phone: lead.phone,
|
||||
company: lead.company,
|
||||
jobTitle: lead.jobTitle,
|
||||
website: lead.website,
|
||||
source: lead.source,
|
||||
status: lead.status,
|
||||
score: lead.score,
|
||||
assignedTo: lead.assignedTo,
|
||||
notes: lead.notes,
|
||||
convertedAt: lead.convertedAt,
|
||||
convertedToOpportunityId: lead.convertedToOpportunityId,
|
||||
addressLine1: lead.addressLine1,
|
||||
addressLine2: lead.addressLine2,
|
||||
city: lead.city,
|
||||
state: lead.state,
|
||||
postalCode: lead.postalCode,
|
||||
country: lead.country,
|
||||
customFields: lead.customFields,
|
||||
createdAt: lead.createdAt,
|
||||
updatedAt: lead.updatedAt,
|
||||
createdBy: lead.createdBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
255
src/modules/sales/services/opportunities.service.ts
Normal file
255
src/modules/sales/services/opportunities.service.ts
Normal file
@ -0,0 +1,255 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { OpportunityEntity, OpportunityStage } from '../entities';
|
||||
import {
|
||||
CreateOpportunityDto,
|
||||
UpdateOpportunityDto,
|
||||
UpdateOpportunityStageDto,
|
||||
OpportunityResponseDto,
|
||||
OpportunityListQueryDto,
|
||||
PaginatedOpportunitiesDto,
|
||||
PipelineSummaryDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class OpportunitiesService {
|
||||
private readonly logger = new Logger(OpportunitiesService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(OpportunityEntity)
|
||||
private readonly opportunityRepo: Repository<OpportunityEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(tenantId: string, userId: string, dto: CreateOpportunityDto): Promise<OpportunityResponseDto> {
|
||||
const opportunity = this.opportunityRepo.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
leadId: dto.leadId,
|
||||
stage: dto.stage ?? OpportunityStage.PROSPECTING,
|
||||
stageId: dto.stageId,
|
||||
amount: dto.amount ?? 0,
|
||||
currency: dto.currency ?? 'USD',
|
||||
probability: dto.probability ?? 0,
|
||||
expectedCloseDate: dto.expectedCloseDate ? new Date(dto.expectedCloseDate) : undefined,
|
||||
assignedTo: dto.assignedTo,
|
||||
contactName: dto.contactName,
|
||||
contactEmail: dto.contactEmail,
|
||||
contactPhone: dto.contactPhone,
|
||||
companyName: dto.companyName,
|
||||
notes: dto.notes,
|
||||
customFields: dto.customFields ?? {},
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
const saved = await this.opportunityRepo.save(opportunity);
|
||||
this.logger.log(`Opportunity created: ${saved.id} for tenant ${tenantId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string, query: OpportunityListQueryDto): Promise<PaginatedOpportunitiesDto> {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const qb = this.opportunityRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.lead', 'lead')
|
||||
.leftJoinAndSelect('o.pipelineStage', 'pipelineStage')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.deleted_at IS NULL');
|
||||
|
||||
if (query.stage) {
|
||||
qb.andWhere('o.stage = :stage', { stage: query.stage });
|
||||
}
|
||||
|
||||
if (query.stageId) {
|
||||
qb.andWhere('o.stage_id = :stageId', { stageId: query.stageId });
|
||||
}
|
||||
|
||||
if (query.assignedTo) {
|
||||
qb.andWhere('o.assigned_to = :assignedTo', { assignedTo: query.assignedTo });
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(o.name ILIKE :search OR o.company_name ILIKE :search OR o.contact_name ILIKE :search)',
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
qb.orderBy('o.created_at', 'DESC').skip(skip).take(limit);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((o) => this.toResponse(o)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, opportunityId: string): Promise<OpportunityResponseDto> {
|
||||
const opportunity = await this.opportunityRepo.findOne({
|
||||
where: { id: opportunityId, tenantId, deletedAt: null as any },
|
||||
relations: ['lead', 'pipelineStage'],
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new NotFoundException('Opportunity not found');
|
||||
}
|
||||
|
||||
return this.toResponse(opportunity);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
opportunityId: string,
|
||||
dto: UpdateOpportunityDto,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
const opportunity = await this.opportunityRepo.findOne({
|
||||
where: { id: opportunityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new NotFoundException('Opportunity not found');
|
||||
}
|
||||
|
||||
Object.assign(opportunity, {
|
||||
name: dto.name ?? opportunity.name,
|
||||
description: dto.description ?? opportunity.description,
|
||||
stage: dto.stage ?? opportunity.stage,
|
||||
stageId: dto.stageId ?? opportunity.stageId,
|
||||
amount: dto.amount ?? opportunity.amount,
|
||||
currency: dto.currency ?? opportunity.currency,
|
||||
probability: dto.probability ?? opportunity.probability,
|
||||
expectedCloseDate: dto.expectedCloseDate
|
||||
? new Date(dto.expectedCloseDate)
|
||||
: opportunity.expectedCloseDate,
|
||||
assignedTo: dto.assignedTo ?? opportunity.assignedTo,
|
||||
lostReason: dto.lostReason ?? opportunity.lostReason,
|
||||
contactName: dto.contactName ?? opportunity.contactName,
|
||||
contactEmail: dto.contactEmail ?? opportunity.contactEmail,
|
||||
contactPhone: dto.contactPhone ?? opportunity.contactPhone,
|
||||
companyName: dto.companyName ?? opportunity.companyName,
|
||||
notes: dto.notes ?? opportunity.notes,
|
||||
customFields: dto.customFields ?? opportunity.customFields,
|
||||
});
|
||||
|
||||
const saved = await this.opportunityRepo.save(opportunity);
|
||||
this.logger.log(`Opportunity updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async updateStage(
|
||||
tenantId: string,
|
||||
opportunityId: string,
|
||||
dto: UpdateOpportunityStageDto,
|
||||
): Promise<OpportunityResponseDto> {
|
||||
const opportunity = await this.opportunityRepo.findOne({
|
||||
where: { id: opportunityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new NotFoundException('Opportunity not found');
|
||||
}
|
||||
|
||||
// Use database function for stage update with tracking
|
||||
await this.dataSource.query(
|
||||
`SELECT sales.update_opportunity_stage($1, $2, $3)`,
|
||||
[opportunityId, dto.stage, dto.notes || null],
|
||||
);
|
||||
|
||||
// Reload the opportunity
|
||||
const updated = await this.opportunityRepo.findOne({
|
||||
where: { id: opportunityId },
|
||||
relations: ['lead', 'pipelineStage'],
|
||||
});
|
||||
|
||||
this.logger.log(`Opportunity ${opportunityId} stage updated to ${dto.stage}`);
|
||||
return this.toResponse(updated!);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, opportunityId: string): Promise<void> {
|
||||
const opportunity = await this.opportunityRepo.findOne({
|
||||
where: { id: opportunityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new NotFoundException('Opportunity not found');
|
||||
}
|
||||
|
||||
opportunity.deletedAt = new Date();
|
||||
await this.opportunityRepo.save(opportunity);
|
||||
this.logger.log(`Opportunity soft-deleted: ${opportunityId}`);
|
||||
}
|
||||
|
||||
async getPipelineSummary(tenantId: string): Promise<PipelineSummaryDto[]> {
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT * FROM sales.get_pipeline_summary($1)`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.map((row: any) => ({
|
||||
stage: row.stage,
|
||||
count: Number(row.count),
|
||||
totalAmount: Number(row.total_amount),
|
||||
avgProbability: Number(row.avg_probability),
|
||||
}));
|
||||
}
|
||||
|
||||
async assignTo(tenantId: string, opportunityId: string, userId: string): Promise<OpportunityResponseDto> {
|
||||
const opportunity = await this.opportunityRepo.findOne({
|
||||
where: { id: opportunityId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new NotFoundException('Opportunity not found');
|
||||
}
|
||||
|
||||
opportunity.assignedTo = userId;
|
||||
const saved = await this.opportunityRepo.save(opportunity);
|
||||
|
||||
this.logger.log(`Opportunity ${opportunityId} assigned to user ${userId}`);
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
private toResponse(opportunity: OpportunityEntity): OpportunityResponseDto {
|
||||
return {
|
||||
id: opportunity.id,
|
||||
tenantId: opportunity.tenantId,
|
||||
name: opportunity.name,
|
||||
description: opportunity.description,
|
||||
leadId: opportunity.leadId,
|
||||
stage: opportunity.stage,
|
||||
stageId: opportunity.stageId,
|
||||
amount: Number(opportunity.amount),
|
||||
currency: opportunity.currency,
|
||||
probability: opportunity.probability,
|
||||
expectedCloseDate: opportunity.expectedCloseDate,
|
||||
actualCloseDate: opportunity.actualCloseDate,
|
||||
assignedTo: opportunity.assignedTo,
|
||||
wonAt: opportunity.wonAt,
|
||||
lostAt: opportunity.lostAt,
|
||||
lostReason: opportunity.lostReason,
|
||||
contactName: opportunity.contactName,
|
||||
contactEmail: opportunity.contactEmail,
|
||||
contactPhone: opportunity.contactPhone,
|
||||
companyName: opportunity.companyName,
|
||||
notes: opportunity.notes,
|
||||
customFields: opportunity.customFields,
|
||||
createdAt: opportunity.createdAt,
|
||||
updatedAt: opportunity.updatedAt,
|
||||
createdBy: opportunity.createdBy,
|
||||
lead: opportunity.lead,
|
||||
pipelineStage: opportunity.pipelineStage,
|
||||
};
|
||||
}
|
||||
}
|
||||
186
src/modules/sales/services/pipeline.service.ts
Normal file
186
src/modules/sales/services/pipeline.service.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { PipelineStageEntity, OpportunityEntity } from '../entities';
|
||||
import {
|
||||
CreatePipelineStageDto,
|
||||
UpdatePipelineStageDto,
|
||||
ReorderStagesDto,
|
||||
PipelineStageResponseDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class PipelineService {
|
||||
private readonly logger = new Logger(PipelineService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PipelineStageEntity)
|
||||
private readonly stageRepo: Repository<PipelineStageEntity>,
|
||||
@InjectRepository(OpportunityEntity)
|
||||
private readonly opportunityRepo: Repository<OpportunityEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async initializeDefaults(tenantId: string): Promise<void> {
|
||||
await this.dataSource.query(`SELECT sales.initialize_default_stages($1)`, [tenantId]);
|
||||
this.logger.log(`Default pipeline stages initialized for tenant ${tenantId}`);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string): Promise<PipelineStageResponseDto[]> {
|
||||
const stages = await this.stageRepo.find({
|
||||
where: { tenantId },
|
||||
order: { position: 'ASC' },
|
||||
});
|
||||
|
||||
// Get opportunity counts and totals for each stage
|
||||
const stats = await this.opportunityRepo
|
||||
.createQueryBuilder('o')
|
||||
.select('o.stage_id', 'stageId')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.addSelect('COALESCE(SUM(o.amount), 0)', 'total')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.groupBy('o.stage_id')
|
||||
.getRawMany();
|
||||
|
||||
const statsMap = new Map(stats.map((s) => [s.stageId, s]));
|
||||
|
||||
return stages.map((stage) => {
|
||||
const stageStats = statsMap.get(stage.id) || { count: 0, total: 0 };
|
||||
return {
|
||||
...this.toResponse(stage),
|
||||
opportunityCount: Number(stageStats.count),
|
||||
totalAmount: Number(stageStats.total),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, stageId: string): Promise<PipelineStageResponseDto> {
|
||||
const stage = await this.stageRepo.findOne({
|
||||
where: { id: stageId, tenantId },
|
||||
});
|
||||
|
||||
if (!stage) {
|
||||
throw new NotFoundException('Pipeline stage not found');
|
||||
}
|
||||
|
||||
return this.toResponse(stage);
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreatePipelineStageDto): Promise<PipelineStageResponseDto> {
|
||||
// Get max position
|
||||
const maxPosition = await this.stageRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('MAX(s.position)', 'max')
|
||||
.where('s.tenant_id = :tenantId', { tenantId })
|
||||
.getRawOne();
|
||||
|
||||
const position = dto.position ?? (maxPosition?.max ?? 0) + 1;
|
||||
|
||||
const stage = this.stageRepo.create({
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
position,
|
||||
color: dto.color ?? '#3B82F6',
|
||||
isWon: dto.isWon ?? false,
|
||||
isLost: dto.isLost ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
|
||||
const saved = await this.stageRepo.save(stage);
|
||||
this.logger.log(`Pipeline stage created: ${saved.id} for tenant ${tenantId}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
stageId: string,
|
||||
dto: UpdatePipelineStageDto,
|
||||
): Promise<PipelineStageResponseDto> {
|
||||
const stage = await this.stageRepo.findOne({
|
||||
where: { id: stageId, tenantId },
|
||||
});
|
||||
|
||||
if (!stage) {
|
||||
throw new NotFoundException('Pipeline stage not found');
|
||||
}
|
||||
|
||||
Object.assign(stage, {
|
||||
name: dto.name ?? stage.name,
|
||||
position: dto.position ?? stage.position,
|
||||
color: dto.color ?? stage.color,
|
||||
isWon: dto.isWon ?? stage.isWon,
|
||||
isLost: dto.isLost ?? stage.isLost,
|
||||
isActive: dto.isActive ?? stage.isActive,
|
||||
});
|
||||
|
||||
const saved = await this.stageRepo.save(stage);
|
||||
this.logger.log(`Pipeline stage updated: ${saved.id}`);
|
||||
|
||||
return this.toResponse(saved);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, stageId: string): Promise<void> {
|
||||
const stage = await this.stageRepo.findOne({
|
||||
where: { id: stageId, tenantId },
|
||||
});
|
||||
|
||||
if (!stage) {
|
||||
throw new NotFoundException('Pipeline stage not found');
|
||||
}
|
||||
|
||||
// Check if there are opportunities in this stage
|
||||
const opportunityCount = await this.opportunityRepo.count({
|
||||
where: { stageId, tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
if (opportunityCount > 0) {
|
||||
throw new NotFoundException(
|
||||
`Cannot delete stage with ${opportunityCount} opportunities. Move them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.stageRepo.remove(stage);
|
||||
this.logger.log(`Pipeline stage deleted: ${stageId}`);
|
||||
}
|
||||
|
||||
async reorder(tenantId: string, dto: ReorderStagesDto): Promise<PipelineStageResponseDto[]> {
|
||||
const stages = await this.stageRepo.find({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
const stageMap = new Map(stages.map((s) => [s.id, s]));
|
||||
|
||||
// Update positions based on the order in dto.stageIds
|
||||
const updates = dto.stageIds.map((id, index) => {
|
||||
const stage = stageMap.get(id);
|
||||
if (stage) {
|
||||
stage.position = index + 1;
|
||||
return stage;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
await this.stageRepo.save(updates as PipelineStageEntity[]);
|
||||
this.logger.log(`Pipeline stages reordered for tenant ${tenantId}`);
|
||||
|
||||
return this.findAll(tenantId);
|
||||
}
|
||||
|
||||
private toResponse(stage: PipelineStageEntity): PipelineStageResponseDto {
|
||||
return {
|
||||
id: stage.id,
|
||||
tenantId: stage.tenantId,
|
||||
name: stage.name,
|
||||
position: stage.position,
|
||||
color: stage.color,
|
||||
isWon: stage.isWon,
|
||||
isLost: stage.isLost,
|
||||
isActive: stage.isActive,
|
||||
createdAt: stage.createdAt,
|
||||
updatedAt: stage.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
227
src/modules/sales/services/sales-dashboard.service.ts
Normal file
227
src/modules/sales/services/sales-dashboard.service.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { LeadEntity, OpportunityEntity, ActivityEntity, OpportunityStage, LeadStatus, ActivityStatus } from '../entities';
|
||||
import {
|
||||
SalesDashboardDto,
|
||||
LeadsByStatusDto,
|
||||
LeadsBySourceDto,
|
||||
OpportunitiesByStageDto,
|
||||
ConversionFunnelDto,
|
||||
SalesPerformanceDto,
|
||||
} from '../dto';
|
||||
|
||||
@Injectable()
|
||||
export class SalesDashboardService {
|
||||
private readonly logger = new Logger(SalesDashboardService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LeadEntity)
|
||||
private readonly leadRepo: Repository<LeadEntity>,
|
||||
@InjectRepository(OpportunityEntity)
|
||||
private readonly opportunityRepo: Repository<OpportunityEntity>,
|
||||
@InjectRepository(ActivityEntity)
|
||||
private readonly activityRepo: Repository<ActivityEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getDashboardSummary(tenantId: string): Promise<SalesDashboardDto> {
|
||||
// Total leads
|
||||
const totalLeads = await this.leadRepo.count({
|
||||
where: { tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
// Total opportunities
|
||||
const totalOpportunities = await this.opportunityRepo.count({
|
||||
where: { tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
// Pipeline value and won deals
|
||||
const pipelineStats = await this.opportunityRepo
|
||||
.createQueryBuilder('o')
|
||||
.select('COUNT(*)', 'total')
|
||||
.addSelect('COALESCE(SUM(o.amount), 0)', 'totalValue')
|
||||
.addSelect('COUNT(*) FILTER (WHERE o.stage = :wonStage)', 'wonCount')
|
||||
.addSelect('COALESCE(SUM(o.amount) FILTER (WHERE o.stage = :wonStage), 0)', 'wonValue')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.setParameter('wonStage', OpportunityStage.CLOSED_WON)
|
||||
.getRawOne();
|
||||
|
||||
// Conversion rate (leads converted to opportunities)
|
||||
const convertedLeads = await this.leadRepo.count({
|
||||
where: { tenantId, status: LeadStatus.CONVERTED, deletedAt: null as any },
|
||||
});
|
||||
const conversionRate = totalLeads > 0 ? (convertedLeads / totalLeads) * 100 : 0;
|
||||
|
||||
// Average deal size
|
||||
const avgDealSize =
|
||||
Number(pipelineStats.wonCount) > 0
|
||||
? Number(pipelineStats.wonValue) / Number(pipelineStats.wonCount)
|
||||
: 0;
|
||||
|
||||
// Activities this week
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const activitiesThisWeek = await this.activityRepo.count({
|
||||
where: {
|
||||
tenantId,
|
||||
status: ActivityStatus.COMPLETED,
|
||||
deletedAt: null as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
totalLeads,
|
||||
totalOpportunities,
|
||||
totalPipelineValue: Number(pipelineStats.totalValue),
|
||||
wonDeals: Number(pipelineStats.wonCount),
|
||||
conversionRate: Math.round(conversionRate * 100) / 100,
|
||||
averageDealSize: Math.round(avgDealSize * 100) / 100,
|
||||
activitiesThisWeek,
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
|
||||
async getLeadsByStatus(tenantId: string): Promise<LeadsByStatusDto[]> {
|
||||
const result = await this.leadRepo
|
||||
.createQueryBuilder('l')
|
||||
.select('l.status', 'status')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('l.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('l.deleted_at IS NULL')
|
||||
.groupBy('l.status')
|
||||
.getRawMany();
|
||||
|
||||
const total = result.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
return result.map((r) => ({
|
||||
status: r.status,
|
||||
count: r.count,
|
||||
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getLeadsBySource(tenantId: string): Promise<LeadsBySourceDto[]> {
|
||||
const result = await this.leadRepo
|
||||
.createQueryBuilder('l')
|
||||
.select('l.source', 'source')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where('l.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('l.deleted_at IS NULL')
|
||||
.groupBy('l.source')
|
||||
.getRawMany();
|
||||
|
||||
const total = result.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
return result.map((r) => ({
|
||||
source: r.source,
|
||||
count: r.count,
|
||||
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getOpportunitiesByStage(tenantId: string): Promise<OpportunitiesByStageDto[]> {
|
||||
const result = await this.opportunityRepo
|
||||
.createQueryBuilder('o')
|
||||
.select('o.stage', 'stage')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.addSelect('COALESCE(SUM(o.amount), 0)', 'totalAmount')
|
||||
.where('o.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('o.deleted_at IS NULL')
|
||||
.groupBy('o.stage')
|
||||
.getRawMany();
|
||||
|
||||
const total = result.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
return result.map((r) => ({
|
||||
stage: r.stage,
|
||||
count: r.count,
|
||||
totalAmount: Number(r.totalAmount),
|
||||
percentage: total > 0 ? Math.round((r.count / total) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getConversionFunnel(tenantId: string): Promise<ConversionFunnelDto[]> {
|
||||
// Define funnel stages
|
||||
const stages = [
|
||||
{ stage: 'New Leads', query: { status: LeadStatus.NEW } },
|
||||
{ stage: 'Contacted', query: { status: LeadStatus.CONTACTED } },
|
||||
{ stage: 'Qualified', query: { status: LeadStatus.QUALIFIED } },
|
||||
{ stage: 'Converted', query: { status: LeadStatus.CONVERTED } },
|
||||
];
|
||||
|
||||
const totalLeads = await this.leadRepo.count({
|
||||
where: { tenantId, deletedAt: null as any },
|
||||
});
|
||||
|
||||
let previousCount = totalLeads;
|
||||
const funnel: ConversionFunnelDto[] = [];
|
||||
|
||||
for (const { stage, query } of stages) {
|
||||
const count = await this.leadRepo.count({
|
||||
where: { tenantId, ...query, deletedAt: null as any },
|
||||
});
|
||||
|
||||
funnel.push({
|
||||
stage,
|
||||
count,
|
||||
percentage: totalLeads > 0 ? Math.round((count / totalLeads) * 100) : 0,
|
||||
conversionFromPrevious:
|
||||
previousCount > 0 ? Math.round((count / previousCount) * 100) : 0,
|
||||
});
|
||||
|
||||
previousCount = count > 0 ? count : previousCount;
|
||||
}
|
||||
|
||||
// Add opportunity stages
|
||||
const wonOpportunities = await this.opportunityRepo.count({
|
||||
where: { tenantId, stage: OpportunityStage.CLOSED_WON, deletedAt: null as any },
|
||||
});
|
||||
|
||||
funnel.push({
|
||||
stage: 'Won Deals',
|
||||
count: wonOpportunities,
|
||||
percentage: totalLeads > 0 ? Math.round((wonOpportunities / totalLeads) * 100) : 0,
|
||||
conversionFromPrevious:
|
||||
previousCount > 0 ? Math.round((wonOpportunities / previousCount) * 100) : 0,
|
||||
});
|
||||
|
||||
return funnel;
|
||||
}
|
||||
|
||||
async getSalesPerformance(tenantId: string): Promise<SalesPerformanceDto[]> {
|
||||
// Get aggregated performance by user
|
||||
const result = await this.dataSource.query(`
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
COALESCE(u.first_name || ' ' || u.last_name, u.email) as user_name,
|
||||
(SELECT COUNT(*) FROM sales.leads WHERE assigned_to = u.id AND tenant_id = $1 AND deleted_at IS NULL)::int as leads_assigned,
|
||||
(SELECT COUNT(*) FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 AND stage = 'closed_won' AND deleted_at IS NULL)::int as opportunities_won,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1 AND stage = 'closed_won' AND deleted_at IS NULL) as total_revenue,
|
||||
(SELECT COUNT(*) FROM sales.activities WHERE assigned_to = u.id AND tenant_id = $1 AND status = 'completed' AND deleted_at IS NULL)::int as activities_completed
|
||||
FROM users.users u
|
||||
WHERE u.tenant_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sales.leads WHERE assigned_to = u.id AND tenant_id = $1
|
||||
UNION
|
||||
SELECT 1 FROM sales.opportunities WHERE assigned_to = u.id AND tenant_id = $1
|
||||
)
|
||||
`, [tenantId]);
|
||||
|
||||
return result.map((r: any) => ({
|
||||
userId: r.user_id,
|
||||
userName: r.user_name,
|
||||
leadsAssigned: r.leads_assigned,
|
||||
opportunitiesWon: r.opportunities_won,
|
||||
totalRevenue: Number(r.total_revenue),
|
||||
conversionRate:
|
||||
r.leads_assigned > 0
|
||||
? Math.round((r.opportunities_won / r.leads_assigned) * 100)
|
||||
: 0,
|
||||
activitiesCompleted: r.activities_completed,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user