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
@@ -0,0 +1,144 @@
-- CreateEnum
CREATE TYPE "SalesPipelineStage" AS ENUM ('negotiation', 'awaiting_catalog', 'awaiting_price', 'ready_to_order');
-- CreateEnum
CREATE TYPE "OpportunityOutcome" AS ENUM ('open', 'won', 'lost');
-- CreateEnum
CREATE TYPE "LostReason" AS ENUM ('price', 'product', 'supply_time', 'customer_decision', 'other');
-- CreateEnum
CREATE TYPE "TimelineEventSource" AS ENUM ('manual', 'system');
-- CreateEnum
CREATE TYPE "TimelineEventKind" AS ENUM ('meeting', 'call', 'note', 'reminder', 'next_action', 'order_created', 'price_announced', 'price_confirmed', 'catalog_requested');
-- CreateEnum
CREATE TYPE "AssignmentRequestStatus" AS ENUM ('pending', 'approved', 'rejected');
-- CreateTable
CREATE TABLE "SalesOpportunity" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"stage" "SalesPipelineStage" NOT NULL DEFAULT 'negotiation',
"outcome" "OpportunityOutcome" NOT NULL DEFAULT 'open',
"lostReason" "LostReason",
"lostNote" TEXT,
"lostAt" TIMESTAMP(3),
"wonOrderId" TEXT,
"wonAt" TIMESTAMP(3),
"assignedAgentId" TEXT,
"ownerAdminId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesOpportunity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomerTimelineEvent" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"kind" "TimelineEventKind" NOT NULL,
"source" "TimelineEventSource" NOT NULL DEFAULT 'manual',
"title" TEXT NOT NULL,
"body" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actorAdminId" TEXT,
"actorCustomerId" TEXT,
"relatedOrderId" TEXT,
"relatedMeetingId" TEXT,
"relatedActionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerTimelineEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrmNextAction" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"opportunityId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"dueAt" TIMESTAMP(3) NOT NULL,
"completed" BOOLEAN NOT NULL DEFAULT false,
"completedAt" TIMESTAMP(3),
"assigneeAdminId" TEXT,
"assigneeAgentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CrmNextAction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomerAssignmentRequest" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"fromAgentId" TEXT,
"toAgentId" TEXT NOT NULL,
"requestedByAgentId" TEXT NOT NULL,
"status" "AssignmentRequestStatus" NOT NULL DEFAULT 'pending',
"reviewNote" TEXT,
"reviewedByAdminId" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerAssignmentRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SalesOpportunity_customerId_idx" ON "SalesOpportunity"("customerId");
-- CreateIndex
CREATE INDEX "SalesOpportunity_stage_outcome_idx" ON "SalesOpportunity"("stage", "outcome");
-- CreateIndex
CREATE INDEX "SalesOpportunity_assignedAgentId_idx" ON "SalesOpportunity"("assignedAgentId");
-- CreateIndex
CREATE INDEX "CustomerTimelineEvent_customerId_occurredAt_idx" ON "CustomerTimelineEvent"("customerId", "occurredAt");
-- CreateIndex
CREATE INDEX "CrmNextAction_customerId_completed_idx" ON "CrmNextAction"("customerId", "completed");
-- CreateIndex
CREATE INDEX "CrmNextAction_dueAt_idx" ON "CrmNextAction"("dueAt");
-- CreateIndex
CREATE INDEX "CustomerAssignmentRequest_status_idx" ON "CustomerAssignmentRequest"("status");
-- CreateIndex
CREATE INDEX "CustomerAssignmentRequest_customerId_idx" ON "CustomerAssignmentRequest"("customerId");
-- AddForeignKey
ALTER TABLE "SalesOpportunity" ADD CONSTRAINT "SalesOpportunity_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesOpportunity" ADD CONSTRAINT "SalesOpportunity_assignedAgentId_fkey" FOREIGN KEY ("assignedAgentId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesOpportunity" ADD CONSTRAINT "SalesOpportunity_wonOrderId_fkey" FOREIGN KEY ("wonOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerTimelineEvent" ADD CONSTRAINT "CustomerTimelineEvent_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrmNextAction" ADD CONSTRAINT "CrmNextAction_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrmNextAction" ADD CONSTRAINT "CrmNextAction_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "SalesOpportunity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_fromAgentId_fkey" FOREIGN KEY ("fromAgentId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_toAgentId_fkey" FOREIGN KEY ("toAgentId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_requestedByAgentId_fkey" FOREIGN KEY ("requestedByAgentId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+143 -2
View File
@@ -85,6 +85,50 @@ enum InteractionType {
note
}
enum SalesPipelineStage {
negotiation
awaiting_catalog
awaiting_price
ready_to_order
}
enum OpportunityOutcome {
open
won
lost
}
enum LostReason {
price
product
supply_time
customer_decision
other
}
enum TimelineEventSource {
manual
system
}
enum TimelineEventKind {
meeting
call
note
reminder
next_action
order_created
price_announced
price_confirmed
catalog_requested
}
enum AssignmentRequestStatus {
pending
approved
rejected
}
enum ProductSeason {
summer
winter
@@ -255,6 +299,14 @@ model Customer {
catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[]
addresses CustomerAddress[]
salesOpportunities SalesOpportunity[]
timelineEvents CustomerTimelineEvent[]
crmNextActions CrmNextAction[]
assignmentRequests CustomerAssignmentRequest[] @relation("AssignmentCustomer")
assignmentFrom CustomerAssignmentRequest[] @relation("AssignmentFromAgent")
assignmentTo CustomerAssignmentRequest[] @relation("AssignmentToAgent")
assignmentRequested CustomerAssignmentRequest[] @relation("AssignmentRequestedBy")
opportunitiesOwned SalesOpportunity[] @relation("OpportunityAgent")
@@index([companyId])
@@index([appRole])
@@ -369,8 +421,9 @@ model Order {
checks Check[]
cashReceipt CashPaymentReceipt?
shipment Shipment?
statusHistory OrderStatusHistory[]
notifications CustomerNotification[]
statusHistory OrderStatusHistory[]
notifications CustomerNotification[]
wonOpportunities SalesOpportunity[]
@@index([userId])
@@index([companyId])
@@ -620,6 +673,94 @@ model Reminder {
@@index([date])
}
model SalesOpportunity {
id String @id @default(cuid())
customerId String
stage SalesPipelineStage @default(negotiation)
outcome OpportunityOutcome @default(open)
lostReason LostReason?
lostNote String?
lostAt DateTime?
wonOrderId String?
wonAt DateTime?
assignedAgentId String?
ownerAdminId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
assignedAgent Customer? @relation("OpportunityAgent", fields: [assignedAgentId], references: [id], onDelete: SetNull)
wonOrder Order? @relation(fields: [wonOrderId], references: [id], onDelete: SetNull)
nextActions CrmNextAction[]
@@index([customerId])
@@index([stage, outcome])
@@index([assignedAgentId])
}
model CustomerTimelineEvent {
id String @id @default(cuid())
customerId String
kind TimelineEventKind
source TimelineEventSource @default(manual)
title String
body String?
occurredAt DateTime @default(now())
actorAdminId String?
actorCustomerId String?
relatedOrderId String?
relatedMeetingId String?
relatedActionId String?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@index([customerId, occurredAt])
}
model CrmNextAction {
id String @id @default(cuid())
customerId String
opportunityId String?
title String
description String?
dueAt DateTime
completed Boolean @default(false)
completedAt DateTime?
assigneeAdminId String?
assigneeAgentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
opportunity SalesOpportunity? @relation(fields: [opportunityId], references: [id], onDelete: SetNull)
@@index([customerId, completed])
@@index([dueAt])
}
model CustomerAssignmentRequest {
id String @id @default(cuid())
customerId String
fromAgentId String?
toAgentId String
requestedByAgentId String
status AssignmentRequestStatus @default(pending)
reviewNote String?
reviewedByAdminId String?
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation("AssignmentCustomer", fields: [customerId], references: [id], onDelete: Cascade)
fromAgent Customer? @relation("AssignmentFromAgent", fields: [fromAgentId], references: [id], onDelete: SetNull)
toAgent Customer @relation("AssignmentToAgent", fields: [toAgentId], references: [id], onDelete: Cascade)
requestedByAgent Customer @relation("AssignmentRequestedBy", fields: [requestedByAgentId], references: [id], onDelete: Cascade)
@@index([status])
@@index([customerId])
}
model ProductFavorite {
id String @id @default(cuid())
customerId String
@@ -0,0 +1,144 @@
-- CreateEnum
CREATE TYPE "SalesPipelineStage" AS ENUM ('negotiation', 'awaiting_catalog', 'awaiting_price', 'ready_to_order');
-- CreateEnum
CREATE TYPE "OpportunityOutcome" AS ENUM ('open', 'won', 'lost');
-- CreateEnum
CREATE TYPE "LostReason" AS ENUM ('price', 'product', 'supply_time', 'customer_decision', 'other');
-- CreateEnum
CREATE TYPE "TimelineEventSource" AS ENUM ('manual', 'system');
-- CreateEnum
CREATE TYPE "TimelineEventKind" AS ENUM ('meeting', 'call', 'note', 'reminder', 'next_action', 'order_created', 'price_announced', 'price_confirmed', 'catalog_requested');
-- CreateEnum
CREATE TYPE "AssignmentRequestStatus" AS ENUM ('pending', 'approved', 'rejected');
-- CreateTable
CREATE TABLE "SalesOpportunity" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"stage" "SalesPipelineStage" NOT NULL DEFAULT 'negotiation',
"outcome" "OpportunityOutcome" NOT NULL DEFAULT 'open',
"lostReason" "LostReason",
"lostNote" TEXT,
"lostAt" TIMESTAMP(3),
"wonOrderId" TEXT,
"wonAt" TIMESTAMP(3),
"assignedAgentId" TEXT,
"ownerAdminId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalesOpportunity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomerTimelineEvent" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"kind" "TimelineEventKind" NOT NULL,
"source" "TimelineEventSource" NOT NULL DEFAULT 'manual',
"title" TEXT NOT NULL,
"body" TEXT,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actorAdminId" TEXT,
"actorCustomerId" TEXT,
"relatedOrderId" TEXT,
"relatedMeetingId" TEXT,
"relatedActionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerTimelineEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrmNextAction" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"opportunityId" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"dueAt" TIMESTAMP(3) NOT NULL,
"completed" BOOLEAN NOT NULL DEFAULT false,
"completedAt" TIMESTAMP(3),
"assigneeAdminId" TEXT,
"assigneeAgentId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CrmNextAction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustomerAssignmentRequest" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"fromAgentId" TEXT,
"toAgentId" TEXT NOT NULL,
"requestedByAgentId" TEXT NOT NULL,
"status" "AssignmentRequestStatus" NOT NULL DEFAULT 'pending',
"reviewNote" TEXT,
"reviewedByAdminId" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerAssignmentRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SalesOpportunity_customerId_idx" ON "SalesOpportunity"("customerId");
-- CreateIndex
CREATE INDEX "SalesOpportunity_stage_outcome_idx" ON "SalesOpportunity"("stage", "outcome");
-- CreateIndex
CREATE INDEX "SalesOpportunity_assignedAgentId_idx" ON "SalesOpportunity"("assignedAgentId");
-- CreateIndex
CREATE INDEX "CustomerTimelineEvent_customerId_occurredAt_idx" ON "CustomerTimelineEvent"("customerId", "occurredAt");
-- CreateIndex
CREATE INDEX "CrmNextAction_customerId_completed_idx" ON "CrmNextAction"("customerId", "completed");
-- CreateIndex
CREATE INDEX "CrmNextAction_dueAt_idx" ON "CrmNextAction"("dueAt");
-- CreateIndex
CREATE INDEX "CustomerAssignmentRequest_status_idx" ON "CustomerAssignmentRequest"("status");
-- CreateIndex
CREATE INDEX "CustomerAssignmentRequest_customerId_idx" ON "CustomerAssignmentRequest"("customerId");
-- AddForeignKey
ALTER TABLE "SalesOpportunity" ADD CONSTRAINT "SalesOpportunity_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesOpportunity" ADD CONSTRAINT "SalesOpportunity_assignedAgentId_fkey" FOREIGN KEY ("assignedAgentId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalesOpportunity" ADD CONSTRAINT "SalesOpportunity_wonOrderId_fkey" FOREIGN KEY ("wonOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerTimelineEvent" ADD CONSTRAINT "CustomerTimelineEvent_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrmNextAction" ADD CONSTRAINT "CrmNextAction_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrmNextAction" ADD CONSTRAINT "CrmNextAction_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "SalesOpportunity"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_fromAgentId_fkey" FOREIGN KEY ("fromAgentId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_toAgentId_fkey" FOREIGN KEY ("toAgentId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustomerAssignmentRequest" ADD CONSTRAINT "CustomerAssignmentRequest_requestedByAgentId_fkey" FOREIGN KEY ("requestedByAgentId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+164 -23
View File
@@ -85,6 +85,50 @@ enum InteractionType {
note
}
enum SalesPipelineStage {
negotiation
awaiting_catalog
awaiting_price
ready_to_order
}
enum OpportunityOutcome {
open
won
lost
}
enum LostReason {
price
product
supply_time
customer_decision
other
}
enum TimelineEventSource {
manual
system
}
enum TimelineEventKind {
meeting
call
note
reminder
next_action
order_created
price_announced
price_confirmed
catalog_requested
}
enum AssignmentRequestStatus {
pending
approved
rejected
}
enum ProductSeason {
summer
winter
@@ -240,21 +284,29 @@ model Customer {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
levelDef CustomerLevelDef? @relation(fields: [levelDefId], references: [id], onDelete: SetNull)
assignedAgent Customer? @relation("AgentCustomers", fields: [assignedAgentId], references: [id], onDelete: SetNull)
agentCustomers Customer[] @relation("AgentCustomers")
carts Cart[]
orders Order[]
notifications CustomerNotification[]
meetings MeetingInteraction[] @relation("MeetingCustomer")
meetingsAsAgent MeetingInteraction[] @relation("MeetingAgent")
reminders Reminder[] @relation("ReminderOwner")
remindersAbout Reminder[] @relation("ReminderCustomer")
favorites ProductFavorite[]
catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[]
addresses CustomerAddress[]
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
levelDef CustomerLevelDef? @relation(fields: [levelDefId], references: [id], onDelete: SetNull)
assignedAgent Customer? @relation("AgentCustomers", fields: [assignedAgentId], references: [id], onDelete: SetNull)
agentCustomers Customer[] @relation("AgentCustomers")
carts Cart[]
orders Order[]
notifications CustomerNotification[]
meetings MeetingInteraction[] @relation("MeetingCustomer")
meetingsAsAgent MeetingInteraction[] @relation("MeetingAgent")
reminders Reminder[] @relation("ReminderOwner")
remindersAbout Reminder[] @relation("ReminderCustomer")
favorites ProductFavorite[]
catalogRequests CatalogRequest[]
productSuggestions ProductSuggestion[]
addresses CustomerAddress[]
salesOpportunities SalesOpportunity[]
timelineEvents CustomerTimelineEvent[]
crmNextActions CrmNextAction[]
assignmentRequests CustomerAssignmentRequest[] @relation("AssignmentCustomer")
assignmentFrom CustomerAssignmentRequest[] @relation("AssignmentFromAgent")
assignmentTo CustomerAssignmentRequest[] @relation("AssignmentToAgent")
assignmentRequested CustomerAssignmentRequest[] @relation("AssignmentRequestedBy")
opportunitiesOwned SalesOpportunity[] @relation("OpportunityAgent")
@@index([companyId])
@@index([appRole])
@@ -363,14 +415,15 @@ model Order {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user Customer @relation(fields: [userId], references: [id], onDelete: Restrict)
company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
items OrderItem[]
checks Check[]
cashReceipt CashPaymentReceipt?
shipment Shipment?
statusHistory OrderStatusHistory[]
notifications CustomerNotification[]
user Customer @relation(fields: [userId], references: [id], onDelete: Restrict)
company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
items OrderItem[]
checks Check[]
cashReceipt CashPaymentReceipt?
shipment Shipment?
statusHistory OrderStatusHistory[]
notifications CustomerNotification[]
wonOpportunities SalesOpportunity[]
@@index([userId])
@@index([companyId])
@@ -620,6 +673,94 @@ model Reminder {
@@index([date])
}
model SalesOpportunity {
id String @id @default(cuid())
customerId String
stage SalesPipelineStage @default(negotiation)
outcome OpportunityOutcome @default(open)
lostReason LostReason?
lostNote String?
lostAt DateTime?
wonOrderId String?
wonAt DateTime?
assignedAgentId String?
ownerAdminId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
assignedAgent Customer? @relation("OpportunityAgent", fields: [assignedAgentId], references: [id], onDelete: SetNull)
wonOrder Order? @relation(fields: [wonOrderId], references: [id], onDelete: SetNull)
nextActions CrmNextAction[]
@@index([customerId])
@@index([stage, outcome])
@@index([assignedAgentId])
}
model CustomerTimelineEvent {
id String @id @default(cuid())
customerId String
kind TimelineEventKind
source TimelineEventSource @default(manual)
title String
body String?
occurredAt DateTime @default(now())
actorAdminId String?
actorCustomerId String?
relatedOrderId String?
relatedMeetingId String?
relatedActionId String?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@index([customerId, occurredAt])
}
model CrmNextAction {
id String @id @default(cuid())
customerId String
opportunityId String?
title String
description String?
dueAt DateTime
completed Boolean @default(false)
completedAt DateTime?
assigneeAdminId String?
assigneeAgentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
opportunity SalesOpportunity? @relation(fields: [opportunityId], references: [id], onDelete: SetNull)
@@index([customerId, completed])
@@index([dueAt])
}
model CustomerAssignmentRequest {
id String @id @default(cuid())
customerId String
fromAgentId String?
toAgentId String
requestedByAgentId String
status AssignmentRequestStatus @default(pending)
reviewNote String?
reviewedByAdminId String?
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation("AssignmentCustomer", fields: [customerId], references: [id], onDelete: Cascade)
fromAgent Customer? @relation("AssignmentFromAgent", fields: [fromAgentId], references: [id], onDelete: SetNull)
toAgent Customer @relation("AssignmentToAgent", fields: [toAgentId], references: [id], onDelete: Cascade)
requestedByAgent Customer @relation("AssignmentRequestedBy", fields: [requestedByAgentId], references: [id], onDelete: Cascade)
@@index([status])
@@index([customerId])
}
model ProductFavorite {
id String @id @default(cuid())
customerId String
+102 -3
View File
File diff suppressed because one or more lines are too long
+1508 -10
View File
File diff suppressed because one or more lines are too long
+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);
}