CRM apis
This commit is contained in:
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+1508
-10
File diff suppressed because one or more lines are too long
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user