697 lines
20 KiB
TypeScript
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;
|
|
}
|