This commit is contained in:
2026-10-10 10:35:56 +03:30
parent 7779101fb4
commit 7481ac5243
17 changed files with 3505 additions and 41 deletions
+2
View File
@@ -24,6 +24,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
import { BannersModule } from './banners/banners.module';
import { AddressesModule } from './addresses/addresses.module';
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
import { CrmModule } from './crm/crm.module';
@Module({
imports: [
@@ -50,6 +51,7 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo
BannersModule,
AddressesModule,
InternalMessagesModule,
CrmModule,
],
providers: [
{
+67
View File
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { AssignmentRequestStatus } from '@prisma/client';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { CrmService } from './crm.service';
import {
CreateAdminAgentDto,
ReviewAssignmentRequestDto,
UpdateAdminAgentDto,
} from './dto/crm.dto';
@Controller('admin/agents')
@AuthType('admin')
@Roles('admin', 'sales')
export class AdminAgentsController {
constructor(private readonly crmService: CrmService) {}
@Get()
list(@Query('q') q?: string) {
return this.crmService.listAgents(q);
}
@Post()
create(@Body() dto: CreateAdminAgentDto) {
return this.crmService.createAgent(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateAdminAgentDto) {
return this.crmService.updateAgent(id, dto);
}
@Get(':id/customers')
customers(@Param('id') id: string) {
return this.crmService.listAgentCustomers(id);
}
}
@Controller('admin/assignment-requests')
@AuthType('admin')
@Roles('admin', 'sales')
export class AdminAssignmentRequestsController {
constructor(private readonly crmService: CrmService) {}
@Get()
list(@Query('status') status?: AssignmentRequestStatus) {
return this.crmService.listAssignmentRequests(status);
}
@Patch(':id')
review(
@Param('id') id: string,
@CurrentUser() user: AuthPrincipal,
@Body() dto: ReviewAssignmentRequestDto,
) {
return this.crmService.reviewAssignmentRequest(id, user.id, dto);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Controller, Get, Param, Patch, Body, Query } from '@nestjs/common';
import { SalesPipelineStage } from '@prisma/client';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CrmService } from './crm.service';
import { UpdateOpportunityDto } from './dto/crm.dto';
@Controller('admin/crm')
@AuthType('admin')
@Roles('admin', 'sales')
export class CrmController {
constructor(private readonly crmService: CrmService) {}
@Get('pipeline')
pipeline() {
return this.crmService.getPipelineCounts();
}
@Get('pipeline/:stage/customers')
pipelineCustomers(
@Param('stage') stage: SalesPipelineStage,
@Query('q') q?: string,
) {
return this.crmService.listPipelineCustomers(stage, q);
}
@Patch('opportunities/:id')
updateOpportunity(
@Param('id') id: string,
@Body() dto: UpdateOpportunityDto,
) {
return this.crmService.updateOpportunity(id, dto);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { CrmService } from './crm.service';
import { CrmController } from './crm.controller';
import {
AdminNextActionsController,
CustomerCrmController,
} from './customer-crm.controller';
import {
AdminAgentsController,
AdminAssignmentRequestsController,
} from './admin-agents.controller';
@Module({
controllers: [
CrmController,
CustomerCrmController,
AdminNextActionsController,
AdminAgentsController,
AdminAssignmentRequestsController,
],
providers: [CrmService],
exports: [CrmService],
})
export class CrmModule {}
+886
View File
@@ -0,0 +1,886 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
AppUserRole,
AssignmentRequestStatus,
OpportunityOutcome,
Prisma,
SalesPipelineStage,
TimelineEventKind,
TimelineEventSource,
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateAdminAgentDto,
CreateNextActionDto,
CreateOpportunityDto,
CreateTimelineEventDto,
ReviewAssignmentRequestDto,
SetCustomerAssignmentDto,
UpdateAdminAgentDto,
UpdateNextActionDto,
UpdateOpportunityDto,
} from './dto/crm.dto';
import { joinFullName } from '../common/utils/name';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
import { resolveAgentCodeForRole } from '../common/utils/agent-code';
const PIPELINE_STAGES: SalesPipelineStage[] = [
SalesPipelineStage.negotiation,
SalesPipelineStage.awaiting_catalog,
SalesPipelineStage.awaiting_price,
SalesPipelineStage.ready_to_order,
];
@Injectable()
export class CrmService {
constructor(private readonly prisma: PrismaService) {}
async getPipelineCounts() {
const groups = await this.prisma.salesOpportunity.groupBy({
by: ['stage'],
where: { outcome: OpportunityOutcome.open },
_count: { _all: true },
});
const counts: Record<SalesPipelineStage, number> = {
negotiation: 0,
awaiting_catalog: 0,
awaiting_price: 0,
ready_to_order: 0,
};
for (const row of groups) {
counts[row.stage] = row._count._all;
}
return { stages: counts, totalOpen: groups.reduce((s, g) => s + g._count._all, 0) };
}
async listPipelineCustomers(stage: SalesPipelineStage, q?: string) {
if (!PIPELINE_STAGES.includes(stage)) {
throw new BadRequestException('مرحله pipeline نامعتبر است');
}
const opportunities = await this.prisma.salesOpportunity.findMany({
where: {
stage,
outcome: OpportunityOutcome.open,
customer: q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: toEnglishDigits(q) } },
],
}
: undefined,
},
orderBy: { updatedAt: 'desc' },
include: {
customer: {
include: {
assignedAgent: {
select: {
id: true,
firstName: true,
lastName: true,
phone: true,
agentCode: true,
},
},
orders: {
select: { id: true, orderNumber: true, createdAt: true, status: true },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
},
assignedAgent: {
select: { id: true, firstName: true, lastName: true, agentCode: true },
},
},
});
return opportunities.map((o) => this.mapPipelineRow(o));
}
async updateOpportunity(id: string, dto: UpdateOpportunityDto) {
const existing = await this.prisma.salesOpportunity.findUnique({
where: { id },
});
if (!existing) throw new NotFoundException('فرصت فروش یافت نشد');
if (dto.assignedAgentId) {
await this.assertAgent(dto.assignedAgentId);
}
const outcome = dto.outcome ?? existing.outcome;
if (outcome === OpportunityOutcome.open && existing.outcome !== OpportunityOutcome.open) {
const otherOpen = await this.prisma.salesOpportunity.findFirst({
where: {
customerId: existing.customerId,
outcome: OpportunityOutcome.open,
id: { not: id },
},
});
if (otherOpen) {
throw new ConflictException('این مشتری از قبل فرصت باز دارد');
}
}
const data: Prisma.SalesOpportunityUncheckedUpdateInput = {
stage: dto.stage,
outcome,
};
if (dto.assignedAgentId !== undefined) {
data.assignedAgentId = dto.assignedAgentId;
}
if (dto.ownerAdminId !== undefined) {
data.ownerAdminId = dto.ownerAdminId;
}
if (outcome === OpportunityOutcome.lost) {
data.lostReason = dto.lostReason ?? existing.lostReason;
data.lostNote = dto.lostNote ?? existing.lostNote;
data.lostAt = new Date();
data.wonOrderId = null;
data.wonAt = null;
} else if (outcome === OpportunityOutcome.won) {
data.wonOrderId = dto.wonOrderId ?? existing.wonOrderId;
data.wonAt = existing.wonAt ?? new Date();
data.lostReason = null;
data.lostNote = null;
data.lostAt = null;
} else {
data.lostReason = null;
data.lostNote = null;
data.lostAt = null;
data.wonOrderId = null;
data.wonAt = null;
}
const updated = await this.prisma.salesOpportunity.update({
where: { id },
data,
});
return this.mapOpportunity(updated);
}
async createOpportunity(customerId: string, dto: CreateOpportunityDto) {
await this.assertCustomer(customerId);
const open = await this.prisma.salesOpportunity.findFirst({
where: { customerId, outcome: OpportunityOutcome.open },
});
if (open) {
throw new ConflictException('فرصت باز برای این مشتری وجود دارد');
}
if (dto.assignedAgentId) {
await this.assertAgent(dto.assignedAgentId);
}
const created = await this.prisma.salesOpportunity.create({
data: {
customerId,
stage: dto.stage ?? SalesPipelineStage.negotiation,
assignedAgentId: dto.assignedAgentId ?? null,
},
});
return this.mapOpportunity(created);
}
async getCustomerCrmExtras(customerId: string) {
const [openOpportunity, timelineCount, openActions] = await Promise.all([
this.prisma.salesOpportunity.findFirst({
where: { customerId, outcome: OpportunityOutcome.open },
orderBy: { createdAt: 'desc' },
}),
this.prisma.customerTimelineEvent.count({ where: { customerId } }),
this.prisma.crmNextAction.count({
where: { customerId, completed: false },
}),
]);
return {
openOpportunity: openOpportunity
? this.mapOpportunity(openOpportunity)
: null,
timelineCount,
openNextActionsCount: openActions,
};
}
async listTimeline(customerId: string) {
await this.assertCustomer(customerId);
const events = await this.prisma.customerTimelineEvent.findMany({
where: { customerId },
orderBy: { occurredAt: 'desc' },
});
return events.map((e) => this.mapTimelineEvent(e));
}
async createTimeline(
customerId: string,
adminId: string,
dto: CreateTimelineEventDto,
) {
await this.assertCustomer(customerId);
await this.ensureOpenOpportunity(customerId);
const created = await this.prisma.customerTimelineEvent.create({
data: {
customerId,
kind: dto.kind,
source: TimelineEventSource.manual,
title: dto.title.trim(),
body: dto.body?.trim() || null,
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : new Date(),
actorAdminId: adminId,
},
});
return this.mapTimelineEvent(created);
}
async listNextActions(customerId: string) {
await this.assertCustomer(customerId);
const rows = await this.prisma.crmNextAction.findMany({
where: { customerId },
orderBy: [{ completed: 'asc' }, { dueAt: 'asc' }],
});
return rows.map((r) => this.mapNextAction(r));
}
async createNextAction(customerId: string, dto: CreateNextActionDto) {
await this.assertCustomer(customerId);
if (dto.assigneeAgentId) await this.assertAgent(dto.assigneeAgentId);
const created = await this.prisma.crmNextAction.create({
data: {
customerId,
opportunityId: dto.opportunityId || null,
title: dto.title.trim(),
description: dto.description?.trim() || null,
dueAt: new Date(dto.dueAt),
assigneeAdminId: dto.assigneeAdminId || null,
assigneeAgentId: dto.assigneeAgentId || null,
},
});
return this.mapNextAction(created);
}
async updateNextAction(id: string, dto: UpdateNextActionDto) {
const existing = await this.prisma.crmNextAction.findUnique({
where: { id },
});
if (!existing) throw new NotFoundException('اقدام بعدی یافت نشد');
if (dto.assigneeAgentId) await this.assertAgent(dto.assigneeAgentId);
const completed =
dto.completed !== undefined ? dto.completed : existing.completed;
const updated = await this.prisma.crmNextAction.update({
where: { id },
data: {
title: dto.title?.trim(),
description:
dto.description !== undefined
? dto.description?.trim() || null
: undefined,
dueAt: dto.dueAt ? new Date(dto.dueAt) : undefined,
completed,
completedAt:
completed && !existing.completed
? new Date()
: !completed
? null
: undefined,
assigneeAdminId:
dto.assigneeAdminId !== undefined ? dto.assigneeAdminId : undefined,
assigneeAgentId:
dto.assigneeAgentId !== undefined ? dto.assigneeAgentId : undefined,
},
});
return this.mapNextAction(updated);
}
async setCustomerAssignment(customerId: string, dto: SetCustomerAssignmentDto) {
await this.assertCustomer(customerId);
if (dto.assignedAgentId) {
await this.assertAgent(dto.assignedAgentId, customerId);
}
const customer = await this.prisma.customer.update({
where: { id: customerId },
data: { assignedAgentId: dto.assignedAgentId ?? null },
select: {
id: true,
assignedAgentId: true,
assignedAgent: {
select: {
id: true,
firstName: true,
lastName: true,
phone: true,
agentCode: true,
},
},
},
});
await this.prisma.salesOpportunity.updateMany({
where: { customerId, outcome: OpportunityOutcome.open },
data: { assignedAgentId: dto.assignedAgentId ?? null },
});
await this.recordSystemEvent(customerId, {
kind: TimelineEventKind.note,
title: 'تغییر واسطه منتسب',
body: customer.assignedAgent
? `واسطه: ${joinFullName(customer.assignedAgent.firstName, customer.assignedAgent.lastName)}`
: 'بدون واسطه',
});
return {
customerId: customer.id,
assignedAgentId: customer.assignedAgentId,
assignedAgent: customer.assignedAgent
? {
id: customer.assignedAgent.id,
name: joinFullName(
customer.assignedAgent.firstName,
customer.assignedAgent.lastName,
),
phone: customer.assignedAgent.phone,
code: customer.assignedAgent.agentCode ?? '',
}
: null,
};
}
async listAgents(q?: string) {
const agents = await this.prisma.customer.findMany({
where: {
appRole: AppUserRole.agent,
...(q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: toEnglishDigits(q) } },
{ agentCode: { contains: q, mode: 'insensitive' } },
],
}
: {}),
},
orderBy: { createdAt: 'desc' },
include: {
agentCustomers: { select: { id: true } },
},
});
return agents.map((a) => ({
id: a.id,
firstName: a.firstName,
lastName: a.lastName,
name: joinFullName(a.firstName, a.lastName),
phone: a.phone,
agentCode: a.agentCode ?? '',
isActive: a.isActive,
customersCount: a.agentCustomers.length,
createdAt: a.createdAt.toISOString(),
}));
}
async createAgent(dto: CreateAdminAgentDto) {
const phone = normalizePhone(toEnglishDigits(dto.phone));
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
try {
const agent = await this.prisma.$transaction(async (tx) => {
const agentCode = await resolveAgentCodeForRole(
tx,
AppUserRole.agent,
dto.agentCode?.trim() || null,
);
return tx.customer.create({
data: {
firstName: dto.firstName.trim(),
lastName: dto.lastName?.trim() ?? '',
phone,
appRole: AppUserRole.agent,
agentCode,
isActive: dto.isActive ?? true,
},
});
});
return this.mapAgent(agent);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('این شماره یا کد واسطه قبلاً ثبت شده است');
}
throw e;
}
}
async updateAgent(id: string, dto: UpdateAdminAgentDto) {
const existing = await this.prisma.customer.findUnique({ where: { id } });
if (!existing || existing.appRole !== AppUserRole.agent) {
throw new NotFoundException('واسطه یافت نشد');
}
const phone =
dto.phone !== undefined
? normalizePhone(toEnglishDigits(dto.phone))
: undefined;
if (phone !== undefined && !isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const agent = await this.prisma.customer.update({
where: { id },
data: {
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
phone,
isActive: dto.isActive,
...(dto.agentCode !== undefined
? { agentCode: dto.agentCode.trim() || null }
: {}),
},
});
return this.mapAgent(agent);
}
async listAgentCustomers(agentId: string) {
await this.assertAgent(agentId);
const customers = await this.prisma.customer.findMany({
where: { assignedAgentId: agentId, appRole: AppUserRole.customer },
orderBy: { createdAt: 'desc' },
});
return customers.map((c) => ({
id: c.id,
name: joinFullName(c.firstName, c.lastName),
phone: c.phone,
isActive: c.isActive,
createdAt: c.createdAt.toISOString(),
}));
}
async listAssignmentRequests(status?: AssignmentRequestStatus) {
const rows = await this.prisma.customerAssignmentRequest.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
include: {
customer: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
fromAgent: {
select: { id: true, firstName: true, lastName: true, agentCode: true },
},
toAgent: {
select: { id: true, firstName: true, lastName: true, agentCode: true },
},
requestedByAgent: {
select: { id: true, firstName: true, lastName: true, agentCode: true },
},
},
});
return rows.map((r) => ({
id: r.id,
status: r.status,
reviewNote: r.reviewNote,
reviewedByAdminId: r.reviewedByAdminId,
reviewedAt: r.reviewedAt?.toISOString() ?? null,
createdAt: r.createdAt.toISOString(),
customer: {
id: r.customer.id,
name: joinFullName(r.customer.firstName, r.customer.lastName),
phone: r.customer.phone,
},
fromAgent: r.fromAgent
? {
id: r.fromAgent.id,
name: joinFullName(r.fromAgent.firstName, r.fromAgent.lastName),
code: r.fromAgent.agentCode ?? '',
}
: null,
toAgent: {
id: r.toAgent.id,
name: joinFullName(r.toAgent.firstName, r.toAgent.lastName),
code: r.toAgent.agentCode ?? '',
},
requestedBy: {
id: r.requestedByAgent.id,
name: joinFullName(
r.requestedByAgent.firstName,
r.requestedByAgent.lastName,
),
code: r.requestedByAgent.agentCode ?? '',
},
}));
}
async reviewAssignmentRequest(
id: string,
adminId: string,
dto: ReviewAssignmentRequestDto,
) {
if (
dto.status !== AssignmentRequestStatus.approved &&
dto.status !== AssignmentRequestStatus.rejected
) {
throw new BadRequestException('وضعیت باید approved یا rejected باشد');
}
const row = await this.prisma.customerAssignmentRequest.findUnique({
where: { id },
});
if (!row) throw new NotFoundException('درخواست یافت نشد');
if (row.status !== AssignmentRequestStatus.pending) {
throw new BadRequestException('این درخواست قبلاً بررسی شده است');
}
const updated = await this.prisma.$transaction(async (tx) => {
const req = await tx.customerAssignmentRequest.update({
where: { id },
data: {
status: dto.status,
reviewNote: dto.reviewNote?.trim() || null,
reviewedByAdminId: adminId,
reviewedAt: new Date(),
},
});
if (dto.status === AssignmentRequestStatus.approved) {
await tx.customer.update({
where: { id: row.customerId },
data: { assignedAgentId: row.toAgentId },
});
await tx.salesOpportunity.updateMany({
where: {
customerId: row.customerId,
outcome: OpportunityOutcome.open,
},
data: { assignedAgentId: row.toAgentId },
});
}
return req;
});
await this.recordSystemEvent(row.customerId, {
kind: TimelineEventKind.note,
title:
dto.status === AssignmentRequestStatus.approved
? 'تأیید انتقال واسطه'
: 'رد انتقال واسطه',
body: dto.reviewNote?.trim() || undefined,
});
return {
id: updated.id,
status: updated.status,
reviewedAt: updated.reviewedAt?.toISOString() ?? null,
};
}
async onOrderSubmitted(customerId: string, orderId: string) {
await this.ensureOpenOpportunity(customerId);
const order = await this.prisma.order.findUnique({
where: { id: orderId },
select: { orderNumber: true },
});
await this.recordSystemEvent(customerId, {
kind: TimelineEventKind.order_created,
title: 'ثبت سفارش',
body: order ? `شماره سفارش: ${order.orderNumber}` : undefined,
relatedOrderId: orderId,
});
}
async onInvoicePriced(customerId: string, orderId: string) {
await this.ensureOpenOpportunity(customerId);
await this.prisma.salesOpportunity.updateMany({
where: { customerId, outcome: OpportunityOutcome.open },
data: { stage: SalesPipelineStage.awaiting_price },
});
const order = await this.prisma.order.findUnique({
where: { id: orderId },
select: { orderNumber: true, finalAmount: true },
});
await this.recordSystemEvent(customerId, {
kind: TimelineEventKind.price_announced,
title: 'اعلام قیمت به مشتری',
body: order
? `سفارش ${order.orderNumber}${order.finalAmount ? ` — ${Math.round(order.finalAmount).toLocaleString('fa-IR')} تومان` : ''}`
: undefined,
relatedOrderId: orderId,
});
}
async onPriceConfirmed(customerId: string, orderId: string) {
await this.prisma.salesOpportunity.updateMany({
where: { customerId, outcome: OpportunityOutcome.open },
data: { stage: SalesPipelineStage.ready_to_order },
});
await this.recordSystemEvent(customerId, {
kind: TimelineEventKind.price_confirmed,
title: 'تأیید قیمت توسط مشتری',
relatedOrderId: orderId,
});
}
async onOrderWon(customerId: string, orderId: string) {
await this.prisma.salesOpportunity.updateMany({
where: { customerId, outcome: OpportunityOutcome.open },
data: {
outcome: OpportunityOutcome.won,
wonOrderId: orderId,
wonAt: new Date(),
stage: SalesPipelineStage.ready_to_order,
},
});
await this.recordSystemEvent(customerId, {
kind: TimelineEventKind.order_created,
title: 'تبدیل فرصت به فروش',
body: 'سفارش نهایی تأیید شد',
relatedOrderId: orderId,
});
}
private async ensureOpenOpportunity(customerId: string) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
select: { assignedAgentId: true },
});
if (!customer) return;
const open = await this.prisma.salesOpportunity.findFirst({
where: { customerId, outcome: OpportunityOutcome.open },
});
if (open) return;
await this.prisma.salesOpportunity.create({
data: {
customerId,
stage: SalesPipelineStage.negotiation,
assignedAgentId: customer.assignedAgentId,
},
});
}
private async recordSystemEvent(
customerId: string,
input: {
kind: TimelineEventKind;
title: string;
body?: string;
relatedOrderId?: string;
},
) {
await this.prisma.customerTimelineEvent.create({
data: {
customerId,
kind: input.kind,
source: TimelineEventSource.system,
title: input.title,
body: input.body ?? null,
relatedOrderId: input.relatedOrderId ?? null,
},
});
}
private async assertCustomer(id: string) {
const c = await this.prisma.customer.findUnique({
where: { id },
select: { id: true, appRole: true },
});
if (!c || c.appRole !== AppUserRole.customer) {
throw new NotFoundException('مشتری یافت نشد');
}
}
private async assertAgent(agentId: string, customerId?: string) {
if (customerId && agentId === customerId) {
throw new BadRequestException('واسطه نمی‌تواند خود مشتری باشد');
}
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
if (!agent || agent.appRole !== AppUserRole.agent) {
throw new BadRequestException('واسطه معتبر نیست');
}
}
private mapOpportunity(o: {
id: string;
customerId: string;
stage: SalesPipelineStage;
outcome: OpportunityOutcome;
lostReason: string | null;
lostNote: string | null;
lostAt: Date | null;
wonOrderId: string | null;
wonAt: Date | null;
assignedAgentId: string | null;
ownerAdminId: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: o.id,
customerId: o.customerId,
stage: o.stage,
outcome: o.outcome,
lostReason: o.lostReason,
lostNote: o.lostNote,
lostAt: o.lostAt?.toISOString() ?? null,
wonOrderId: o.wonOrderId,
wonAt: o.wonAt?.toISOString() ?? null,
assignedAgentId: o.assignedAgentId,
ownerAdminId: o.ownerAdminId,
createdAt: o.createdAt.toISOString(),
updatedAt: o.updatedAt.toISOString(),
};
}
private mapTimelineEvent(e: {
id: string;
customerId: string;
kind: TimelineEventKind;
source: TimelineEventSource;
title: string;
body: string | null;
occurredAt: Date;
actorAdminId: string | null;
actorCustomerId: string | null;
relatedOrderId: string | null;
relatedMeetingId: string | null;
relatedActionId: string | null;
createdAt: Date;
}) {
return {
id: e.id,
customerId: e.customerId,
kind: e.kind,
source: e.source,
title: e.title,
body: e.body,
occurredAt: e.occurredAt.toISOString(),
actorAdminId: e.actorAdminId,
actorCustomerId: e.actorCustomerId,
relatedOrderId: e.relatedOrderId,
relatedMeetingId: e.relatedMeetingId,
relatedActionId: e.relatedActionId,
createdAt: e.createdAt.toISOString(),
};
}
private mapNextAction(a: {
id: string;
customerId: string;
opportunityId: string | null;
title: string;
description: string | null;
dueAt: Date;
completed: boolean;
completedAt: Date | null;
assigneeAdminId: string | null;
assigneeAgentId: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: a.id,
customerId: a.customerId,
opportunityId: a.opportunityId,
title: a.title,
description: a.description,
dueAt: a.dueAt.toISOString(),
completed: a.completed,
completedAt: a.completedAt?.toISOString() ?? null,
assigneeAdminId: a.assigneeAdminId,
assigneeAgentId: a.assigneeAgentId,
createdAt: a.createdAt.toISOString(),
updatedAt: a.updatedAt.toISOString(),
};
}
private mapAgent(a: {
id: string;
firstName: string;
lastName: string;
phone: string;
agentCode: string | null;
isActive: boolean;
createdAt: Date;
}) {
return {
id: a.id,
firstName: a.firstName,
lastName: a.lastName,
name: joinFullName(a.firstName, a.lastName),
phone: a.phone,
agentCode: a.agentCode ?? '',
isActive: a.isActive,
createdAt: a.createdAt.toISOString(),
};
}
private mapPipelineRow(
o: Prisma.SalesOpportunityGetPayload<{
include: {
customer: {
include: {
assignedAgent: {
select: {
id: true;
firstName: true;
lastName: true;
phone: true;
agentCode: true;
};
};
orders: {
select: {
id: true;
orderNumber: true;
createdAt: true;
status: true;
};
};
};
};
assignedAgent: {
select: {
id: true;
firstName: true;
lastName: true;
agentCode: true;
};
};
};
}>,
) {
const c = o.customer;
const lastOrder = c.orders[0];
const agent = o.assignedAgent ?? c.assignedAgent;
return {
opportunityId: o.id,
stage: o.stage,
outcome: o.outcome,
customer: {
id: c.id,
name: joinFullName(c.firstName, c.lastName),
phone: c.phone,
},
agent: agent
? {
id: agent.id,
name: joinFullName(agent.firstName, agent.lastName),
code: agent.agentCode ?? '',
phone: 'phone' in agent ? agent.phone : undefined,
}
: null,
lastOrder: lastOrder
? {
id: lastOrder.id,
orderNumber: lastOrder.orderNumber,
status: lastOrder.status,
createdAt: lastOrder.createdAt.toISOString(),
}
: null,
updatedAt: o.updatedAt.toISOString(),
};
}
}
+81
View File
@@ -0,0 +1,81 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { CrmService } from './crm.service';
import {
CreateNextActionDto,
CreateOpportunityDto,
CreateTimelineEventDto,
SetCustomerAssignmentDto,
UpdateNextActionDto,
} from './dto/crm.dto';
@Controller('admin/customers')
@AuthType('admin')
@Roles('admin', 'sales')
export class CustomerCrmController {
constructor(private readonly crmService: CrmService) {}
@Get(':id/timeline')
timeline(@Param('id') id: string) {
return this.crmService.listTimeline(id);
}
@Post(':id/timeline')
addTimeline(
@Param('id') id: string,
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateTimelineEventDto,
) {
return this.crmService.createTimeline(id, user.id, dto);
}
@Get(':id/next-actions')
nextActions(@Param('id') id: string) {
return this.crmService.listNextActions(id);
}
@Post(':id/next-actions')
createNextAction(
@Param('id') id: string,
@Body() dto: CreateNextActionDto,
) {
return this.crmService.createNextAction(id, dto);
}
@Post(':id/opportunities')
createOpportunity(
@Param('id') id: string,
@Body() dto: CreateOpportunityDto,
) {
return this.crmService.createOpportunity(id, dto);
}
@Patch(':id/assignment')
setAssignment(
@Param('id') id: string,
@Body() dto: SetCustomerAssignmentDto,
) {
return this.crmService.setCustomerAssignment(id, dto);
}
}
@Controller('admin/next-actions')
@AuthType('admin')
@Roles('admin', 'sales')
export class AdminNextActionsController {
constructor(private readonly crmService: CrmService) {}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateNextActionDto) {
return this.crmService.updateNextAction(id, dto);
}
}
+183
View File
@@ -0,0 +1,183 @@
import {
IsBoolean,
IsDateString,
IsEnum,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import {
AssignmentRequestStatus,
LostReason,
OpportunityOutcome,
SalesPipelineStage,
TimelineEventKind,
} from '@prisma/client';
export class UpdateOpportunityDto {
@IsOptional()
@IsEnum(SalesPipelineStage)
stage?: SalesPipelineStage;
@IsOptional()
@IsEnum(OpportunityOutcome)
outcome?: OpportunityOutcome;
@IsOptional()
@IsEnum(LostReason)
lostReason?: LostReason;
@IsOptional()
@IsString()
lostNote?: string;
@IsOptional()
@IsString()
wonOrderId?: string;
@IsOptional()
@IsString()
assignedAgentId?: string | null;
@IsOptional()
@IsString()
ownerAdminId?: string | null;
}
export class CreateTimelineEventDto {
@IsEnum(TimelineEventKind)
kind!: TimelineEventKind;
@IsString()
@MinLength(1)
title!: string;
@IsOptional()
@IsString()
body?: string;
@IsOptional()
@IsDateString()
occurredAt?: string;
}
export class CreateNextActionDto {
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsDateString()
dueAt!: string;
@IsOptional()
@IsString()
opportunityId?: string;
@IsOptional()
@IsString()
assigneeAdminId?: string;
@IsOptional()
@IsString()
assigneeAgentId?: string;
}
export class UpdateNextActionDto {
@IsOptional()
@IsString()
@MinLength(2)
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsDateString()
dueAt?: string;
@IsOptional()
@IsBoolean()
completed?: boolean;
@IsOptional()
@IsString()
assigneeAdminId?: string | null;
@IsOptional()
@IsString()
assigneeAgentId?: string | null;
}
export class SetCustomerAssignmentDto {
@IsOptional()
@IsString()
assignedAgentId?: string | null;
}
export class CreateAdminAgentDto {
@IsString()
@MinLength(2)
firstName!: string;
@IsOptional()
@IsString()
lastName?: string;
@IsString()
phone!: string;
@IsOptional()
@IsString()
agentCode?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateAdminAgentDto {
@IsOptional()
@IsString()
@MinLength(2)
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
agentCode?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReviewAssignmentRequestDto {
@IsEnum(AssignmentRequestStatus)
status!: AssignmentRequestStatus;
@IsOptional()
@IsString()
reviewNote?: string;
}
export class CreateOpportunityDto {
@IsOptional()
@IsEnum(SalesPipelineStage)
stage?: SalesPipelineStage;
@IsOptional()
@IsString()
assignedAgentId?: string | null;
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
import { CrmModule } from '../crm/crm.module';
@Module({
imports: [CrmModule],
controllers: [CustomersController],
providers: [CustomersService],
exports: [CustomersService],
+7 -2
View File
@@ -14,6 +14,7 @@ import {
} from '../common/utils/phone';
import { joinFullName, splitFullName } from '../common/utils/name';
import { resolveAgentCodeForRole } from '../common/utils/agent-code';
import { CrmService } from '../crm/crm.service';
const customerInclude = {
company: { select: { name: true } },
@@ -30,7 +31,10 @@ type CustomerWithRelations = Prisma.CustomerGetPayload<{
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly crmService: CrmService,
) {}
async findAll(q?: string, appRole?: AppUserRole) {
const customers = await this.prisma.customer.findMany({
@@ -60,7 +64,8 @@ export class CustomersService {
include: customerInclude,
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
return this.map(customer);
const crm = await this.crmService.getCustomerCrmExtras(id);
return { ...this.map(customer), crm };
}
async create(dto: CreateCustomerDto) {
+2 -1
View File
@@ -7,9 +7,10 @@ import { OrdersService } from './orders.service';
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AgentModule } from '../agent/agent.module';
import { CrmModule } from '../crm/crm.module';
@Module({
imports: [NotificationsModule, AgentModule],
imports: [NotificationsModule, AgentModule, CrmModule],
controllers: [CustomerOrdersController, AdminOrdersController],
providers: [OrdersService, CashInvoiceSchedulerService],
exports: [OrdersService],
+13
View File
@@ -38,6 +38,7 @@ import {
} from '../common/utils/order-transitions';
import { resolveMaxMeterage } from '../common/utils/meterage';
import { AgentLevelService } from '../agent/agent-level.service';
import { CrmService } from '../crm/crm.service';
import { NotificationsService } from '../notifications/notifications.service';
import { eventForStatus } from '../notifications/notification-events';
import { joinFullName, splitFullName } from '../common/utils/name';
@@ -87,6 +88,7 @@ export class OrdersService {
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly agentLevelService: AgentLevelService,
private readonly crmService: CrmService,
) {}
async findMyOrders(userId: string) {
@@ -202,6 +204,7 @@ export class OrdersService {
body: `سفارش ${order.orderNumber} با ${order.items.length} قلم و ${order.totalMeterage} متر در انتظار قیمت‌گذاری است.`,
link: `/orders/${order.id}`,
});
void this.crmService.onOrderSubmitted(order.userId, order.id);
return this.mapOrder(order);
}
@@ -266,6 +269,8 @@ export class OrdersService {
});
});
void this.crmService.onPriceConfirmed(order.userId, id);
return this.findMyOrder(userId, id);
}
@@ -342,6 +347,7 @@ export class OrdersService {
order.id,
'order_registered_by_admin',
);
void this.crmService.onOrderSubmitted(order.userId, order.id);
return this.mapOrder(order, true);
}
@@ -578,6 +584,13 @@ export class OrdersService {
});
}
if (dto.status === OrderStatus.awaiting_settlement) {
void this.crmService.onInvoicePriced(order.userId, id);
}
if (dto.status === OrderStatus.final_approved) {
void this.crmService.onOrderWon(order.userId, id);
}
return this.findAdminOrder(id);
}