feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsAccount" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"balanceRials" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"pricePerPartRials" INTEGER NOT NULL DEFAULT 2500,
|
||||||
|
"isBlocked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"autoBlockBelowRials" INTEGER,
|
||||||
|
"alertCriticalRials" INTEGER NOT NULL DEFAULT 10000,
|
||||||
|
"alertWarningRials" INTEGER NOT NULL DEFAULT 100000,
|
||||||
|
"panelMode" TEXT NOT NULL DEFAULT 'platform',
|
||||||
|
"ownPanelType" TEXT NOT NULL DEFAULT '0098',
|
||||||
|
"ownPanelUsername" TEXT,
|
||||||
|
"ownPanelPassword" TEXT,
|
||||||
|
"ownPanelFrom" TEXT,
|
||||||
|
"chargeCard" TEXT,
|
||||||
|
"chargeIban" TEXT,
|
||||||
|
"chargeHolder" TEXT,
|
||||||
|
"chargeInstruction" TEXT,
|
||||||
|
"alertDaysSent" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastAlertDate" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsLedger" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"smsAccountId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"amountRials" INTEGER NOT NULL,
|
||||||
|
"balanceAfterRials" INTEGER NOT NULL,
|
||||||
|
"parts" INTEGER,
|
||||||
|
"pricePerPartRials" INTEGER,
|
||||||
|
"note" TEXT,
|
||||||
|
"smsSendLogId" TEXT,
|
||||||
|
"createdById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsLedger_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SmsBillingSendLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"smsAccountId" TEXT NOT NULL,
|
||||||
|
"receiver" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"parts" INTEGER NOT NULL,
|
||||||
|
"pricePerPartRials" INTEGER NOT NULL,
|
||||||
|
"totalAmountRials" INTEGER NOT NULL,
|
||||||
|
"provider" TEXT,
|
||||||
|
"source" TEXT,
|
||||||
|
"isAlert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "SmsBillingSendLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SmsAccount_organizationId_key" ON "SmsAccount"("organizationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsAccount_isBlocked_idx" ON "SmsAccount"("isBlocked");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsAccount_balanceRials_idx" ON "SmsAccount"("balanceRials");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsLedger_smsAccountId_createdAt_idx" ON "SmsLedger"("smsAccountId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsLedger_type_idx" ON "SmsLedger"("type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsBillingSendLog_smsAccountId_createdAt_idx" ON "SmsBillingSendLog"("smsAccountId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "SmsBillingSendLog_source_idx" ON "SmsBillingSendLog"("source");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SmsAccount" ADD CONSTRAINT "SmsAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SmsLedger" ADD CONSTRAINT "SmsLedger_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "SmsBillingSendLog" ADD CONSTRAINT "SmsBillingSendLog_smsAccountId_fkey" FOREIGN KEY ("smsAccountId") REFERENCES "SmsAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -206,6 +206,7 @@ model Organization {
|
|||||||
webhookEndpoints WebhookEndpoint[]
|
webhookEndpoints WebhookEndpoint[]
|
||||||
smsCampaigns SmsCampaign[]
|
smsCampaigns SmsCampaign[]
|
||||||
outboundSmsLogs OutboundSmsLog[]
|
outboundSmsLogs OutboundSmsLog[]
|
||||||
|
smsAccount SmsAccount?
|
||||||
financialAccounts FinancialAccount[]
|
financialAccounts FinancialAccount[]
|
||||||
financialCategories FinancialCategory[]
|
financialCategories FinancialCategory[]
|
||||||
financialDocuments FinancialDocument[]
|
financialDocuments FinancialDocument[]
|
||||||
@@ -2290,6 +2291,78 @@ model FinancialInstallment {
|
|||||||
@@index([dueDate])
|
@@index([dueDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// کیفپول و تنظیمات پیامک هر محیط (الگوی همکار / سوپرادمین آبشُده)
|
||||||
|
model SmsAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String @unique
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
balanceRials Int @default(0)
|
||||||
|
pricePerPartRials Int @default(2500)
|
||||||
|
isBlocked Boolean @default(false)
|
||||||
|
/// اگر ست باشد: وقتی موجودی < این مقدار، ارسال قطع میشود (میتواند منفی باشد). null = فقط کلید دستی
|
||||||
|
autoBlockBelowRials Int?
|
||||||
|
alertCriticalRials Int @default(10000)
|
||||||
|
alertWarningRials Int @default(100000)
|
||||||
|
/// platform = سامانهٔ ما | own = پنل اختصاصی مشتری (بدون کسر/لاگ کیف)
|
||||||
|
panelMode String @default("platform")
|
||||||
|
/// وقتی panelMode=own: 0098 | asanak | trez
|
||||||
|
ownPanelType String @default("0098")
|
||||||
|
ownPanelUsername String?
|
||||||
|
ownPanelPassword String?
|
||||||
|
ownPanelFrom String?
|
||||||
|
chargeCard String?
|
||||||
|
chargeIban String?
|
||||||
|
chargeHolder String?
|
||||||
|
chargeInstruction String?
|
||||||
|
alertDaysSent Int @default(0)
|
||||||
|
lastAlertDate DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
ledgers SmsLedger[]
|
||||||
|
sendLogs SmsBillingSendLog[]
|
||||||
|
|
||||||
|
@@index([isBlocked])
|
||||||
|
@@index([balanceRials])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SmsLedger {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
smsAccountId String
|
||||||
|
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
|
||||||
|
/// charge | debit | alert_debit
|
||||||
|
type String
|
||||||
|
amountRials Int
|
||||||
|
balanceAfterRials Int
|
||||||
|
parts Int?
|
||||||
|
pricePerPartRials Int?
|
||||||
|
note String?
|
||||||
|
smsSendLogId String?
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([smsAccountId, createdAt])
|
||||||
|
@@index([type])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// لاگ ارسال صورتحسابشده (همهٔ مسیرها وقتی panelMode=platform)
|
||||||
|
model SmsBillingSendLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
smsAccountId String
|
||||||
|
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
|
||||||
|
receiver String
|
||||||
|
body String
|
||||||
|
parts Int
|
||||||
|
pricePerPartRials Int
|
||||||
|
totalAmountRials Int
|
||||||
|
provider String?
|
||||||
|
source String?
|
||||||
|
isAlert Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([smsAccountId, createdAt])
|
||||||
|
@@index([source])
|
||||||
|
}
|
||||||
|
|
||||||
/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون)
|
/// لاگ یکپارچهٔ پیامک خروجی (دستی / کمپین / اتوماسیون)
|
||||||
model OutboundSmsLog {
|
model OutboundSmsLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { GamificationModule } from './gamification/gamification.module';
|
|||||||
import { DeletionsModule } from './deletions/deletions.module';
|
import { DeletionsModule } from './deletions/deletions.module';
|
||||||
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
|
||||||
import { BehaviorModule } from './behavior/behavior.module';
|
import { BehaviorModule } from './behavior/behavior.module';
|
||||||
|
import { SmsBillingModule } from './sms-billing/sms-billing.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -63,6 +64,7 @@ import { BehaviorModule } from './behavior/behavior.module';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
AiModule,
|
AiModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SmsBillingModule,
|
||||||
OutboundSmsModule,
|
OutboundSmsModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { AttendanceService } from './attendance.service';
|
import { AttendanceService } from './attendance.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [AttendanceService, SmsService],
|
providers: [AttendanceService],
|
||||||
exports: [AttendanceService],
|
exports: [AttendanceService],
|
||||||
})
|
})
|
||||||
export class AttendanceModule {}
|
export class AttendanceModule {}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import {
|
import {
|
||||||
loggedInAfterCutoff,
|
loggedInAfterCutoff,
|
||||||
tehranClock,
|
tehranClock,
|
||||||
@@ -29,7 +29,7 @@ export class AttendanceService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
|
async recordLogin(organizationId: string, userId: string, role: string, at = new Date()) {
|
||||||
@@ -185,6 +185,11 @@ export class AttendanceService {
|
|||||||
: input.kind === 'forced_logout'
|
: input.kind === 'forced_logout'
|
||||||
? `${name} در ساعت تعیینشده از منتکت خارج شد. ساعت ${clock}`
|
? `${name} در ساعت تعیینشده از منتکت خارج شد. ساعت ${clock}`
|
||||||
: `${name} از منتکت خارج شد. ساعت ${clock}`;
|
: `${name} از منتکت خارج شد. ساعت ${clock}`;
|
||||||
await this.sms.sendSmsDetailed(owner.user.mobile, text);
|
await this.sms.send({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
phone: owner.user.mobile,
|
||||||
|
text,
|
||||||
|
source: 'attendance',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { AuthController } from './auth.controller';
|
|||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
import { RolesGuard } from './roles.guard';
|
import { RolesGuard } from './roles.guard';
|
||||||
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
|
import { ViewAsReadOnlyInterceptor } from './view-as-readonly.interceptor';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -37,7 +36,6 @@ import { SmsService } from '../common/sms.service';
|
|||||||
AuthService,
|
AuthService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RolesGuard,
|
RolesGuard,
|
||||||
SmsService,
|
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: ViewAsReadOnlyInterceptor,
|
useClass: ViewAsReadOnlyInterceptor,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
|
||||||
import type {} from 'multer';
|
import type {} from 'multer';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||||
import { MembershipsService } from '../memberships/memberships.service';
|
import { MembershipsService } from '../memberships/memberships.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
@@ -107,7 +107,7 @@ export class AuthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly memberships: MembershipsService,
|
private readonly memberships: MembershipsService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
@@ -174,7 +174,7 @@ export class AuthService {
|
|||||||
if (emailOtpEnabled && emailCode) {
|
if (emailOtpEnabled && emailCode) {
|
||||||
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
await this.dispatchEmailOtp(email, emailCode, returnOtp);
|
||||||
}
|
}
|
||||||
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp);
|
await this.dispatchMobileOtp(mobile, mobileCode, returnOtp, null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
challengeId: challenge.id,
|
challengeId: challenge.id,
|
||||||
@@ -353,7 +353,7 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.dispatchMobileOtp(mobile, code, returnOtp);
|
await this.dispatchMobileOtp(mobile, code, returnOtp, user.organizationId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
challengeId: challenge.id,
|
challengeId: challenge.id,
|
||||||
@@ -875,9 +875,10 @@ export class AuthService {
|
|||||||
mobile: string,
|
mobile: string,
|
||||||
code: string,
|
code: string,
|
||||||
returnOtp: boolean,
|
returnOtp: boolean,
|
||||||
|
organizationId: string | null,
|
||||||
) {
|
) {
|
||||||
if (this.sms.isConfigured()) {
|
if (this.sms.isConfigured() || organizationId) {
|
||||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
const sent = await this.sms.sendOtp(organizationId, mobile, code);
|
||||||
if (!sent && !returnOtp) {
|
if (!sent && !returnOtp) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
import { LoyaltyModule } from '../loyalty/loyalty.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { AutomationsController } from './automations.controller';
|
import { AutomationsController } from './automations.controller';
|
||||||
import { AutomationsService } from './automations.service';
|
import { AutomationsService } from './automations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, NotificationsModule, LoyaltyModule],
|
imports: [AuthModule, NotificationsModule, LoyaltyModule],
|
||||||
controllers: [AutomationsController],
|
controllers: [AutomationsController],
|
||||||
providers: [AutomationsService, SmsService],
|
providers: [AutomationsService],
|
||||||
exports: [AutomationsService],
|
exports: [AutomationsService],
|
||||||
})
|
})
|
||||||
export class AutomationsModule {}
|
export class AutomationsModule {}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import {
|
import {
|
||||||
formatFormAnswers,
|
formatFormAnswers,
|
||||||
pickRoundRobin,
|
pickRoundRobin,
|
||||||
@@ -79,7 +79,7 @@ export class AutomationsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly loyalty: LoyaltyService,
|
private readonly loyalty: LoyaltyService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
async list(organizationId: string): Promise<AutomationRuleDto[]> {
|
||||||
@@ -537,7 +537,11 @@ export class AutomationsService {
|
|||||||
select: { mobile: true, phone: true },
|
select: { mobile: true, phone: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
await this.sendAutomationSms(person?.mobile || person?.phone, smsText);
|
await this.sendAutomationSms(
|
||||||
|
ctx.organizationId,
|
||||||
|
person?.mobile || person?.phone,
|
||||||
|
smsText,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -570,11 +574,12 @@ export class AutomationsService {
|
|||||||
},
|
},
|
||||||
select: { mobile: true },
|
select: { mobile: true },
|
||||||
});
|
});
|
||||||
await this.sendAutomationSms(user?.mobile, smsText);
|
await this.sendAutomationSms(ctx.organizationId, user?.mobile, smsText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendAutomationSms(
|
private async sendAutomationSms(
|
||||||
|
organizationId: string,
|
||||||
mobile: string | null | undefined,
|
mobile: string | null | undefined,
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
@@ -582,11 +587,12 @@ export class AutomationsService {
|
|||||||
this.logger.warn('Automation SMS skipped: no mobile');
|
this.logger.warn('Automation SMS skipped: no mobile');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.sms.isConfigured()) {
|
const result = await this.sms.send({
|
||||||
this.logger.warn('Automation SMS skipped: SMS not configured');
|
organizationId,
|
||||||
return;
|
phone: mobile,
|
||||||
}
|
text,
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
source: 'automation',
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
|
this.logger.warn(`Automation SMS failed: ${result.reason ?? 'unknown'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,19 @@ const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
|
|||||||
|
|
||||||
export type SmsSendResult = {
|
export type SmsSendResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
transport: 'trez' | 'soap' | 'link';
|
blocked?: boolean;
|
||||||
|
transport: 'trez' | 'soap' | 'link' | 'none';
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
parts?: number;
|
||||||
|
costRials?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmsTransportOverride = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
sender: string;
|
||||||
|
/** trez | 0098 — default 0098 for own panels */
|
||||||
|
provider?: 'trez' | '0098';
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,17 +61,29 @@ export class SmsService {
|
|||||||
return result.ok;
|
return result.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
|
buildOtpText(code: string): string {
|
||||||
const username = this.config.get<string>('SMS_USERNAME');
|
const template =
|
||||||
const password = this.getPassword();
|
this.config.get<string>('SMS_OTP_TEMPLATE')?.trim() ||
|
||||||
const sender = this.config.get<string>('SMS_SENDER');
|
`کد ورود منتکت: {code}`;
|
||||||
|
return template.replace('{code}', code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendSmsDetailed(
|
||||||
|
phone: string,
|
||||||
|
text: string,
|
||||||
|
override?: SmsTransportOverride,
|
||||||
|
): Promise<SmsSendResult> {
|
||||||
|
const username = override?.username || this.config.get<string>('SMS_USERNAME');
|
||||||
|
const password = override?.password || this.getPassword();
|
||||||
|
const sender = override?.sender || this.config.get<string>('SMS_SENDER');
|
||||||
|
|
||||||
if (!username || !password || !sender) {
|
if (!username || !password || !sender) {
|
||||||
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
|
return { ok: false, transport: 'none', reason: 'SMS not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const to = this.normalizePhoneForSms(phone);
|
const to = this.normalizePhoneForSms(phone);
|
||||||
const provider = this.resolveProvider();
|
const provider =
|
||||||
|
override?.provider ?? this.resolveProvider();
|
||||||
|
|
||||||
if (provider === 'trez') {
|
if (provider === 'trez') {
|
||||||
return this.sendViaTrez(username, password, sender, to, text);
|
return this.sendViaTrez(username, password, sender, to, text);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { CompaniesService } from './companies.service';
|
|||||||
import { PeopleController } from './people.controller';
|
import { PeopleController } from './people.controller';
|
||||||
import { PeopleService } from './people.service';
|
import { PeopleService } from './people.service';
|
||||||
import { ContactsExcelService } from './contacts-excel.service';
|
import { ContactsExcelService } from './contacts-excel.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { BehaviorModule } from '../behavior/behavior.module';
|
import { BehaviorModule } from '../behavior/behavior.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -20,7 +19,7 @@ import { BehaviorModule } from '../behavior/behavior.module';
|
|||||||
BehaviorModule,
|
BehaviorModule,
|
||||||
],
|
],
|
||||||
controllers: [PeopleController, CompaniesController],
|
controllers: [PeopleController, CompaniesController],
|
||||||
providers: [PeopleService, CompaniesService, ContactsExcelService, SmsService],
|
providers: [PeopleService, CompaniesService, ContactsExcelService],
|
||||||
exports: [PeopleService, CompaniesService],
|
exports: [PeopleService, CompaniesService],
|
||||||
})
|
})
|
||||||
export class ContactsModule {}
|
export class ContactsModule {}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { MessageAutomationsService } from '../message-automations/message-automa
|
|||||||
import { WebhooksService } from '../public-api/webhooks.service';
|
import { WebhooksService } from '../public-api/webhooks.service';
|
||||||
import { GamificationService } from '../gamification/gamification.service';
|
import { GamificationService } from '../gamification/gamification.service';
|
||||||
import { parseBirthDate } from '../common/excel';
|
import { parseBirthDate } from '../common/excel';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { selectMonthBirthdays } from './birthday-month';
|
import { selectMonthBirthdays } from './birthday-month';
|
||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText } from '../common/sms-text';
|
||||||
import { normalizeTags } from '../common/tags';
|
import { normalizeTags } from '../common/tags';
|
||||||
@@ -131,7 +131,7 @@ export class PeopleService implements OnModuleInit {
|
|||||||
private readonly messageAutomations: MessageAutomationsService,
|
private readonly messageAutomations: MessageAutomationsService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
private readonly behaviorAccess: BehaviorAccessService,
|
private readonly behaviorAccess: BehaviorAccessService,
|
||||||
) {}
|
) {}
|
||||||
@@ -662,7 +662,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
company: person.company?.name ?? '',
|
company: person.company?.name ?? '',
|
||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text: message,
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
personId: person.id,
|
personId: person.id,
|
||||||
@@ -675,9 +680,11 @@ export class PeopleService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.blocked
|
||||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
: result.reason === 'SMS not configured'
|
||||||
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||||
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -698,7 +705,12 @@ export class PeopleService implements OnModuleInit {
|
|||||||
company: person.company?.name ?? '',
|
company: person.company?.name ?? '',
|
||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text: message,
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
personId: person.id,
|
personId: person.id,
|
||||||
@@ -711,9 +723,11 @@ export class PeopleService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.blocked
|
||||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
: result.reason === 'SMS not configured'
|
||||||
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||||
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Cron } from '@nestjs/schedule';
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import {
|
import {
|
||||||
CHEQUE_REMINDER_DAYS,
|
CHEQUE_REMINDER_DAYS,
|
||||||
isDueInDays,
|
isDueInDays,
|
||||||
@@ -18,7 +18,7 @@ export class ChequeRemindersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron('0 8 * * *', { timeZone: 'Asia/Tehran' })
|
@Cron('0 8 * * *', { timeZone: 'Asia/Tehran' })
|
||||||
@@ -134,20 +134,27 @@ export class ChequeRemindersService {
|
|||||||
dedupe: true,
|
dedupe: true,
|
||||||
});
|
});
|
||||||
if (user.mobile) {
|
if (user.mobile) {
|
||||||
await this.sms.sendSmsDetailed(user.mobile, staffText);
|
await this.sms.send({
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
phone: user.mobile,
|
||||||
|
text: staffText,
|
||||||
|
source: 'cheque_reminder',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incoming && row.person?.mobile) {
|
if (incoming && row.person?.mobile) {
|
||||||
await this.sms.sendSmsDetailed(
|
await this.sms.send({
|
||||||
row.person.mobile,
|
organizationId: row.organizationId,
|
||||||
personIncomingSms({
|
phone: row.person.mobile,
|
||||||
|
text: personIncomingSms({
|
||||||
number: row.number,
|
number: row.number,
|
||||||
amount,
|
amount,
|
||||||
currency: row.currency,
|
currency: row.currency,
|
||||||
dueDate: row.dueDate,
|
dueDate: row.dueDate,
|
||||||
}),
|
}),
|
||||||
);
|
source: 'cheque_reminder',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { TreasuryModule } from '../treasury/treasury.module';
|
import { TreasuryModule } from '../treasury/treasury.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { ChequesService } from './cheques.service';
|
import { ChequesService } from './cheques.service';
|
||||||
import { ChequeRemindersService } from './cheque-reminders.service';
|
import { ChequeRemindersService } from './cheque-reminders.service';
|
||||||
import { CommissionsService } from './commissions.service';
|
import { CommissionsService } from './commissions.service';
|
||||||
@@ -11,7 +10,7 @@ import { FinanceController } from './finance.controller';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, NotificationsModule, TreasuryModule],
|
imports: [AuthModule, NotificationsModule, TreasuryModule],
|
||||||
controllers: [FinanceController],
|
controllers: [FinanceController],
|
||||||
providers: [ChequesService, ChequeRemindersService, CommissionsService, SmsService],
|
providers: [ChequesService, ChequeRemindersService, CommissionsService],
|
||||||
exports: [ChequesService, CommissionsService],
|
exports: [ChequesService, CommissionsService],
|
||||||
})
|
})
|
||||||
export class FinanceModule {}
|
export class FinanceModule {}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { LeadStagesService } from './lead-stages.service';
|
|||||||
import { LeadsController } from './leads.controller';
|
import { LeadsController } from './leads.controller';
|
||||||
import { LeadsImportService } from './leads-import.service';
|
import { LeadsImportService } from './leads-import.service';
|
||||||
import { LeadsService } from './leads.service';
|
import { LeadsService } from './leads.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BillingModule],
|
imports: [AuthModule, BillingModule],
|
||||||
controllers: [LeadsController],
|
controllers: [LeadsController],
|
||||||
providers: [LeadsService, LeadStagesService, LeadsImportService, SmsService],
|
providers: [LeadsService, LeadStagesService, LeadsImportService],
|
||||||
exports: [LeadsService, LeadStagesService],
|
exports: [LeadsService, LeadStagesService],
|
||||||
})
|
})
|
||||||
export class LeadsModule {}
|
export class LeadsModule {}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { FILTERED_IDS_MAX } from '@mento/shared';
|
import { FILTERED_IDS_MAX } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText } from '../common/sms-text';
|
||||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import {
|
import {
|
||||||
@@ -87,7 +87,7 @@ export class LeadsService {
|
|||||||
private readonly stages: LeadStagesService,
|
private readonly stages: LeadStagesService,
|
||||||
private readonly files: LocalFileStorage,
|
private readonly files: LocalFileStorage,
|
||||||
private readonly game: GamificationService,
|
private readonly game: GamificationService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -1291,7 +1291,12 @@ export class LeadsService {
|
|||||||
company: lead.contactName ? lead.title : '',
|
company: lead.contactName ? lead.title : '',
|
||||||
});
|
});
|
||||||
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
if (!message) throw new BadRequestException('متن پیامک خالی است.');
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, message);
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text: message,
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
leadId: id,
|
leadId: id,
|
||||||
@@ -1304,9 +1309,11 @@ export class LeadsService {
|
|||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
result.reason === 'SMS not configured'
|
result.blocked
|
||||||
? 'ارسال پیامک روی سرور فعال نیست.'
|
? 'ارسال پیامک بهخاطر محدودیت اعتبار مسدود است.'
|
||||||
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
: result.reason === 'SMS not configured'
|
||||||
|
? 'ارسال پیامک روی سرور فعال نیست.'
|
||||||
|
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message);
|
await this.addEvent(actor, id, 'note', 'پیامک ارسال شد', message);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { LoyaltyController } from './loyalty.controller';
|
import { LoyaltyController } from './loyalty.controller';
|
||||||
import { LoyaltyService } from './loyalty.service';
|
import { LoyaltyService } from './loyalty.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [LoyaltyController],
|
controllers: [LoyaltyController],
|
||||||
providers: [LoyaltyService, SmsService],
|
providers: [LoyaltyService],
|
||||||
exports: [LoyaltyService],
|
exports: [LoyaltyService],
|
||||||
})
|
})
|
||||||
export class LoyaltyModule {}
|
export class LoyaltyModule {}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { buildSearchWhere, ci } from '../common/search';
|
import { buildSearchWhere, ci } from '../common/search';
|
||||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
|
|
||||||
const DEFAULT_TIERS = [
|
const DEFAULT_TIERS = [
|
||||||
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
|
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
|
||||||
@@ -57,7 +57,7 @@ const DAY_MS = 86_400_000;
|
|||||||
export class LoyaltyService {
|
export class LoyaltyService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ensureDefaults(organizationId: string): Promise<void> {
|
async ensureDefaults(organizationId: string): Promise<void> {
|
||||||
@@ -447,10 +447,12 @@ export class LoyaltyService {
|
|||||||
const settings = await this.ensureSettings(actor.organizationId);
|
const settings = await this.ensureSettings(actor.organizationId);
|
||||||
if (settings.notifyOnAdjust && updated.mobile) {
|
if (settings.notifyOnAdjust && updated.mobile) {
|
||||||
const sign = delta > 0 ? '+' : '';
|
const sign = delta > 0 ? '+' : '';
|
||||||
void this.sms.sendSmsDetailed(
|
void this.sms.send({
|
||||||
updated.mobile,
|
organizationId: actor.organizationId,
|
||||||
`باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
|
phone: updated.mobile,
|
||||||
);
|
text: `باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
|
||||||
|
source: 'loyalty',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -504,7 +506,12 @@ export class LoyaltyService {
|
|||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const result = await this.sms.sendSmsDetailed(p.mobile, body);
|
const result = await this.sms.send({
|
||||||
|
organizationId: actor.organizationId,
|
||||||
|
phone: p.mobile,
|
||||||
|
text: body,
|
||||||
|
source: 'loyalty',
|
||||||
|
});
|
||||||
if (result.ok) sent += 1;
|
if (result.ok) sent += 1;
|
||||||
else failed += 1;
|
else failed += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { MessageAutomationsController } from './message-automations.controller';
|
import { MessageAutomationsController } from './message-automations.controller';
|
||||||
import { MessageAutomationsService } from './message-automations.service';
|
import { MessageAutomationsService } from './message-automations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, NotificationsModule],
|
imports: [AuthModule, NotificationsModule],
|
||||||
controllers: [MessageAutomationsController],
|
controllers: [MessageAutomationsController],
|
||||||
providers: [MessageAutomationsService, SmsService],
|
providers: [MessageAutomationsService],
|
||||||
exports: [MessageAutomationsService],
|
exports: [MessageAutomationsService],
|
||||||
})
|
})
|
||||||
export class MessageAutomationsModule {}
|
export class MessageAutomationsModule {}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { dateToJalali, jalaliMonthName } from '../common/jalali';
|
import { dateToJalali, jalaliMonthName } from '../common/jalali';
|
||||||
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +47,7 @@ export class MessageAutomationsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -397,7 +397,10 @@ export class MessageAutomationsService {
|
|||||||
},
|
},
|
||||||
select: { mobile: true },
|
select: { mobile: true },
|
||||||
});
|
});
|
||||||
await this.sendSms(user?.mobile, smsText);
|
await this.sendSms(user?.mobile, smsText, {
|
||||||
|
organizationId: job.organizationId,
|
||||||
|
ruleId: job.ruleId || job.rule.id || '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +416,7 @@ export class MessageAutomationsService {
|
|||||||
private async sendSms(
|
private async sendSms(
|
||||||
mobile: string | null | undefined,
|
mobile: string | null | undefined,
|
||||||
text: string,
|
text: string,
|
||||||
log?: {
|
log: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
personId?: string | null;
|
personId?: string | null;
|
||||||
ruleId: string;
|
ruleId: string;
|
||||||
@@ -423,12 +426,13 @@ export class MessageAutomationsService {
|
|||||||
this.logger.warn('Message automation SMS skipped: no mobile');
|
this.logger.warn('Message automation SMS skipped: no mobile');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.sms.isConfigured()) {
|
const result = await this.sms.send({
|
||||||
this.logger.warn('Message automation SMS skipped: SMS not configured');
|
organizationId: log.organizationId,
|
||||||
return;
|
phone: mobile,
|
||||||
}
|
text,
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
source: 'automation',
|
||||||
if (log?.personId) {
|
});
|
||||||
|
if (log.personId) {
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: log.organizationId,
|
organizationId: log.organizationId,
|
||||||
personId: log.personId,
|
personId: log.personId,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController],
|
||||||
providers: [NotificationsService, SmsService],
|
providers: [NotificationsService],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Cron } from '@nestjs/schedule';
|
|||||||
import { brand } from '@mento/shared';
|
import { brand } from '@mento/shared';
|
||||||
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
|
import type { NotificationDto, NotificationsListDto } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
|
|
||||||
/** یادآورهایی که بیش از این دیرتر پردازش شوند (مثلاً قطعی سرور) فقط اعلان میگیرند، نه پیامک. */
|
/** یادآورهایی که بیش از این دیرتر پردازش شوند (مثلاً قطعی سرور) فقط اعلان میگیرند، نه پیامک. */
|
||||||
const REMINDER_SMS_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
|
const REMINDER_SMS_MAX_DELAY_MS = 6 * 60 * 60 * 1000;
|
||||||
@@ -41,7 +41,7 @@ export class NotificationsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -249,7 +249,7 @@ export class NotificationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (activity.reminderNotifySms && !stale) {
|
if (activity.reminderNotifySms && !stale) {
|
||||||
await this.sendActivityReminderSms(mobile, {
|
await this.sendActivityReminderSms(activity.organizationId, mobile, {
|
||||||
title: activity.title,
|
title: activity.title,
|
||||||
related,
|
related,
|
||||||
relatedMobile: activity.person?.mobile ?? null,
|
relatedMobile: activity.person?.mobile ?? null,
|
||||||
@@ -295,6 +295,7 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async sendActivityReminderSms(
|
private async sendActivityReminderSms(
|
||||||
|
organizationId: string,
|
||||||
mobile: string | null | undefined,
|
mobile: string | null | undefined,
|
||||||
info: { title: string; related: string; relatedMobile: string | null; dueAt: Date | null },
|
info: { title: string; related: string; relatedMobile: string | null; dueAt: Date | null },
|
||||||
) {
|
) {
|
||||||
@@ -302,10 +303,6 @@ export class NotificationsService {
|
|||||||
this.logger.warn('Activity reminder SMS skipped: assignee has no mobile');
|
this.logger.warn('Activity reminder SMS skipped: assignee has no mobile');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.sms.isConfigured()) {
|
|
||||||
this.logger.warn('Activity reminder SMS skipped: SMS not configured');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = [`${brand.reminderPrefixFa} ${info.title}`];
|
const lines = [`${brand.reminderPrefixFa} ${info.title}`];
|
||||||
if (info.related) {
|
if (info.related) {
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -314,7 +311,12 @@ export class NotificationsService {
|
|||||||
}
|
}
|
||||||
if (info.dueAt) lines.push(`زمان: ${formatTehranDateTime(info.dueAt)}`);
|
if (info.dueAt) lines.push(`زمان: ${formatTehranDateTime(info.dueAt)}`);
|
||||||
const text = lines.join('\n').slice(0, 300);
|
const text = lines.join('\n').slice(0, 300);
|
||||||
const result = await this.sms.sendSmsDetailed(mobile, text);
|
const result = await this.sms.send({
|
||||||
|
organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text,
|
||||||
|
source: 'activity_reminder',
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`);
|
this.logger.warn(`Activity reminder SMS failed: ${result.reason ?? 'unknown'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { PortalController } from './portal.controller';
|
import { PortalController } from './portal.controller';
|
||||||
import { PortalService } from './portal.service';
|
import { PortalService } from './portal.service';
|
||||||
import { PortalAuthGuard } from './portal-auth';
|
import { PortalAuthGuard } from './portal-auth';
|
||||||
@@ -8,6 +7,6 @@ import { PortalAuthGuard } from './portal-auth';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [PortalController],
|
controllers: [PortalController],
|
||||||
providers: [PortalService, PortalAuthGuard, SmsService],
|
providers: [PortalService, PortalAuthGuard],
|
||||||
})
|
})
|
||||||
export class PortalModule {}
|
export class PortalModule {}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { TICKET_SLA_HOURS } from '@mento/shared';
|
import { TICKET_SLA_HOURS } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import type { PortalActor } from './portal-auth';
|
import type { PortalActor } from './portal-auth';
|
||||||
|
|
||||||
const OTP_TTL_SECONDS = 5 * 60;
|
const OTP_TTL_SECONDS = 5 * 60;
|
||||||
@@ -31,7 +31,7 @@ export class PortalService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
|
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
|
||||||
@@ -99,16 +99,13 @@ export class PortalService {
|
|||||||
(this.config.get<string>('NODE_ENV') !== 'production' &&
|
(this.config.get<string>('NODE_ENV') !== 'production' &&
|
||||||
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
|
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
|
||||||
|
|
||||||
if (this.sms.isConfigured()) {
|
const sent = await this.sms.sendOtp(org.id, mobile, code);
|
||||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
if (!sent && !returnOtp) {
|
||||||
if (!sent && !returnOtp) {
|
throw new BadRequestException(
|
||||||
throw new BadRequestException(
|
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
||||||
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
|
);
|
||||||
);
|
}
|
||||||
}
|
if (!sent) {
|
||||||
} else if (!returnOtp) {
|
|
||||||
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`);
|
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class PlatformSmsChargeDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
amountRials!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlatformSmsUpdateAccountDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
pricePerPartRials?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isBlocked?: boolean;
|
||||||
|
|
||||||
|
/** null = غیرفعال کردن آستانه خودکار */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
value === null || value === '' || value === undefined ? null : Number(value),
|
||||||
|
)
|
||||||
|
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||||
|
@IsInt()
|
||||||
|
autoBlockBelowRials?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
alertCriticalRials?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
alertWarningRials?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['platform', 'own'])
|
||||||
|
panelMode?: 'platform' | 'own';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
ownPanelType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
ownPanelUsername?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
ownPanelPassword?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
ownPanelFrom?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
chargeCard?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
chargeIban?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
chargeHolder?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
chargeInstruction?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlatformSmsBlockDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isBlocked!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlatformSmsPriceDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
pricePerPartRials!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { SmsWalletService } from './sms-wallet.service';
|
||||||
|
|
||||||
|
@Controller('sms-wallet')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class OrgSmsController {
|
||||||
|
constructor(private readonly wallet: SmsWalletService) {}
|
||||||
|
|
||||||
|
/** خلاصه برای نشان اعتبار در هدر CRM */
|
||||||
|
@Get('summary')
|
||||||
|
async summary(@CurrentUser() user: { organizationId: string }) {
|
||||||
|
const account = await this.wallet.ensureAccount(user.organizationId);
|
||||||
|
const billingEnabled = this.wallet.isBillingEnabled(account);
|
||||||
|
return {
|
||||||
|
billingEnabled,
|
||||||
|
balanceRials: account.balanceRials,
|
||||||
|
pricePerPartRials: account.pricePerPartRials,
|
||||||
|
isBlocked: account.isBlocked,
|
||||||
|
autoBlocked: this.wallet.isAutoBlocked(account),
|
||||||
|
balanceLevel: this.wallet.balanceLevel(account),
|
||||||
|
panelMode: account.panelMode,
|
||||||
|
chargeInfo: billingEnabled ? this.wallet.chargeInfoOf(account) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { PlatformAuthGuard } from '../platform/platform-auth.guard';
|
||||||
|
import type { PlatformActor } from '../platform/platform-jwt.strategy';
|
||||||
|
import {
|
||||||
|
PlatformSmsBlockDto,
|
||||||
|
PlatformSmsChargeDto,
|
||||||
|
PlatformSmsPriceDto,
|
||||||
|
PlatformSmsUpdateAccountDto,
|
||||||
|
} from './dto';
|
||||||
|
import { SmsWalletService } from './sms-wallet.service';
|
||||||
|
|
||||||
|
@Controller('platform/sms')
|
||||||
|
@UseGuards(PlatformAuthGuard)
|
||||||
|
export class PlatformSmsController {
|
||||||
|
constructor(private readonly wallet: SmsWalletService) {}
|
||||||
|
|
||||||
|
@Get('tenants')
|
||||||
|
listTenants(@Query('q') q?: string) {
|
||||||
|
return this.wallet.listTenantSummaries(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('organizations/:organizationId')
|
||||||
|
overview(@Param('organizationId') organizationId: string) {
|
||||||
|
return this.wallet.getOverview(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('organizations/:organizationId/charge')
|
||||||
|
charge(
|
||||||
|
@Param('organizationId') organizationId: string,
|
||||||
|
@Body() dto: PlatformSmsChargeDto,
|
||||||
|
@CurrentUser() actor: PlatformActor,
|
||||||
|
) {
|
||||||
|
return this.wallet
|
||||||
|
.charge(organizationId, dto.amountRials, actor.platformAdminId, dto.note)
|
||||||
|
.then((account) => this.wallet.getOverview(organizationId).then((o) => ({
|
||||||
|
...o,
|
||||||
|
account: this.wallet.publicAccount(account),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('organizations/:organizationId/price')
|
||||||
|
updatePrice(
|
||||||
|
@Param('organizationId') organizationId: string,
|
||||||
|
@Body() dto: PlatformSmsPriceDto,
|
||||||
|
) {
|
||||||
|
return this.wallet
|
||||||
|
.updateAccount(organizationId, {
|
||||||
|
pricePerPartRials: dto.pricePerPartRials,
|
||||||
|
})
|
||||||
|
.then(() => this.wallet.getOverview(organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('organizations/:organizationId/block')
|
||||||
|
setBlocked(
|
||||||
|
@Param('organizationId') organizationId: string,
|
||||||
|
@Body() dto: PlatformSmsBlockDto,
|
||||||
|
) {
|
||||||
|
return this.wallet
|
||||||
|
.updateAccount(organizationId, { isBlocked: dto.isBlocked })
|
||||||
|
.then(() => this.wallet.getOverview(organizationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('organizations/:organizationId')
|
||||||
|
updateAccount(
|
||||||
|
@Param('organizationId') organizationId: string,
|
||||||
|
@Body() dto: PlatformSmsUpdateAccountDto,
|
||||||
|
) {
|
||||||
|
return this.wallet
|
||||||
|
.updateAccount(organizationId, dto)
|
||||||
|
.then(() => this.wallet.getOverview(organizationId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { SmsService } from '../common/sms.service';
|
||||||
|
import { OrgSmsController } from './org-sms.controller';
|
||||||
|
import { PlatformSmsController } from './platform-sms.controller';
|
||||||
|
import { SmsGatewayService } from './sms-gateway.service';
|
||||||
|
import { SmsWalletService } from './sms-wallet.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
controllers: [PlatformSmsController, OrgSmsController],
|
||||||
|
providers: [SmsService, SmsWalletService, SmsGatewayService],
|
||||||
|
exports: [SmsService, SmsWalletService, SmsGatewayService],
|
||||||
|
})
|
||||||
|
export class SmsBillingModule {}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
SmsService,
|
||||||
|
type SmsSendResult,
|
||||||
|
type SmsTransportOverride,
|
||||||
|
} from '../common/sms.service';
|
||||||
|
import { countSmsParts } from './sms-segment.util';
|
||||||
|
import { SmsWalletService } from './sms-wallet.service';
|
||||||
|
|
||||||
|
export type SmsGatewaySendInput = {
|
||||||
|
/** اگر نباشد (مثلاً ثبتنام قبل از ساخت محیط) فقط ارسال پلتفرم بدون کسر */
|
||||||
|
organizationId?: string | null;
|
||||||
|
phone: string;
|
||||||
|
text: string;
|
||||||
|
source?: string | null;
|
||||||
|
isAlert?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsGatewayService {
|
||||||
|
private readonly logger = new Logger(SmsGatewayService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly transport: SmsService,
|
||||||
|
private readonly wallet: SmsWalletService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return this.transport.isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizePhoneForSms(mobile: string): string {
|
||||||
|
return this.transport.normalizePhoneForSms(mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOtp(
|
||||||
|
organizationId: string | null | undefined,
|
||||||
|
mobile: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const text = this.transport.buildOtpText(code);
|
||||||
|
const result = await this.send({
|
||||||
|
organizationId,
|
||||||
|
phone: mobile,
|
||||||
|
text,
|
||||||
|
source: 'otp',
|
||||||
|
});
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(input: SmsGatewaySendInput): Promise<SmsSendResult> {
|
||||||
|
const text = String(input.text ?? '').trim();
|
||||||
|
if (!text) {
|
||||||
|
return { ok: false, transport: 'none', reason: 'empty body' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const partsEstimate = Math.max(1, countSmsParts(text));
|
||||||
|
let override: SmsTransportOverride | undefined;
|
||||||
|
let billable = false;
|
||||||
|
let providerLabel = 'platform';
|
||||||
|
|
||||||
|
if (input.organizationId) {
|
||||||
|
const account = await this.wallet.ensureAccount(input.organizationId);
|
||||||
|
const billingEnabled = this.wallet.isBillingEnabled(account);
|
||||||
|
|
||||||
|
if (billingEnabled) {
|
||||||
|
if (!this.wallet.canSend(account, Boolean(input.isAlert))) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
blocked: true,
|
||||||
|
transport: 'none',
|
||||||
|
reason: account.isBlocked ? 'blocked' : 'auto_blocked',
|
||||||
|
parts: partsEstimate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
billable = true;
|
||||||
|
} else {
|
||||||
|
// پنل اختصاصی مشتری: بدون کسر/لاگ کیف
|
||||||
|
const user = account.ownPanelUsername?.trim();
|
||||||
|
const pass = account.ownPanelPassword?.trim();
|
||||||
|
const from = account.ownPanelFrom?.trim();
|
||||||
|
if (!user || !pass || !from) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transport: 'none',
|
||||||
|
reason: 'own SMS panel not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const type = (account.ownPanelType || '0098').toLowerCase();
|
||||||
|
override = {
|
||||||
|
username: user,
|
||||||
|
password: pass,
|
||||||
|
sender: from,
|
||||||
|
provider: type === 'trez' ? 'trez' : '0098',
|
||||||
|
};
|
||||||
|
providerLabel = `own:${type || '0098'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!override && !this.transport.isConfigured()) {
|
||||||
|
return { ok: false, transport: 'none', reason: 'SMS not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.transport.sendSmsDetailed(
|
||||||
|
input.phone,
|
||||||
|
text,
|
||||||
|
override,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = Math.max(1, countSmsParts(text));
|
||||||
|
const to = this.transport.normalizePhoneForSms(input.phone);
|
||||||
|
|
||||||
|
if (billable && input.organizationId) {
|
||||||
|
try {
|
||||||
|
const debit = await this.wallet.debitSuccessfulSms({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
receiver: to,
|
||||||
|
body: text,
|
||||||
|
parts,
|
||||||
|
provider: result.transport || providerLabel,
|
||||||
|
source: input.source,
|
||||||
|
isAlert: input.isAlert,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
parts,
|
||||||
|
costRials: debit.costRials,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(
|
||||||
|
`SMS debit failed after send org=${input.organizationId}: ${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return { ...result, parts };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, parts, costRials: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** Persian SMS parts (same rules as Hamkar SmsSegmentCalculator). */
|
||||||
|
const PERSIAN_LIMITS = [70, 64, 67] as const;
|
||||||
|
|
||||||
|
export function countSmsParts(text: string): number {
|
||||||
|
const length = [...String(text ?? '').trim()].length;
|
||||||
|
if (length <= 0) return 0;
|
||||||
|
|
||||||
|
const [first, second, rest] = PERSIAN_LIMITS;
|
||||||
|
if (length <= first) return 1;
|
||||||
|
if (length <= first + second) return 2;
|
||||||
|
const thirdThreshold = first + second + rest;
|
||||||
|
if (length <= thirdThreshold) return 3;
|
||||||
|
return 3 + Math.ceil((length - thirdThreshold) / rest);
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import type { SmsAccount } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { countSmsParts } from './sms-segment.util';
|
||||||
|
|
||||||
|
export type SmsBalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||||
|
|
||||||
|
const DEFAULT_CHARGE_INSTRUCTION =
|
||||||
|
'لطفا مبلغ مورد نظر خود جهت شارژ سامانه پیامکی را به شماره کارت یا حساب زیر واریز نمایید و تصویر فیش پرداختی را برای پشتیبانی بفرستید.';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsWalletService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async ensureAccount(organizationId: string): Promise<SmsAccount> {
|
||||||
|
return this.prisma.smsAccount.upsert({
|
||||||
|
where: { organizationId },
|
||||||
|
create: { organizationId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isBillingEnabled(account: SmsAccount): boolean {
|
||||||
|
return (account.panelMode ?? 'platform') !== 'own';
|
||||||
|
}
|
||||||
|
|
||||||
|
isAutoBlocked(account: SmsAccount): boolean {
|
||||||
|
return (
|
||||||
|
account.autoBlockBelowRials != null &&
|
||||||
|
account.balanceRials < account.autoBlockBelowRials
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
canSend(account: SmsAccount, isAlert = false): boolean {
|
||||||
|
if (!this.isBillingEnabled(account)) return true;
|
||||||
|
if (isAlert) return true;
|
||||||
|
if (account.isBlocked) return false;
|
||||||
|
if (this.isAutoBlocked(account)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
balanceLevel(account: SmsAccount): SmsBalanceLevel {
|
||||||
|
if (!this.isBillingEnabled(account)) return 'unknown';
|
||||||
|
const bal = account.balanceRials;
|
||||||
|
if (bal <= account.alertCriticalRials) return 'critical';
|
||||||
|
if (bal <= account.alertWarningRials) return 'warning';
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateCost(account: SmsAccount, text: string): { parts: number; costRials: number } {
|
||||||
|
const parts = Math.max(1, countSmsParts(text));
|
||||||
|
return { parts, costRials: parts * account.pricePerPartRials };
|
||||||
|
}
|
||||||
|
|
||||||
|
chargeInfoOf(account: SmsAccount) {
|
||||||
|
return {
|
||||||
|
card: account.chargeCard?.trim() || '',
|
||||||
|
iban: account.chargeIban?.trim() || '',
|
||||||
|
accountHolder: account.chargeHolder?.trim() || '',
|
||||||
|
instruction:
|
||||||
|
account.chargeInstruction?.trim() || DEFAULT_CHARGE_INSTRUCTION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(organizationId: string, parts = 1, isAlert = false) {
|
||||||
|
const account = await this.ensureAccount(organizationId);
|
||||||
|
const safeParts = Math.max(1, Math.min(50, Math.floor(parts) || 1));
|
||||||
|
const estimatedCostRials = safeParts * account.pricePerPartRials;
|
||||||
|
const billingEnabled = this.isBillingEnabled(account);
|
||||||
|
|
||||||
|
let reason: string | null = null;
|
||||||
|
let allowed = true;
|
||||||
|
|
||||||
|
if (billingEnabled) {
|
||||||
|
if (account.isBlocked && !isAlert) {
|
||||||
|
allowed = false;
|
||||||
|
reason = 'blocked';
|
||||||
|
} else if (this.isAutoBlocked(account) && !isAlert) {
|
||||||
|
allowed = false;
|
||||||
|
reason = 'auto_blocked';
|
||||||
|
} else if (!isAlert && account.balanceRials < estimatedCostRials) {
|
||||||
|
reason = 'insufficient_balance';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
billingEnabled,
|
||||||
|
allowed,
|
||||||
|
blocked: billingEnabled && (account.isBlocked || this.isAutoBlocked(account)),
|
||||||
|
reason,
|
||||||
|
balanceRials: account.balanceRials,
|
||||||
|
pricePerPartRials: account.pricePerPartRials,
|
||||||
|
autoBlockBelowRials: account.autoBlockBelowRials,
|
||||||
|
panelMode: account.panelMode,
|
||||||
|
parts: safeParts,
|
||||||
|
estimatedCostRials,
|
||||||
|
balanceLevel: this.balanceLevel(account),
|
||||||
|
isBlocked: account.isBlocked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async charge(
|
||||||
|
organizationId: string,
|
||||||
|
amountRials: number,
|
||||||
|
createdById?: string | null,
|
||||||
|
note?: string | null,
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(amountRials) || amountRials === 0) {
|
||||||
|
throw new BadRequestException('مبلغ شارژ نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const account = await tx.smsAccount.upsert({
|
||||||
|
where: { organizationId },
|
||||||
|
create: { organizationId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
const next = account.balanceRials + Math.trunc(amountRials);
|
||||||
|
const updated = await tx.smsAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: {
|
||||||
|
balanceRials: next,
|
||||||
|
...(next > 0 ? { alertDaysSent: 0, lastAlertDate: null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.smsLedger.create({
|
||||||
|
data: {
|
||||||
|
smsAccountId: account.id,
|
||||||
|
type: 'charge',
|
||||||
|
amountRials: Math.trunc(amountRials),
|
||||||
|
balanceAfterRials: next,
|
||||||
|
note: note?.trim() || null,
|
||||||
|
createdById: createdById ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async debitSuccessfulSms(input: {
|
||||||
|
organizationId: string;
|
||||||
|
receiver: string;
|
||||||
|
body: string;
|
||||||
|
parts: number;
|
||||||
|
provider: string;
|
||||||
|
source?: string | null;
|
||||||
|
isAlert?: boolean;
|
||||||
|
}) {
|
||||||
|
const parts = Math.max(1, input.parts);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const account = await tx.smsAccount.findUnique({
|
||||||
|
where: { organizationId: input.organizationId },
|
||||||
|
});
|
||||||
|
if (!account) {
|
||||||
|
throw new BadRequestException('حساب پیامک یافت نشد');
|
||||||
|
}
|
||||||
|
if (!this.isBillingEnabled(account)) {
|
||||||
|
return { account, costRials: 0, logId: null as string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const costRials = parts * account.pricePerPartRials;
|
||||||
|
const next = account.balanceRials - costRials;
|
||||||
|
const updated = await tx.smsAccount.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { balanceRials: next },
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = await tx.smsBillingSendLog.create({
|
||||||
|
data: {
|
||||||
|
smsAccountId: account.id,
|
||||||
|
receiver: input.receiver.slice(0, 32),
|
||||||
|
body: input.body.slice(0, 2000),
|
||||||
|
parts,
|
||||||
|
pricePerPartRials: account.pricePerPartRials,
|
||||||
|
totalAmountRials: costRials,
|
||||||
|
provider: input.provider.slice(0, 64),
|
||||||
|
source: input.source?.slice(0, 64) ?? null,
|
||||||
|
isAlert: Boolean(input.isAlert),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.smsLedger.create({
|
||||||
|
data: {
|
||||||
|
smsAccountId: account.id,
|
||||||
|
type: input.isAlert ? 'alert_debit' : 'debit',
|
||||||
|
amountRials: -costRials,
|
||||||
|
balanceAfterRials: next,
|
||||||
|
parts,
|
||||||
|
pricePerPartRials: account.pricePerPartRials,
|
||||||
|
smsSendLogId: log.id,
|
||||||
|
note: input.isAlert
|
||||||
|
? 'هشدار موجودی پیامک'
|
||||||
|
: input.source
|
||||||
|
? `مصرف ارسال (${input.source})`
|
||||||
|
: 'مصرف ارسال',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { account: updated, costRials, logId: log.id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTenantSummaries(q?: string) {
|
||||||
|
const term = q?.trim();
|
||||||
|
const orgs = await this.prisma.organization.findMany({
|
||||||
|
where: term
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: term, mode: 'insensitive' } },
|
||||||
|
{ slug: { contains: term, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
take: 500,
|
||||||
|
include: { smsAccount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await Promise.all(
|
||||||
|
orgs.map(async (org) => {
|
||||||
|
const account = org.smsAccount ?? (await this.ensureAccount(org.id));
|
||||||
|
return {
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
slug: org.slug,
|
||||||
|
isActive: org.isActive,
|
||||||
|
isBlocked: account.isBlocked,
|
||||||
|
panelMode: account.panelMode,
|
||||||
|
sms: {
|
||||||
|
balanceRials: account.balanceRials,
|
||||||
|
pricePerPartRials: account.pricePerPartRials,
|
||||||
|
autoBlockBelowRials: account.autoBlockBelowRials,
|
||||||
|
balanceLevel: this.balanceLevel(account),
|
||||||
|
billingEnabled: this.isBillingEnabled(account),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOverview(organizationId: string) {
|
||||||
|
const account = await this.ensureAccount(organizationId);
|
||||||
|
const org = await this.prisma.organization.findUniqueOrThrow({
|
||||||
|
where: { id: organizationId },
|
||||||
|
select: { id: true, name: true, slug: true, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startOfDay = new Date(now);
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const [todayAgg, monthAgg, recentLedgers, recentLogs] = await Promise.all([
|
||||||
|
this.prisma.smsBillingSendLog.aggregate({
|
||||||
|
where: {
|
||||||
|
smsAccountId: account.id,
|
||||||
|
createdAt: { gte: startOfDay },
|
||||||
|
isAlert: false,
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
_sum: { parts: true, totalAmountRials: true },
|
||||||
|
}),
|
||||||
|
this.prisma.smsBillingSendLog.aggregate({
|
||||||
|
where: {
|
||||||
|
smsAccountId: account.id,
|
||||||
|
createdAt: { gte: startOfMonth },
|
||||||
|
isAlert: false,
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
_sum: { parts: true, totalAmountRials: true },
|
||||||
|
}),
|
||||||
|
this.prisma.smsLedger.findMany({
|
||||||
|
where: { smsAccountId: account.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
this.prisma.smsBillingSendLog.findMany({
|
||||||
|
where: { smsAccountId: account.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: org,
|
||||||
|
account: this.publicAccount(account),
|
||||||
|
chargeInfo: this.chargeInfoOf(account),
|
||||||
|
today: {
|
||||||
|
messages: todayAgg._count._all,
|
||||||
|
parts: todayAgg._sum.parts ?? 0,
|
||||||
|
amountRials: todayAgg._sum.totalAmountRials ?? 0,
|
||||||
|
},
|
||||||
|
month: {
|
||||||
|
messages: monthAgg._count._all,
|
||||||
|
parts: monthAgg._sum.parts ?? 0,
|
||||||
|
amountRials: monthAgg._sum.totalAmountRials ?? 0,
|
||||||
|
},
|
||||||
|
ledgers: recentLedgers.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
type: l.type,
|
||||||
|
amountRials: l.amountRials,
|
||||||
|
balanceAfterRials: l.balanceAfterRials,
|
||||||
|
parts: l.parts,
|
||||||
|
note: l.note,
|
||||||
|
createdAt: l.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
sendLogs: recentLogs.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
receiver: l.receiver,
|
||||||
|
body: l.body,
|
||||||
|
parts: l.parts,
|
||||||
|
totalAmountRials: l.totalAmountRials,
|
||||||
|
provider: l.provider,
|
||||||
|
source: l.source,
|
||||||
|
isAlert: l.isAlert,
|
||||||
|
createdAt: l.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
publicAccount(account: SmsAccount) {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
organizationId: account.organizationId,
|
||||||
|
balanceRials: account.balanceRials,
|
||||||
|
pricePerPartRials: account.pricePerPartRials,
|
||||||
|
isBlocked: account.isBlocked,
|
||||||
|
autoBlockBelowRials: account.autoBlockBelowRials,
|
||||||
|
alertCriticalRials: account.alertCriticalRials,
|
||||||
|
alertWarningRials: account.alertWarningRials,
|
||||||
|
panelMode: account.panelMode as 'platform' | 'own',
|
||||||
|
ownPanelType: account.ownPanelType,
|
||||||
|
ownPanelUsername: account.ownPanelUsername,
|
||||||
|
ownPanelFrom: account.ownPanelFrom,
|
||||||
|
hasOwnPanelPassword: Boolean(account.ownPanelPassword),
|
||||||
|
balanceLevel: this.balanceLevel(account),
|
||||||
|
billingEnabled: this.isBillingEnabled(account),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(
|
||||||
|
organizationId: string,
|
||||||
|
data: {
|
||||||
|
pricePerPartRials?: number;
|
||||||
|
isBlocked?: boolean;
|
||||||
|
autoBlockBelowRials?: number | null;
|
||||||
|
alertCriticalRials?: number;
|
||||||
|
alertWarningRials?: number;
|
||||||
|
panelMode?: 'platform' | 'own';
|
||||||
|
ownPanelType?: string;
|
||||||
|
ownPanelUsername?: string | null;
|
||||||
|
ownPanelPassword?: string | null;
|
||||||
|
ownPanelFrom?: string | null;
|
||||||
|
chargeCard?: string | null;
|
||||||
|
chargeIban?: string | null;
|
||||||
|
chargeHolder?: string | null;
|
||||||
|
chargeInstruction?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await this.ensureAccount(organizationId);
|
||||||
|
if (
|
||||||
|
data.alertCriticalRials != null &&
|
||||||
|
data.alertWarningRials != null &&
|
||||||
|
data.alertCriticalRials > data.alertWarningRials
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('آستانه قرمز نباید از آستانه زرد بزرگتر باشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.smsAccount.update({
|
||||||
|
where: { organizationId },
|
||||||
|
data: {
|
||||||
|
pricePerPartRials:
|
||||||
|
data.pricePerPartRials === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.trunc(data.pricePerPartRials)),
|
||||||
|
isBlocked: data.isBlocked,
|
||||||
|
autoBlockBelowRials:
|
||||||
|
data.autoBlockBelowRials === undefined
|
||||||
|
? undefined
|
||||||
|
: data.autoBlockBelowRials,
|
||||||
|
alertCriticalRials:
|
||||||
|
data.alertCriticalRials === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.trunc(data.alertCriticalRials)),
|
||||||
|
alertWarningRials:
|
||||||
|
data.alertWarningRials === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.trunc(data.alertWarningRials)),
|
||||||
|
panelMode: data.panelMode,
|
||||||
|
ownPanelType: data.ownPanelType?.trim() || undefined,
|
||||||
|
ownPanelUsername:
|
||||||
|
data.ownPanelUsername === undefined
|
||||||
|
? undefined
|
||||||
|
: data.ownPanelUsername?.trim() || null,
|
||||||
|
ownPanelPassword:
|
||||||
|
data.ownPanelPassword === undefined
|
||||||
|
? undefined
|
||||||
|
: data.ownPanelPassword?.trim() || null,
|
||||||
|
ownPanelFrom:
|
||||||
|
data.ownPanelFrom === undefined
|
||||||
|
? undefined
|
||||||
|
: data.ownPanelFrom?.trim() || null,
|
||||||
|
chargeCard:
|
||||||
|
data.chargeCard === undefined
|
||||||
|
? undefined
|
||||||
|
: data.chargeCard?.trim() || null,
|
||||||
|
chargeIban:
|
||||||
|
data.chargeIban === undefined
|
||||||
|
? undefined
|
||||||
|
: data.chargeIban?.trim() || null,
|
||||||
|
chargeHolder:
|
||||||
|
data.chargeHolder === undefined
|
||||||
|
? undefined
|
||||||
|
: data.chargeHolder?.trim() || null,
|
||||||
|
chargeInstruction:
|
||||||
|
data.chargeInstruction === undefined
|
||||||
|
? undefined
|
||||||
|
: data.chargeInstruction?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.publicAccount(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { TrackingModule } from '../tracking/tracking.module';
|
import { TrackingModule } from '../tracking/tracking.module';
|
||||||
import { SmsCampaignsController } from './sms-campaigns.controller';
|
import { SmsCampaignsController } from './sms-campaigns.controller';
|
||||||
import { SmsCampaignsService } from './sms-campaigns.service';
|
import { SmsCampaignsService } from './sms-campaigns.service';
|
||||||
@@ -9,7 +8,7 @@ import { SmsCampaignsService } from './sms-campaigns.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BillingModule, TrackingModule],
|
imports: [AuthModule, BillingModule, TrackingModule],
|
||||||
controllers: [SmsCampaignsController],
|
controllers: [SmsCampaignsController],
|
||||||
providers: [SmsCampaignsService, SmsService],
|
providers: [SmsCampaignsService],
|
||||||
exports: [SmsCampaignsService],
|
exports: [SmsCampaignsService],
|
||||||
})
|
})
|
||||||
export class SmsCampaignsModule {}
|
export class SmsCampaignsModule {}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
|
import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import { renderSmsText } from '../common/sms-text';
|
import { renderSmsText } from '../common/sms-text';
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
@@ -45,7 +45,7 @@ function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
|
|||||||
export class SmsCampaignsService {
|
export class SmsCampaignsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
private readonly tracking: TrackingService,
|
private readonly tracking: TrackingService,
|
||||||
private readonly outboundSms: OutboundSmsLogService,
|
private readonly outboundSms: OutboundSmsLogService,
|
||||||
) {}
|
) {}
|
||||||
@@ -583,10 +583,12 @@ export class SmsCampaignsService {
|
|||||||
body: withTokens,
|
body: withTokens,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await this.sms.sendSmsDetailed(
|
const result = await this.sms.send({
|
||||||
recipient.mobile,
|
organizationId: campaign.organizationId,
|
||||||
personalized.body,
|
phone: recipient.mobile,
|
||||||
);
|
text: personalized.body,
|
||||||
|
source: 'campaign',
|
||||||
|
});
|
||||||
await this.outboundSms.record({
|
await this.outboundSms.record({
|
||||||
organizationId: campaign.organizationId,
|
organizationId: campaign.organizationId,
|
||||||
personId: recipient.personId,
|
personId: recipient.personId,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { SmsService } from '../common/sms.service';
|
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export class TreasuryReceivablesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsGatewayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
||||||
@@ -306,8 +306,15 @@ export class TreasuryReceivablesService {
|
|||||||
where: { id: admin.userId },
|
where: { id: admin.userId },
|
||||||
select: { mobile: true },
|
select: { mobile: true },
|
||||||
});
|
});
|
||||||
if (user?.mobile && this.sms.isConfigured()) {
|
if (user?.mobile) {
|
||||||
await this.sms.sendSmsDetailed(user.mobile, `${title}: ${body}`).catch(() => undefined);
|
await this.sms
|
||||||
|
.send({
|
||||||
|
organizationId: row.receivable.organizationId,
|
||||||
|
phone: user.mobile,
|
||||||
|
text: `${title}: ${body}`,
|
||||||
|
source: 'treasury_reminder',
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.prisma.financialInstallment.update({
|
await this.prisma.financialInstallment.update({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { SmsService } from '../common/sms.service';
|
|
||||||
import { TreasuryController } from './treasury.controller';
|
import { TreasuryController } from './treasury.controller';
|
||||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||||
import { TreasuryBridgeService } from './treasury-bridge.service';
|
import { TreasuryBridgeService } from './treasury-bridge.service';
|
||||||
@@ -18,7 +17,6 @@ import { TreasuryService } from './treasury.service';
|
|||||||
TreasuryOpsService,
|
TreasuryOpsService,
|
||||||
TreasuryBridgeService,
|
TreasuryBridgeService,
|
||||||
TreasuryAccessGuard,
|
TreasuryAccessGuard,
|
||||||
SmsService,
|
|
||||||
],
|
],
|
||||||
exports: [TreasuryService, TreasuryBridgeService],
|
exports: [TreasuryService, TreasuryBridgeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
|
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
|
||||||
import { MESSAGE_AUTOMATION_FIELDS, MESSAGE_AUTOMATION_SMS_SEGMENT } from '@mento/shared';
|
import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import { UiButton, UiModal } from '@/components/ui';
|
import { UiButton, UiModal } from '@/components/ui';
|
||||||
@@ -46,7 +46,7 @@ const historyLoading = ref(false);
|
|||||||
|
|
||||||
const preview = computed(() => fill(text.value));
|
const preview = computed(() => fill(text.value));
|
||||||
const count = computed(() => text.value.length);
|
const count = computed(() => text.value.length);
|
||||||
const parts = computed(() => (count.value === 0 ? 0 : Math.ceil(count.value / MESSAGE_AUTOMATION_SMS_SEGMENT)));
|
const parts = computed(() => countSmsParts(text.value));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import type { SmsWalletSummaryDto } from '@mento/shared';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const summary = ref<SmsWalletSummaryDto | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const chargeOpen = ref(false);
|
||||||
|
const warnOpen = ref(false);
|
||||||
|
const NEGATIVE_KEY = 'mento_sms_negative_warning_shown';
|
||||||
|
|
||||||
|
const visible = computed(() => Boolean(summary.value?.billingEnabled));
|
||||||
|
|
||||||
|
const balanceLabel = computed(() => {
|
||||||
|
const n = summary.value?.balanceRials ?? 0;
|
||||||
|
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n));
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneClass = computed(() => {
|
||||||
|
const level = summary.value?.balanceLevel;
|
||||||
|
if (level === 'critical') return 'critical';
|
||||||
|
if (level === 'warning') return 'warning';
|
||||||
|
if ((summary.value?.balanceRials ?? 0) < 0) return 'negative';
|
||||||
|
return 'ok';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!auth.isAuthenticated || !auth.user?.organizationId) {
|
||||||
|
summary.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<SmsWalletSummaryDto>('/sms-wallet/summary');
|
||||||
|
summary.value = data;
|
||||||
|
syncWarn(data);
|
||||||
|
} catch {
|
||||||
|
summary.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncWarn(data: SmsWalletSummaryDto) {
|
||||||
|
if (!data.billingEnabled || data.balanceRials >= 0) {
|
||||||
|
warnOpen.value = false;
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(NEGATIVE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(NEGATIVE_KEY) === '1') return;
|
||||||
|
sessionStorage.setItem(NEGATIVE_KEY, '1');
|
||||||
|
warnOpen.value = true;
|
||||||
|
} catch {
|
||||||
|
warnOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => auth.user?.organizationId,
|
||||||
|
() => {
|
||||||
|
void load();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ refresh: load });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="sms-wallet">
|
||||||
|
<div class="chip" :class="toneClass" :title="loading ? '…' : undefined">
|
||||||
|
<span>اعتبار پیامک: {{ balanceLabel }} ریال</span>
|
||||||
|
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="chargeOpen" class="layer">
|
||||||
|
<div class="backdrop" @click="chargeOpen = false" />
|
||||||
|
<div class="modal" role="dialog" aria-label="شارژ پیامک">
|
||||||
|
<header>
|
||||||
|
<strong>شارژ اعتبار پیامک</strong>
|
||||||
|
<button type="button" class="x" @click="chargeOpen = false">×</button>
|
||||||
|
</header>
|
||||||
|
<p class="muted">
|
||||||
|
برای شارژ با پشتیبانی هماهنگ کنید یا فیش واریز را ارسال کنید.
|
||||||
|
</p>
|
||||||
|
<template v-if="summary?.chargeInfo">
|
||||||
|
<dl>
|
||||||
|
<div v-if="summary.chargeInfo.card">
|
||||||
|
<dt>کارت</dt>
|
||||||
|
<dd dir="ltr">{{ summary.chargeInfo.card }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="summary.chargeInfo.iban">
|
||||||
|
<dt>شبا</dt>
|
||||||
|
<dd dir="ltr">{{ summary.chargeInfo.iban }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="summary.chargeInfo.accountHolder">
|
||||||
|
<dt>صاحب حساب</dt>
|
||||||
|
<dd>{{ summary.chargeInfo.accountHolder }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="instruction">{{ summary.chargeInfo.instruction }}</p>
|
||||||
|
</template>
|
||||||
|
<button type="button" class="ok" @click="chargeOpen = false">متوجه شدم</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="warnOpen" class="layer">
|
||||||
|
<div class="backdrop" @click="warnOpen = false" />
|
||||||
|
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||||
|
<header>
|
||||||
|
<strong>موجودی پیامک منفی است</strong>
|
||||||
|
<button type="button" class="x" @click="warnOpen = false">×</button>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
اعتبار پیامک محیط شما منفی شده
|
||||||
|
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
||||||
|
شارژ کنید.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
background: rgba(236, 253, 245, 0.7);
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
.chip.warning {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: rgba(255, 251, 235, 0.85);
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
.chip.critical,
|
||||||
|
.chip.negative {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: rgba(254, 242, 242, 0.9);
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
.charge {
|
||||||
|
border: 0;
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip.critical .charge,
|
||||||
|
.chip.negative .charge,
|
||||||
|
.chip.warning .charge {
|
||||||
|
background: #b45309;
|
||||||
|
}
|
||||||
|
.layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
position: relative;
|
||||||
|
width: min(420px, calc(100vw - 2rem));
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
.modal header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.muted,
|
||||||
|
.instruction {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
dl > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
border: 0;
|
||||||
|
background: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,6 +22,8 @@ const pageTitle = computed(() => {
|
|||||||
return 'کاربران';
|
return 'کاربران';
|
||||||
case 'adm-admins':
|
case 'adm-admins':
|
||||||
return 'ادمینها';
|
return 'ادمینها';
|
||||||
|
case 'adm-sms':
|
||||||
|
return 'پیامک';
|
||||||
default:
|
default:
|
||||||
return 'سوپرادمین';
|
return 'سوپرادمین';
|
||||||
}
|
}
|
||||||
@@ -31,6 +33,7 @@ const tabs = [
|
|||||||
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
|
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
|
||||||
{ name: 'adm-organizations', label: 'محیطها', icon: 'building' as const },
|
{ name: 'adm-organizations', label: 'محیطها', icon: 'building' as const },
|
||||||
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
|
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
|
||||||
|
{ name: 'adm-sms', label: 'پیامک', icon: 'mail' as const },
|
||||||
{ name: 'adm-admins', label: 'ادمینها', icon: 'lock' as const },
|
{ name: 'adm-admins', label: 'ادمینها', icon: 'lock' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import QuickCreate from '@/components/QuickCreate.vue';
|
|||||||
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
||||||
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
||||||
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
|
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
|
||||||
|
import SmsHeaderWallet from '@/components/SmsHeaderWallet.vue';
|
||||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||||
import type {
|
import type {
|
||||||
ActivitySummaryDto,
|
ActivitySummaryDto,
|
||||||
@@ -769,6 +770,7 @@ onUnmounted(() => {
|
|||||||
<BrandName lang="en" />
|
<BrandName lang="en" />
|
||||||
</div>
|
</div>
|
||||||
<div class="top-tools">
|
<div class="top-tools">
|
||||||
|
<SmsHeaderWallet />
|
||||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||||
<UiIcon name="bell" :size="18" />
|
<UiIcon name="bell" :size="18" />
|
||||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||||
@@ -805,6 +807,7 @@ onUnmounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
|
<SmsHeaderWallet />
|
||||||
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
|
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
|
||||||
<UiIcon name="sparkle" :size="18" />
|
<UiIcon name="sparkle" :size="18" />
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const router = createRouter({
|
|||||||
name: 'adm-admins',
|
name: 'adm-admins',
|
||||||
component: () => import('@/views/adm/AdmAdminsView.vue'),
|
component: () => import('@/views/adm/AdmAdminsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'sms',
|
||||||
|
name: 'adm-sms',
|
||||||
|
component: () => import('@/views/adm/AdmSmsView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import type { PlatformSmsOverviewDto, PlatformSmsTenantRowDto } from '@mento/shared';
|
||||||
|
import { platformApi } from '@/lib/platform-api';
|
||||||
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const q = ref('');
|
||||||
|
const tenants = ref<PlatformSmsTenantRowDto[]>([]);
|
||||||
|
const overview = ref<PlatformSmsOverviewDto | null>(null);
|
||||||
|
|
||||||
|
const selectedId = computed(() => String(route.query.org || ''));
|
||||||
|
|
||||||
|
const chargeAmount = ref('');
|
||||||
|
const chargeNote = ref('');
|
||||||
|
const priceInput = ref('');
|
||||||
|
const autoBlockInput = ref('');
|
||||||
|
const ownUser = ref('');
|
||||||
|
const ownPass = ref('');
|
||||||
|
const ownFrom = ref('');
|
||||||
|
const ownType = ref('0098');
|
||||||
|
|
||||||
|
function formatRials(n: number) {
|
||||||
|
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n)) + ' ریال';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTenants() {
|
||||||
|
const { data } = await platformApi.get<PlatformSmsTenantRowDto[]>('/platform/sms/tenants', {
|
||||||
|
params: q.value.trim() ? { q: q.value.trim() } : undefined,
|
||||||
|
});
|
||||||
|
tenants.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverview(orgId: string) {
|
||||||
|
const { data } = await platformApi.get<PlatformSmsOverviewDto>(
|
||||||
|
`/platform/sms/organizations/${orgId}`,
|
||||||
|
);
|
||||||
|
overview.value = data;
|
||||||
|
priceInput.value = String(data.account.pricePerPartRials);
|
||||||
|
autoBlockInput.value =
|
||||||
|
data.account.autoBlockBelowRials == null
|
||||||
|
? ''
|
||||||
|
: String(data.account.autoBlockBelowRials);
|
||||||
|
ownUser.value = data.account.ownPanelUsername || '';
|
||||||
|
ownPass.value = '';
|
||||||
|
ownFrom.value = data.account.ownPanelFrom || '';
|
||||||
|
ownType.value = data.account.ownPanelType || '0098';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
error.value = '';
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await loadTenants();
|
||||||
|
if (selectedId.value) {
|
||||||
|
await loadOverview(selectedId.value);
|
||||||
|
} else {
|
||||||
|
overview.value = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error.value = 'بارگذاری پنل پیامک ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOrg(id: string) {
|
||||||
|
void router.replace({ name: 'adm-sms', query: { org: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSave(fn: () => Promise<void>) {
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
if (selectedId.value) await loadOverview(selectedId.value);
|
||||||
|
await loadTenants();
|
||||||
|
} catch {
|
||||||
|
error.value = 'ذخیره ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function charge() {
|
||||||
|
const amount = Number(String(chargeAmount.value).replace(/[^\d-]/g, ''));
|
||||||
|
if (!selectedId.value || !amount) return;
|
||||||
|
void runSave(async () => {
|
||||||
|
await platformApi.post(`/platform/sms/organizations/${selectedId.value}/charge`, {
|
||||||
|
amountRials: amount,
|
||||||
|
note: chargeNote.value || undefined,
|
||||||
|
});
|
||||||
|
chargeAmount.value = '';
|
||||||
|
chargeNote.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePrice() {
|
||||||
|
const price = Number(priceInput.value);
|
||||||
|
if (!selectedId.value || !Number.isFinite(price) || price < 0) return;
|
||||||
|
void runSave(async () => {
|
||||||
|
await platformApi.put(`/platform/sms/organizations/${selectedId.value}/price`, {
|
||||||
|
pricePerPartRials: Math.trunc(price),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBlock() {
|
||||||
|
if (!overview.value || !selectedId.value) return;
|
||||||
|
const next = !overview.value.account.isBlocked;
|
||||||
|
void runSave(async () => {
|
||||||
|
await platformApi.put(`/platform/sms/organizations/${selectedId.value}/block`, {
|
||||||
|
isBlocked: next,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAutoBlock() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
const raw = autoBlockInput.value.trim();
|
||||||
|
const value = raw === '' ? null : Number(raw.replace(/[^\d-]/g, ''));
|
||||||
|
if (raw !== '' && !Number.isFinite(value)) return;
|
||||||
|
void runSave(async () => {
|
||||||
|
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
|
||||||
|
autoBlockBelowRials: value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPanelMode(mode: 'platform' | 'own') {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
void runSave(async () => {
|
||||||
|
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
|
||||||
|
panelMode: mode,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveOwnPanel() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
void runSave(async () => {
|
||||||
|
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
|
||||||
|
panelMode: 'own',
|
||||||
|
ownPanelType: ownType.value,
|
||||||
|
ownPanelUsername: ownUser.value || null,
|
||||||
|
ownPanelFrom: ownFrom.value || null,
|
||||||
|
...(ownPass.value ? { ownPanelPassword: ownPass.value } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.org,
|
||||||
|
() => {
|
||||||
|
void load();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||||
|
|
||||||
|
<div v-else class="grid">
|
||||||
|
<aside class="list-card">
|
||||||
|
<div class="list-head">
|
||||||
|
<strong>محیطها</strong>
|
||||||
|
<input v-model="q" type="search" placeholder="جستجو…" @keyup.enter="load" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="t in tenants"
|
||||||
|
:key="t.id"
|
||||||
|
type="button"
|
||||||
|
class="tenant"
|
||||||
|
:class="{ active: t.id === selectedId }"
|
||||||
|
@click="openOrg(t.id)"
|
||||||
|
>
|
||||||
|
<span class="name">{{ t.name }}</span>
|
||||||
|
<small>
|
||||||
|
{{ t.sms.billingEnabled ? formatRials(t.sms.balanceRials) : 'پنل اختصاصی' }}
|
||||||
|
<span v-if="t.isBlocked" class="badge bad">قطع</span>
|
||||||
|
</small>
|
||||||
|
</button>
|
||||||
|
<p v-if="!tenants.length" class="muted">محیطی یافت نشد.</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section v-if="overview" class="detail-card">
|
||||||
|
<header class="detail-head">
|
||||||
|
<div>
|
||||||
|
<h2>پنل پیامک</h2>
|
||||||
|
<p class="muted">
|
||||||
|
{{ overview.organization.name }} · مدیریت کیف پول، تعرفه و گزارش ارسال
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:class="overview.account.isBlocked ? 'btn-ok' : 'btn-danger'"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="toggleBlock"
|
||||||
|
>
|
||||||
|
{{ overview.account.isBlocked ? 'وصل کردن ارسال' : 'قطع ارسال' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<article>
|
||||||
|
<span>موجودی کیف پول</span>
|
||||||
|
<strong>{{ formatRials(overview.account.balanceRials) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>قیمت هر پارت</span>
|
||||||
|
<strong>{{ formatRials(overview.account.pricePerPartRials) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>امروز</span>
|
||||||
|
<strong>{{ toFaDigits(overview.today.messages) }} پیامک</strong>
|
||||||
|
<small>
|
||||||
|
{{ toFaDigits(overview.today.parts) }} پارت ·
|
||||||
|
{{ formatRials(overview.today.amountRials) }}
|
||||||
|
</small>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>ماه جاری</span>
|
||||||
|
<strong>{{ toFaDigits(overview.month.messages) }} پیامک</strong>
|
||||||
|
<small>
|
||||||
|
{{ toFaDigits(overview.month.parts) }} پارت ·
|
||||||
|
{{ formatRials(overview.month.amountRials) }}
|
||||||
|
</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panels">
|
||||||
|
<div class="panel">
|
||||||
|
<h3>شارژ</h3>
|
||||||
|
<input v-model="chargeAmount" type="text" placeholder="مبلغ ریال" />
|
||||||
|
<input v-model="chargeNote" type="text" placeholder="یادداشت (اختیاری)" />
|
||||||
|
<button type="button" class="btn btn-primary" :disabled="saving" @click="charge">
|
||||||
|
ثبت شارژ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>تعرفه</h3>
|
||||||
|
<input v-model="priceInput" type="text" placeholder="قیمت هر پارت (ریال)" />
|
||||||
|
<button type="button" class="btn" :disabled="saving" @click="savePrice">
|
||||||
|
ذخیره تعرفه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>آستانه قطع خودکار</h3>
|
||||||
|
<p class="hint">
|
||||||
|
معمولاً منفی؛ مثلاً -500000 یعنی تا ۵۰۰ هزار ریال بدهی ارسال مجاز است. خالی =
|
||||||
|
فقط کلید دستی.
|
||||||
|
</p>
|
||||||
|
<input v-model="autoBlockInput" type="text" placeholder="مثلاً -500000" />
|
||||||
|
<button type="button" class="btn" :disabled="saving" @click="saveAutoBlock">
|
||||||
|
ذخیره آستانه
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>سامانه پیامک</h3>
|
||||||
|
<div class="mode-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:class="{ 'btn-primary': overview.account.panelMode === 'platform' }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="setPanelMode('platform')"
|
||||||
|
>
|
||||||
|
سامانه ما
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:class="{ 'btn-primary': overview.account.panelMode === 'own' }"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="setPanelMode('own')"
|
||||||
|
>
|
||||||
|
پنل اختصاصی مشتری
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template v-if="overview.account.panelMode === 'own'">
|
||||||
|
<p class="hint">با پنل اختصاصی، از کیف ما کسر و لاگ billing ثبت نمیشود.</p>
|
||||||
|
<select v-model="ownType">
|
||||||
|
<option value="0098">۰۰۹۸</option>
|
||||||
|
<option value="trez">ترز</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="ownUser" type="text" placeholder="نام کاربری" />
|
||||||
|
<input v-model="ownPass" type="password" placeholder="رمز (خالی = بدون تغییر)" />
|
||||||
|
<input v-model="ownFrom" type="text" placeholder="شماره فرستنده" />
|
||||||
|
<button type="button" class="btn" :disabled="saving" @click="saveOwnPanel">
|
||||||
|
ذخیره پنل اختصاصی
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tables">
|
||||||
|
<div>
|
||||||
|
<h3>آخرین ارسالها</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>گیرنده</th>
|
||||||
|
<th>پارت</th>
|
||||||
|
<th>مبلغ</th>
|
||||||
|
<th>منبع</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in overview.sendLogs" :key="row.id">
|
||||||
|
<td dir="ltr">{{ row.receiver }}</td>
|
||||||
|
<td>{{ toFaDigits(row.parts) }}</td>
|
||||||
|
<td>{{ formatRials(row.totalAmountRials) }}</td>
|
||||||
|
<td>{{ row.source || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="!overview.sendLogs.length" class="muted">هنوز لاگی نیست.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>گردش کیف</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>نوع</th>
|
||||||
|
<th>مبلغ</th>
|
||||||
|
<th>مانده</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in overview.ledgers" :key="row.id">
|
||||||
|
<td>{{ row.type }}</td>
|
||||||
|
<td>{{ formatRials(row.amountRials) }}</td>
|
||||||
|
<td>{{ formatRials(row.balanceAfterRials) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-if="!overview.ledgers.length" class="muted">گردشی ثبت نشده.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="detail-card empty">
|
||||||
|
<p class="muted">یک محیط را از فهرست انتخاب کنید.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.list-card,
|
||||||
|
.detail-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.list-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.list-head input,
|
||||||
|
.panel input,
|
||||||
|
.panel select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.tenant {
|
||||||
|
width: 100%;
|
||||||
|
text-align: start;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tenant.active {
|
||||||
|
border-color: #14b8a6;
|
||||||
|
background: #f0fdfa;
|
||||||
|
}
|
||||||
|
.tenant .name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.tenant small {
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.badge.bad {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.05rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.detail-head h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.kpis article {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.kpis span,
|
||||||
|
.kpis small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.kpis strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.85rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.mode-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #0f766e;
|
||||||
|
border-color: #0f766e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #b91c1c;
|
||||||
|
border-color: #b91c1c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-ok {
|
||||||
|
background: #15803d;
|
||||||
|
border-color: #15803d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.tables {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 0.4rem 0.25rem;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
min-height: 240px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.grid,
|
||||||
|
.kpis,
|
||||||
|
.panels,
|
||||||
|
.tables {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3866,8 +3866,23 @@ export const MESSAGE_AUTOMATION_FIELDS = [
|
|||||||
{ token: "{شرکت}", label: "نام شرکت" },
|
{ token: "{شرکت}", label: "نام شرکت" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** First-part Persian SMS limit (legacy UI estimate). Prefer countSmsParts(). */
|
||||||
export const MESSAGE_AUTOMATION_SMS_SEGMENT = 70;
|
export const MESSAGE_AUTOMATION_SMS_SEGMENT = 70;
|
||||||
|
|
||||||
|
/** Hamkar-compatible Persian multipart limits: 70 / 64 / 67 */
|
||||||
|
export function countSmsParts(text: string): number {
|
||||||
|
const length = [...String(text ?? "").trim()].length;
|
||||||
|
if (length <= 0) return 0;
|
||||||
|
const first = 70;
|
||||||
|
const second = 64;
|
||||||
|
const rest = 67;
|
||||||
|
if (length <= first) return 1;
|
||||||
|
if (length <= first + second) return 2;
|
||||||
|
const thirdThreshold = first + second + rest;
|
||||||
|
if (length <= thirdThreshold) return 3;
|
||||||
|
return 3 + Math.ceil((length - thirdThreshold) / rest);
|
||||||
|
}
|
||||||
|
|
||||||
export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
|
export const MESSAGE_AUTOMATION_CHANNEL_LABELS: Record<
|
||||||
MessageAutomationChannel,
|
MessageAutomationChannel,
|
||||||
string
|
string
|
||||||
@@ -5734,6 +5749,94 @@ export interface TreasuryDashboardDto {
|
|||||||
upcomingPayables: FinancialDocumentDto[];
|
upcomingPayables: FinancialDocumentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────── کیفپول پیامک (صورتحساب محیط) ───────── */
|
||||||
|
|
||||||
|
export type SmsBalanceLevel = "healthy" | "warning" | "critical" | "unknown";
|
||||||
|
export type SmsPanelMode = "platform" | "own";
|
||||||
|
|
||||||
|
export interface SmsWalletSummaryDto {
|
||||||
|
billingEnabled: boolean;
|
||||||
|
balanceRials: number;
|
||||||
|
pricePerPartRials: number;
|
||||||
|
isBlocked: boolean;
|
||||||
|
autoBlocked: boolean;
|
||||||
|
balanceLevel: SmsBalanceLevel;
|
||||||
|
panelMode: SmsPanelMode | string;
|
||||||
|
chargeInfo: SmsChargeInfoDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsChargeInfoDto {
|
||||||
|
card: string;
|
||||||
|
iban: string;
|
||||||
|
accountHolder: string;
|
||||||
|
instruction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformSmsTenantRowDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isBlocked: boolean;
|
||||||
|
panelMode: string;
|
||||||
|
sms: {
|
||||||
|
balanceRials: number;
|
||||||
|
pricePerPartRials: number;
|
||||||
|
autoBlockBelowRials: number | null;
|
||||||
|
balanceLevel: SmsBalanceLevel;
|
||||||
|
billingEnabled: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformSmsOverviewDto {
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
account: {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
balanceRials: number;
|
||||||
|
pricePerPartRials: number;
|
||||||
|
isBlocked: boolean;
|
||||||
|
autoBlockBelowRials: number | null;
|
||||||
|
alertCriticalRials: number;
|
||||||
|
alertWarningRials: number;
|
||||||
|
panelMode: SmsPanelMode;
|
||||||
|
ownPanelType: string;
|
||||||
|
ownPanelUsername: string | null;
|
||||||
|
ownPanelFrom: string | null;
|
||||||
|
hasOwnPanelPassword: boolean;
|
||||||
|
balanceLevel: SmsBalanceLevel;
|
||||||
|
billingEnabled: boolean;
|
||||||
|
};
|
||||||
|
chargeInfo: SmsChargeInfoDto;
|
||||||
|
today: { messages: number; parts: number; amountRials: number };
|
||||||
|
month: { messages: number; parts: number; amountRials: number };
|
||||||
|
ledgers: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
amountRials: number;
|
||||||
|
balanceAfterRials: number;
|
||||||
|
parts: number | null;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
sendLogs: Array<{
|
||||||
|
id: string;
|
||||||
|
receiver: string;
|
||||||
|
body: string;
|
||||||
|
parts: number;
|
||||||
|
totalAmountRials: number;
|
||||||
|
provider: string | null;
|
||||||
|
source: string | null;
|
||||||
|
isAlert: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
|
/* ───────── باشگاه امتیازات (گیمیفیکیشن کارکنان) ───────── */
|
||||||
|
|
||||||
export * from "./gamification";
|
export * from "./gamification";
|
||||||
|
|||||||
Reference in New Issue
Block a user