285 lines
9.2 KiB
TypeScript
285 lines
9.2 KiB
TypeScript
import {
|
|||
|
|
BadRequestException,
|
||
|
|
Injectable,
|
||
|
|
NotFoundException,
|
||
|
|
} from '@nestjs/common';
|
||
|
|
import { Prisma, SmsAccount } from '@prisma/client';
|
||
|
|
import { PrismaService } from '../prisma/prisma.service';
|
||
|
|
import { AuditService } from '../audit/audit.service';
|
||
|
|
import { ChargeSmsWalletDto, UpdateSmsAccountDto } from './dto/sms-account.dto';
|
||
|
|
|
||
|
|
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown';
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class SmsService {
|
||
|
|
constructor(
|
||
|
|
private readonly prisma: PrismaService,
|
||
|
|
private readonly audit: AuditService,
|
||
|
|
) {}
|
||
|
|
|
||
|
|
async listTenants() {
|
||
|
|
await this.ensureAccountsForAllTenants();
|
||
|
|
|
||
|
|
const rows = await this.prisma.tenant.findMany({
|
||
|
|
where: { isActive: true },
|
||
|
|
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
|
||
|
|
include: { smsAccount: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
return rows.map((tenant) => {
|
||
|
|
const account = tenant.smsAccount!;
|
||
|
|
return {
|
||
|
|
id: tenant.id,
|
||
|
|
product: tenant.product,
|
||
|
|
slug: tenant.slug,
|
||
|
|
nameFa: tenant.nameFa,
|
||
|
|
nameEn: tenant.nameEn,
|
||
|
|
domain: tenant.domain,
|
||
|
|
avatarUrl: tenant.avatarUrl,
|
||
|
|
isActive: tenant.isActive,
|
||
|
|
isBlocked: account.isBlocked,
|
||
|
|
sms: this.toSummary(account),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async getLowestBalances(limit = 5) {
|
||
|
|
const rows = await this.listTenants();
|
||
|
|
return [...rows]
|
||
|
|
.sort((a, b) => a.sms.balanceRials - b.sms.balanceRials)
|
||
|
|
.slice(0, limit);
|
||
|
|
}
|
||
|
|
|
||
|
|
async getAccount(tenantId: string) {
|
||
|
|
const tenant = await this.requireTenant(tenantId);
|
||
|
|
const account = await this.ensureAccount(tenant.id);
|
||
|
|
const ledgers = await this.prisma.smsLedger.findMany({
|
||
|
|
where: { smsAccountId: account.id },
|
||
|
|
orderBy: { createdAt: 'desc' },
|
||
|
|
take: 20,
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
tenant: {
|
||
|
|
id: tenant.id,
|
||
|
|
product: tenant.product,
|
||
|
|
slug: tenant.slug,
|
||
|
|
nameFa: tenant.nameFa,
|
||
|
|
nameEn: tenant.nameEn,
|
||
|
|
domain: tenant.domain,
|
||
|
|
avatarUrl: tenant.avatarUrl,
|
||
|
|
},
|
||
|
|
account: {
|
||
|
|
id: account.id,
|
||
|
|
balanceRials: account.balanceRials,
|
||
|
|
pricePerPartRials: account.pricePerPartRials,
|
||
|
|
isBlocked: account.isBlocked,
|
||
|
|
alertCriticalRials: account.alertCriticalRials,
|
||
|
|
alertWarningRials: account.alertWarningRials,
|
||
|
|
gatewayProvider: account.gatewayProvider,
|
||
|
|
melipayamakUser: account.melipayamakUser,
|
||
|
|
melipayamakPassword: account.melipayamakPassword,
|
||
|
|
melipayamakFrom: account.melipayamakFrom,
|
||
|
|
panelUsername: account.panelUsername,
|
||
|
|
panelPassword: account.panelPassword,
|
||
|
|
panelFrom: account.panelFrom,
|
||
|
|
extraSettings: (account.extraSettings as Record<string, string>) || {},
|
||
|
|
todayMessages: account.todayMessages,
|
||
|
|
todayParts: account.todayParts,
|
||
|
|
todayCostRials: account.todayCostRials,
|
||
|
|
monthMessages: account.monthMessages,
|
||
|
|
monthParts: account.monthParts,
|
||
|
|
monthCostRials: account.monthCostRials,
|
||
|
|
},
|
||
|
|
summary: this.toSummary(account),
|
||
|
|
ledgers,
|
||
|
|
safeNote:
|
||
|
|
'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمیدهد.',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async updateAccount(tenantId: string, dto: UpdateSmsAccountDto, userId?: string) {
|
||
|
|
const account = await this.ensureAccount(tenantId);
|
||
|
|
const before = { ...account };
|
||
|
|
|
||
|
|
if (
|
||
|
|
dto.alertCriticalRials != null &&
|
||
|
|
dto.alertWarningRials != null &&
|
||
|
|
dto.alertCriticalRials > dto.alertWarningRials
|
||
|
|
) {
|
||
|
|
throw new BadRequestException('آستانه قرمز نباید از آستانه زرد بزرگتر باشد');
|
||
|
|
}
|
||
|
|
|
||
|
|
const updated = await this.prisma.smsAccount.update({
|
||
|
|
where: { id: account.id },
|
||
|
|
data: {
|
||
|
|
pricePerPartRials: dto.pricePerPartRials ?? undefined,
|
||
|
|
isBlocked: dto.isBlocked ?? undefined,
|
||
|
|
alertCriticalRials: dto.alertCriticalRials ?? undefined,
|
||
|
|
alertWarningRials: dto.alertWarningRials ?? undefined,
|
||
|
|
gatewayProvider: dto.gatewayProvider ?? undefined,
|
||
|
|
melipayamakUser: dto.melipayamakUser ?? undefined,
|
||
|
|
melipayamakPassword: dto.melipayamakPassword ?? undefined,
|
||
|
|
melipayamakFrom: dto.melipayamakFrom ?? undefined,
|
||
|
|
panelUsername: dto.panelUsername ?? undefined,
|
||
|
|
panelPassword: dto.panelPassword ?? undefined,
|
||
|
|
panelFrom: dto.panelFrom ?? undefined,
|
||
|
|
extraSettings:
|
||
|
|
dto.extraSettings === undefined
|
||
|
|
? undefined
|
||
|
|
: (dto.extraSettings as Prisma.InputJsonValue),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await this.audit.log({
|
||
|
|
action: 'sms.account.update',
|
||
|
|
entityType: 'sms_account',
|
||
|
|
entityId: updated.id,
|
||
|
|
tenantId,
|
||
|
|
userId,
|
||
|
|
before: this.publicAccount(before) as unknown as Prisma.InputJsonValue,
|
||
|
|
after: this.publicAccount(updated) as unknown as Prisma.InputJsonValue,
|
||
|
|
});
|
||
|
|
|
||
|
|
return this.getAccount(tenantId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async chargeWallet(tenantId: string, dto: ChargeSmsWalletDto, userId?: string) {
|
||
|
|
if (!dto.amountRials || dto.amountRials === 0) {
|
||
|
|
throw new BadRequestException('مبلغ شارژ نامعتبر است');
|
||
|
|
}
|
||
|
|
|
||
|
|
const account = await this.ensureAccount(tenantId);
|
||
|
|
const nextBalance = account.balanceRials + dto.amountRials;
|
||
|
|
if (nextBalance < 0) {
|
||
|
|
throw new BadRequestException('موجودی نمیتواند منفی شود');
|
||
|
|
}
|
||
|
|
|
||
|
|
const type = dto.amountRials > 0 ? 'charge' : 'adjust';
|
||
|
|
|
||
|
|
const [updated] = await this.prisma.$transaction([
|
||
|
|
this.prisma.smsAccount.update({
|
||
|
|
where: { id: account.id },
|
||
|
|
data: { balanceRials: nextBalance },
|
||
|
|
}),
|
||
|
|
this.prisma.smsLedger.create({
|
||
|
|
data: {
|
||
|
|
smsAccountId: account.id,
|
||
|
|
type,
|
||
|
|
amountRials: dto.amountRials,
|
||
|
|
note: dto.note || (dto.amountRials > 0 ? 'شارژ کیف پول' : 'اصلاح موجودی'),
|
||
|
|
createdById: userId,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
|
||
|
|
await this.audit.log({
|
||
|
|
action: 'sms.wallet.charge',
|
||
|
|
entityType: 'sms_account',
|
||
|
|
entityId: account.id,
|
||
|
|
tenantId,
|
||
|
|
userId,
|
||
|
|
after: {
|
||
|
|
amountRials: dto.amountRials,
|
||
|
|
balanceRials: updated.balanceRials,
|
||
|
|
note: dto.note || null,
|
||
|
|
} as unknown as Prisma.InputJsonValue,
|
||
|
|
});
|
||
|
|
|
||
|
|
return this.getAccount(tenantId);
|
||
|
|
}
|
||
|
|
|
||
|
|
async setBlocked(tenantId: string, isBlocked: boolean, userId?: string) {
|
||
|
|
return this.updateAccount(tenantId, { isBlocked }, userId);
|
||
|
|
}
|
||
|
|
|
||
|
|
private async ensureAccountsForAllTenants() {
|
||
|
|
const tenants = await this.prisma.tenant.findMany({
|
||
|
|
where: { smsAccount: null },
|
||
|
|
select: { id: true },
|
||
|
|
});
|
||
|
|
for (const tenant of tenants) {
|
||
|
|
await this.ensureAccount(tenant.id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private async ensureAccount(tenantId: string) {
|
||
|
|
await this.requireTenant(tenantId);
|
||
|
|
const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } });
|
||
|
|
if (existing) return existing;
|
||
|
|
|
||
|
|
// Seed varied demo balances so color cards are visible until real charges exist
|
||
|
|
const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000];
|
||
|
|
const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
|
||
|
|
const balanceRials = demoBalances[hash % demoBalances.length];
|
||
|
|
|
||
|
|
return this.prisma.smsAccount.create({
|
||
|
|
data: {
|
||
|
|
tenantId,
|
||
|
|
balanceRials,
|
||
|
|
pricePerPartRials: 2500,
|
||
|
|
alertCriticalRials: 10000,
|
||
|
|
alertWarningRials: 100000,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private toSummary(account: SmsAccount) {
|
||
|
|
const level = this.balanceLevel(
|
||
|
|
account.balanceRials,
|
||
|
|
account.alertCriticalRials,
|
||
|
|
account.alertWarningRials,
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
balanceRials: account.balanceRials,
|
||
|
|
balanceLabel: this.formatMoney(account.balanceRials),
|
||
|
|
balanceLevel: level,
|
||
|
|
perPartRials: account.pricePerPartRials,
|
||
|
|
todayMessages: account.todayMessages,
|
||
|
|
todayParts: account.todayParts,
|
||
|
|
todayCostRials: account.todayCostRials,
|
||
|
|
monthMessages: account.monthMessages,
|
||
|
|
monthParts: account.monthParts,
|
||
|
|
monthCostRials: account.monthCostRials,
|
||
|
|
isBlocked: account.isBlocked,
|
||
|
|
alertCriticalRials: account.alertCriticalRials,
|
||
|
|
alertWarningRials: account.alertWarningRials,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private balanceLevel(
|
||
|
|
balanceRials: number,
|
||
|
|
critical = 10000,
|
||
|
|
warning = 100000,
|
||
|
|
): BalanceLevel {
|
||
|
|
if (balanceRials < critical) return 'critical';
|
||
|
|
if (balanceRials <= warning) return 'warning';
|
||
|
|
return 'healthy';
|
||
|
|
}
|
||
|
|
|
||
|
|
private formatMoney(value: number) {
|
||
|
|
return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private publicAccount(account: SmsAccount) {
|
||
|
|
return {
|
||
|
|
balanceRials: account.balanceRials,
|
||
|
|
pricePerPartRials: account.pricePerPartRials,
|
||
|
|
isBlocked: account.isBlocked,
|
||
|
|
alertCriticalRials: account.alertCriticalRials,
|
||
|
|
alertWarningRials: account.alertWarningRials,
|
||
|
|
gatewayProvider: account.gatewayProvider,
|
||
|
|
melipayamakUser: account.melipayamakUser,
|
||
|
|
melipayamakFrom: account.melipayamakFrom,
|
||
|
|
panelUsername: account.panelUsername,
|
||
|
|
panelFrom: account.panelFrom,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private async requireTenant(tenantId: string) {
|
||
|
|
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
|
||
|
|
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
|
||
|
|
return tenant;
|
||
|
|
}
|
||
|
|
}
|