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
|
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 {
|
enum ProductSeason {
|
||||||
summer
|
summer
|
||||||
winter
|
winter
|
||||||
@@ -255,6 +299,14 @@ model Customer {
|
|||||||
catalogRequests CatalogRequest[]
|
catalogRequests CatalogRequest[]
|
||||||
productSuggestions ProductSuggestion[]
|
productSuggestions ProductSuggestion[]
|
||||||
addresses CustomerAddress[]
|
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([companyId])
|
||||||
@@index([appRole])
|
@@index([appRole])
|
||||||
@@ -369,8 +421,9 @@ model Order {
|
|||||||
checks Check[]
|
checks Check[]
|
||||||
cashReceipt CashPaymentReceipt?
|
cashReceipt CashPaymentReceipt?
|
||||||
shipment Shipment?
|
shipment Shipment?
|
||||||
statusHistory OrderStatusHistory[]
|
statusHistory OrderStatusHistory[]
|
||||||
notifications CustomerNotification[]
|
notifications CustomerNotification[]
|
||||||
|
wonOpportunities SalesOpportunity[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
@@ -620,6 +673,94 @@ model Reminder {
|
|||||||
@@index([date])
|
@@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 {
|
model ProductFavorite {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
customerId String
|
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
|
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 {
|
enum ProductSeason {
|
||||||
summer
|
summer
|
||||||
winter
|
winter
|
||||||
@@ -240,21 +284,29 @@ model Customer {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
levelDef CustomerLevelDef? @relation(fields: [levelDefId], references: [id], onDelete: SetNull)
|
levelDef CustomerLevelDef? @relation(fields: [levelDefId], references: [id], onDelete: SetNull)
|
||||||
assignedAgent Customer? @relation("AgentCustomers", fields: [assignedAgentId], references: [id], onDelete: SetNull)
|
assignedAgent Customer? @relation("AgentCustomers", fields: [assignedAgentId], references: [id], onDelete: SetNull)
|
||||||
agentCustomers Customer[] @relation("AgentCustomers")
|
agentCustomers Customer[] @relation("AgentCustomers")
|
||||||
carts Cart[]
|
carts Cart[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
notifications CustomerNotification[]
|
notifications CustomerNotification[]
|
||||||
meetings MeetingInteraction[] @relation("MeetingCustomer")
|
meetings MeetingInteraction[] @relation("MeetingCustomer")
|
||||||
meetingsAsAgent MeetingInteraction[] @relation("MeetingAgent")
|
meetingsAsAgent MeetingInteraction[] @relation("MeetingAgent")
|
||||||
reminders Reminder[] @relation("ReminderOwner")
|
reminders Reminder[] @relation("ReminderOwner")
|
||||||
remindersAbout Reminder[] @relation("ReminderCustomer")
|
remindersAbout Reminder[] @relation("ReminderCustomer")
|
||||||
favorites ProductFavorite[]
|
favorites ProductFavorite[]
|
||||||
catalogRequests CatalogRequest[]
|
catalogRequests CatalogRequest[]
|
||||||
productSuggestions ProductSuggestion[]
|
productSuggestions ProductSuggestion[]
|
||||||
addresses CustomerAddress[]
|
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([companyId])
|
||||||
@@index([appRole])
|
@@index([appRole])
|
||||||
@@ -363,14 +415,15 @@ model Order {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user Customer @relation(fields: [userId], references: [id], onDelete: Restrict)
|
user Customer @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
|
company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
|
||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
checks Check[]
|
checks Check[]
|
||||||
cashReceipt CashPaymentReceipt?
|
cashReceipt CashPaymentReceipt?
|
||||||
shipment Shipment?
|
shipment Shipment?
|
||||||
statusHistory OrderStatusHistory[]
|
statusHistory OrderStatusHistory[]
|
||||||
notifications CustomerNotification[]
|
notifications CustomerNotification[]
|
||||||
|
wonOpportunities SalesOpportunity[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
@@ -620,6 +673,94 @@ model Reminder {
|
|||||||
@@index([date])
|
@@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 {
|
model ProductFavorite {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
customerId String
|
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 { BannersModule } from './banners/banners.module';
|
||||||
import { AddressesModule } from './addresses/addresses.module';
|
import { AddressesModule } from './addresses/addresses.module';
|
||||||
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
|
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
|
||||||
|
import { CrmModule } from './crm/crm.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -50,6 +51,7 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo
|
|||||||
BannersModule,
|
BannersModule,
|
||||||
AddressesModule,
|
AddressesModule,
|
||||||
InternalMessagesModule,
|
InternalMessagesModule,
|
||||||
|
CrmModule,
|
||||||
],
|
],
|
||||||
providers: [
|
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 { Module } from '@nestjs/common';
|
||||||
import { CustomersController } from './customers.controller';
|
import { CustomersController } from './customers.controller';
|
||||||
import { CustomersService } from './customers.service';
|
import { CustomersService } from './customers.service';
|
||||||
|
import { CrmModule } from '../crm/crm.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [CrmModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService],
|
providers: [CustomersService],
|
||||||
exports: [CustomersService],
|
exports: [CustomersService],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../common/utils/phone';
|
} from '../common/utils/phone';
|
||||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||||
import { resolveAgentCodeForRole } from '../common/utils/agent-code';
|
import { resolveAgentCodeForRole } from '../common/utils/agent-code';
|
||||||
|
import { CrmService } from '../crm/crm.service';
|
||||||
|
|
||||||
const customerInclude = {
|
const customerInclude = {
|
||||||
company: { select: { name: true } },
|
company: { select: { name: true } },
|
||||||
@@ -30,7 +31,10 @@ type CustomerWithRelations = Prisma.CustomerGetPayload<{
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crmService: CrmService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(q?: string, appRole?: AppUserRole) {
|
async findAll(q?: string, appRole?: AppUserRole) {
|
||||||
const customers = await this.prisma.customer.findMany({
|
const customers = await this.prisma.customer.findMany({
|
||||||
@@ -60,7 +64,8 @@ export class CustomersService {
|
|||||||
include: customerInclude,
|
include: customerInclude,
|
||||||
});
|
});
|
||||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
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) {
|
async create(dto: CreateCustomerDto) {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { OrdersService } from './orders.service';
|
|||||||
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
|
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { AgentModule } from '../agent/agent.module';
|
import { AgentModule } from '../agent/agent.module';
|
||||||
|
import { CrmModule } from '../crm/crm.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AgentModule],
|
imports: [NotificationsModule, AgentModule, CrmModule],
|
||||||
controllers: [CustomerOrdersController, AdminOrdersController],
|
controllers: [CustomerOrdersController, AdminOrdersController],
|
||||||
providers: [OrdersService, CashInvoiceSchedulerService],
|
providers: [OrdersService, CashInvoiceSchedulerService],
|
||||||
exports: [OrdersService],
|
exports: [OrdersService],
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from '../common/utils/order-transitions';
|
} from '../common/utils/order-transitions';
|
||||||
import { resolveMaxMeterage } from '../common/utils/meterage';
|
import { resolveMaxMeterage } from '../common/utils/meterage';
|
||||||
import { AgentLevelService } from '../agent/agent-level.service';
|
import { AgentLevelService } from '../agent/agent-level.service';
|
||||||
|
import { CrmService } from '../crm/crm.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { eventForStatus } from '../notifications/notification-events';
|
import { eventForStatus } from '../notifications/notification-events';
|
||||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||||
@@ -87,6 +88,7 @@ export class OrdersService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly agentLevelService: AgentLevelService,
|
private readonly agentLevelService: AgentLevelService,
|
||||||
|
private readonly crmService: CrmService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findMyOrders(userId: string) {
|
async findMyOrders(userId: string) {
|
||||||
@@ -202,6 +204,7 @@ export class OrdersService {
|
|||||||
body: `سفارش ${order.orderNumber} با ${order.items.length} قلم و ${order.totalMeterage} متر در انتظار قیمتگذاری است.`,
|
body: `سفارش ${order.orderNumber} با ${order.items.length} قلم و ${order.totalMeterage} متر در انتظار قیمتگذاری است.`,
|
||||||
link: `/orders/${order.id}`,
|
link: `/orders/${order.id}`,
|
||||||
});
|
});
|
||||||
|
void this.crmService.onOrderSubmitted(order.userId, order.id);
|
||||||
|
|
||||||
return this.mapOrder(order);
|
return this.mapOrder(order);
|
||||||
}
|
}
|
||||||
@@ -266,6 +269,8 @@ export class OrdersService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void this.crmService.onPriceConfirmed(order.userId, id);
|
||||||
|
|
||||||
return this.findMyOrder(userId, id);
|
return this.findMyOrder(userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +347,7 @@ export class OrdersService {
|
|||||||
order.id,
|
order.id,
|
||||||
'order_registered_by_admin',
|
'order_registered_by_admin',
|
||||||
);
|
);
|
||||||
|
void this.crmService.onOrderSubmitted(order.userId, order.id);
|
||||||
|
|
||||||
return this.mapOrder(order, true);
|
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);
|
return this.findAdminOrder(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user