Files
CRM_Montzar/apps/api/src/sales/sales.service.ts
T

697 lines
20 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateSalesGoalRequest,
PaginatedResponse,
SalesForecastDto,
SalesForecastKpiDto,
SalesGoalDto,
SalesGoalStatus,
SalesGoalType,
UpdateSalesGoalRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import {
type Actor,
elevated,
forScope,
ownerWhere,
} from '../common/record-scope';
import {
decimalToBase,
loadOrgRates,
type RateMap,
} from '../common/currency';
const include = {
user: { select: { id: true, fullName: true } },
} satisfies Prisma.SalesGoalInclude;
type GoalRow = Prisma.SalesGoalGetPayload<{ include: typeof include }>;
const GOAL_TYPES: SalesGoalType[] = [
'sales',
'customer',
'revenue',
'deal',
'activity',
'quality',
];
const DEFAULT_OPEN_WEIGHT = 0.4;
@Injectable()
export class SalesService {
constructor(private readonly prisma: PrismaService) {}
async listGoals(
actor: Actor,
opts: {
from?: string;
to?: string;
userId?: string;
q?: string;
status?: string;
type?: string;
} = {},
): Promise<PaginatedResponse<SalesGoalDto>> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const where: Prisma.SalesGoalWhereInput = {
organizationId: actor.organizationId,
periodStart: { lte: end },
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
if (opts.type && isGoalType(opts.type)) {
where.type = opts.type;
}
const search = buildSearchWhere<Prisma.SalesGoalWhereInput>(opts.q, (t) => [
{ title: ci(t) },
]);
if (search) where.AND = [search];
const rows = await this.prisma.salesGoal.findMany({
where,
include,
orderBy: [{ periodStart: 'desc' }, { createdAt: 'desc' }],
take: 100,
});
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
let items = await Promise.all(
rows.map((row) => this.toGoalDto(row, rateMap)),
);
if (opts.status && isGoalStatus(opts.status)) {
items = items.filter((g) => g.status === opts.status);
}
return {
items,
total: items.length,
page: 1,
limit: 100,
};
}
async getGoal(actor: Actor, id: string): Promise<SalesGoalDto> {
const row = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
include,
});
if (!row) throw new NotFoundException('هدف پیدا نشد');
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
return this.toGoalDto(row, rateMap);
}
async createGoal(
actor: Actor,
dto: CreateSalesGoalRequest,
): Promise<SalesGoalDto> {
const periodStart = parseDate(dto.periodStart, 'شروع دوره');
const periodEnd = parseDate(dto.periodEnd, 'پایان دوره');
if (periodEnd < periodStart) {
throw new BadRequestException('پایان دوره باید بعد از شروع باشد');
}
const userId = await this.resolveGoalUserId(actor, dto.userId);
const type = normalizeGoalType(dto.type);
const row = await this.prisma.salesGoal.create({
data: {
organizationId: actor.organizationId,
userId,
title: dto.title.trim(),
type,
periodStart,
periodEnd,
targetAmount: new Prisma.Decimal(dto.targetAmount),
forecastAmount:
dto.forecastAmount != null
? new Prisma.Decimal(dto.forecastAmount)
: null,
probability:
dto.probability != null ? new Prisma.Decimal(dto.probability) : null,
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
notes: emptyToNull(dto.notes ?? null),
},
include,
});
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
return this.toGoalDto(row, rateMap);
}
async updateGoal(
actor: Actor,
id: string,
dto: UpdateSalesGoalRequest,
): Promise<SalesGoalDto> {
const existing = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
});
if (!existing) throw new NotFoundException('هدف پیدا نشد');
if (!elevated(actor.role) && existing.userId !== actor.userId) {
throw new ForbiddenException('اجازه ویرایش این هدف را ندارید');
}
const data: Prisma.SalesGoalUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.periodStart !== undefined) {
data.periodStart = parseDate(dto.periodStart, 'شروع دوره');
}
if (dto.periodEnd !== undefined) {
data.periodEnd = parseDate(dto.periodEnd, 'پایان دوره');
}
if (dto.targetAmount !== undefined) {
data.targetAmount = new Prisma.Decimal(dto.targetAmount);
}
if (dto.type !== undefined) data.type = normalizeGoalType(dto.type);
if ('forecastAmount' in dto) {
data.forecastAmount =
dto.forecastAmount != null
? new Prisma.Decimal(dto.forecastAmount)
: null;
}
if ('probability' in dto) {
data.probability =
dto.probability != null ? new Prisma.Decimal(dto.probability) : null;
}
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('userId' in dto) {
data.userId = await this.resolveGoalUserId(actor, dto.userId);
}
const start =
data.periodStart instanceof Date
? data.periodStart
: existing.periodStart;
const end =
data.periodEnd instanceof Date ? data.periodEnd : existing.periodEnd;
if (end < start) {
throw new BadRequestException('پایان دوره باید بعد از شروع باشد');
}
const row = await this.prisma.salesGoal.update({
where: { id },
data,
include,
});
const rateMap = await loadOrgRates(this.prisma, actor.organizationId);
return this.toGoalDto(row, rateMap);
}
async removeGoal(actor: Actor, id: string): Promise<void> {
const existing = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
});
if (!existing) throw new NotFoundException('هدف پیدا نشد');
if (!elevated(actor.role) && existing.userId !== actor.userId) {
throw new ForbiddenException('اجازه حذف این هدف را ندارید');
}
await this.prisma.salesGoal.delete({ where: { id } });
}
async forecast(
actor: Actor,
opts: { from?: string; to?: string; userId?: string } = {},
): Promise<SalesForecastDto> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const dealOwner =
elevated(actor.role) && opts.userId
? { ownerId: opts.userId }
: ownerWhere(actor, 'deals');
const goalsWhere: Prisma.SalesGoalWhereInput = {
organizationId: actor.organizationId,
periodStart: { lte: end },
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
const personOwner =
elevated(actor.role) && opts.userId
? { ownerId: opts.userId }
: ownerWhere(actor);
const [
goals,
wonDeals,
openDeals,
rateMap,
newCustomersCount,
newDealsCount,
] = await Promise.all([
this.prisma.salesGoal.findMany({
where: goalsWhere,
include,
orderBy: { periodStart: 'asc' },
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
status: 'won',
updatedAt: { gte: start, lte: end },
...dealOwner,
},
select: { amount: true, currency: true },
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
status: 'open',
...dealOwner,
},
select: { amount: true, currency: true },
}),
loadOrgRates(this.prisma, actor.organizationId),
this.prisma.person.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: start, lte: end },
...personOwner,
},
}),
this.prisma.deal.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: start, lte: end },
...dealOwner,
},
}),
]);
const goalDtos = await Promise.all(
goals.map((g) => this.toGoalDto(g, rateMap)),
);
const salesGoals = goals.filter((g) => normalizeGoalType(g.type) === 'sales');
const customerGoals = goals.filter(
(g) => normalizeGoalType(g.type) === 'customer',
);
const dealGoals = goals.filter((g) => normalizeGoalType(g.type) === 'deal');
const targetAmount = salesGoals.reduce(
(sum, g) => sum + decimalToBase(g.targetAmount, g.currency, rateMap),
0,
);
const customerTarget = customerGoals.reduce(
(sum, g) => sum + Number(g.targetAmount),
0,
);
const dealTarget = dealGoals.reduce(
(sum, g) => sum + Number(g.targetAmount),
0,
);
const forecastTargets = goals
.map((g) =>
g.forecastAmount != null
? decimalToBase(g.forecastAmount, g.currency, rateMap)
: null,
)
.filter((v): v is number => v != null);
const forecastTarget =
forecastTargets.length > 0
? forecastTargets.reduce((a, b) => a + b, 0)
: targetAmount;
const wonAmount = wonDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const openPipelineAmount = openDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const probs = goals
.map((g) => (g.probability != null ? Number(g.probability) / 100 : null))
.filter((v): v is number => v != null && !Number.isNaN(v));
const avgProbability =
probs.length > 0
? probs.reduce((a, b) => a + b, 0) / probs.length
: DEFAULT_OPEN_WEIGHT;
const forecastAmount = wonAmount + openPipelineAmount * avgProbability;
const progressPercent =
targetAmount > 0
? Math.min(999, Math.round((wonAmount / targetAmount) * 1000) / 10)
: null;
const kpis: SalesForecastKpiDto[] = [
kpi(
'sales',
'هدف فروش ماه جاری',
wonAmount,
targetAmount,
'money',
),
kpi(
'customer',
'تعداد مشتریان جدید',
newCustomersCount,
customerTarget,
'count',
),
kpi('deal', 'تعداد معاملات جدید', newDealsCount, dealTarget, 'count'),
kpi(
'forecast',
'میزان پیش‌بینی درآمد',
forecastAmount,
forecastTarget,
'money',
),
];
return {
periodStart: start.toISOString(),
periodEnd: end.toISOString(),
currency: rateMap.baseCurrency,
targetAmount,
wonAmount,
openPipelineAmount,
forecastAmount,
progressPercent,
openDealsCount: openDeals.length,
wonDealsCount: wonDeals.length,
goals: goalDtos,
kpis,
newCustomersCount,
newDealsCount,
};
}
private goalVisibility(
baseActor: Actor,
filterUserId?: string,
): Prisma.SalesGoalWhereInput {
const actor = forScope(baseActor, 'deals');
if (elevated(actor.role) || actor.dataVisibility === 'org' || actor.visibleUserIds === null) {
if (filterUserId === 'org') return { userId: null };
if (filterUserId) return { userId: filterUserId };
return {};
}
const ids = actor.visibleUserIds?.length
? actor.visibleUserIds
: [actor.userId];
return {
OR: [{ userId: { in: ids } }, { userId: null }],
};
}
private async resolveGoalUserId(
actor: Actor,
requested?: string | null,
): Promise<string | null> {
if (!elevated(actor.role)) {
return actor.userId;
}
if (requested === undefined) return null;
if (requested == null || requested === '' || requested === 'org') {
return null;
}
const user = await this.prisma.user.findFirst({
where: {
id: requested,
memberships: { some: { organizationId: actor.organizationId } },
},
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر هدف معتبر نیست');
return user.id;
}
private async toGoalDto(
row: GoalRow,
rateMap: RateMap,
): Promise<SalesGoalDto> {
const currentAmount = await this.computeGoalCurrent(row, rateMap);
const target = Number(row.targetAmount);
const progressPercent = computeProgressPercent(currentAmount, target);
const status = computeGoalStatus(
progressPercent,
row.periodStart,
row.periodEnd,
);
return {
id: row.id,
title: row.title,
type: normalizeGoalType(row.type),
periodStart: row.periodStart.toISOString(),
periodEnd: row.periodEnd.toISOString(),
targetAmount: target,
forecastAmount:
row.forecastAmount != null ? Number(row.forecastAmount) : null,
probability: row.probability != null ? Number(row.probability) : null,
currency: row.currency,
notes: row.notes,
user: row.user,
currentAmount,
progressPercent,
status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
private async computeGoalCurrent(
goal: GoalRow,
rateMap: RateMap,
): Promise<number> {
const type = normalizeGoalType(goal.type);
const orgId = goal.organizationId;
const start = goal.periodStart;
const end = goal.periodEnd;
const scopeUserId = goal.userId;
switch (type) {
case 'sales': {
const deals = await this.prisma.deal.findMany({
where: {
organizationId: orgId,
status: 'won',
updatedAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
select: { amount: true, currency: true },
});
const base = deals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
return fromBaseToCurrency(base, goal.currency, rateMap);
}
case 'revenue': {
const payments = await this.prisma.payment.findMany({
where: {
paidAt: { gte: start, lte: end },
invoice: {
organizationId: orgId,
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
},
select: { amount: true, currency: true },
});
const base = payments.reduce(
(sum, p) => sum + decimalToBase(p.amount, p.currency, rateMap),
0,
);
return fromBaseToCurrency(base, goal.currency, rateMap);
}
case 'customer': {
return this.prisma.person.count({
where: {
organizationId: orgId,
createdAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
});
}
case 'deal': {
return this.prisma.deal.count({
where: {
organizationId: orgId,
createdAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
},
});
}
case 'activity': {
return this.prisma.activity.count({
where: {
organizationId: orgId,
status: 'done',
OR: [
{ completedAt: { gte: start, lte: end } },
{
AND: [
{ completedAt: null },
{ updatedAt: { gte: start, lte: end } },
],
},
],
...(scopeUserId ? { assigneeId: scopeUserId } : {}),
},
});
}
case 'quality': {
const whereBase: Prisma.DealWhereInput = {
organizationId: orgId,
status: { in: ['won', 'lost'] },
updatedAt: { gte: start, lte: end },
...(scopeUserId ? { ownerId: scopeUserId } : {}),
};
const [won, lost] = await Promise.all([
this.prisma.deal.count({
where: { ...whereBase, status: 'won' },
}),
this.prisma.deal.count({
where: { ...whereBase, status: 'lost' },
}),
]);
const closed = won + lost;
if (closed === 0) return 0;
return Math.round((won / closed) * 1000) / 10;
}
default:
return 0;
}
}
}
function kpi(
key: SalesForecastKpiDto['key'],
label: string,
current: number,
target: number,
unit: SalesForecastKpiDto['unit'],
): SalesForecastKpiDto {
return {
key,
label,
current,
target,
percent: computeProgressPercent(current, target),
unit,
};
}
function computeProgressPercent(current: number, target: number): number {
if (!(target > 0)) return 0;
return Math.min(999, Math.round((current / target) * 1000) / 10);
}
function computeGoalStatus(
progressPercent: number,
periodStart: Date,
periodEnd: Date,
now = new Date(),
): SalesGoalStatus {
if (progressPercent >= 100) return 'completed';
if (now > periodEnd && progressPercent < 100) return 'needs_attention';
const totalMs = periodEnd.getTime() - periodStart.getTime();
if (totalMs > 0) {
const elapsed = Math.min(
1,
Math.max(0, (now.getTime() - periodStart.getTime()) / totalMs),
);
if (elapsed > 0.66 && progressPercent < elapsed * 100 * 0.5) {
return 'needs_attention';
}
}
return 'in_progress';
}
function fromBaseToCurrency(
baseAmount: number,
currency: string,
rateMap: RateMap,
): number {
const code = (currency || rateMap.baseCurrency).trim() || rateMap.baseCurrency;
if (code === rateMap.baseCurrency) return baseAmount;
const rate = rateMap.rates.get(code);
if (rate == null || rate <= 0) return baseAmount;
return baseAmount / rate;
}
function normalizeGoalType(value?: string | null): SalesGoalType {
if (value && isGoalType(value)) return value;
return 'sales';
}
function isGoalType(value: string): value is SalesGoalType {
return (GOAL_TYPES as string[]).includes(value);
}
function isGoalStatus(value: string): value is SalesGoalStatus {
return (
value === 'in_progress' ||
value === 'completed' ||
value === 'needs_attention'
);
}
function resolvePeriod(from?: string, to?: string): { start: Date; end: Date } {
if (from || to) {
const start = from
? parseDate(from, 'از تاریخ')
: startOfMonth(new Date());
const end = to ? endOfDay(parseDate(to, 'تا تاریخ')) : endOfMonth(start);
return { start, end };
}
const now = new Date();
return { start: startOfMonth(now), end: endOfMonth(now) };
}
function parseDate(value: string, label: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
throw new BadRequestException(`${label} معتبر نیست`);
}
return d;
}
function startOfMonth(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
}
function endOfMonth(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0, 23, 59, 59, 999),
);
}
function endOfDay(d: Date): Date {
const x = new Date(d);
x.setUTCHours(23, 59, 59, 999);
return x;
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}