Add remote SMS panel sync API and product MySQL import script.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:53:59 +03:30
co-authored by Cursor
parent 57654e295c
commit 17a54774ae
6 changed files with 850 additions and 13 deletions
+231 -6
View File
@@ -13,6 +13,8 @@ import { AdminNotifyService } from '../notifications/admin-notify.service';
import { iranYmd } from '../common/iran-time.util';
import {
ChargeSmsWalletDto,
RemoteSmsChargeDto,
RemoteSmsChargeInfoDto,
RemoteSmsConsumeDto,
SmsTableQueryDto,
UpdateSmsAccountDto,
@@ -86,7 +88,7 @@ export class SmsService {
summary: await this.toSummaryFromLogs(account),
ledgers,
safeNote:
'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمی‌دهد.',
'منبع حقیقت کیف پول SMS این پنل است؛ پنل پیامک همکار/خانگی وقتی remote فعال باشد از همین داده همگام می‌شود.',
};
}
@@ -365,6 +367,231 @@ export class SmsService {
return result;
}
/** Product panel: overview in Hamkar/Khanegi shape */
async remoteOverview(tenantId: string) {
const account = await this.ensureAccount(tenantId);
const usage = await this.computeUsageStats(account.id);
const charge = this.chargeInfoOf(account);
return {
wallet: {
balance: account.balanceRials,
price_per_part: account.pricePerPartRials,
is_blocked: account.isBlocked,
alert_days_sent: 0,
last_alert_date: null as string | null,
},
charge_info: {
card: charge.card,
iban: charge.iban,
account_holder: charge.accountHolder,
instruction: charge.instruction,
},
stats: {
today: {
parts: usage.today.parts,
amount: usage.today.amount,
messages: usage.today.messages,
},
month: {
parts: usage.month.parts,
amount: usage.month.amount,
messages: usage.month.messages,
},
},
source: 'superadmin',
};
}
async remoteBalance(tenantId: string) {
const account = await this.ensureAccount(tenantId);
return {
balance: account.balanceRials,
is_blocked: account.isBlocked,
price_per_part: account.pricePerPartRials,
source: 'superadmin',
};
}
async remoteChargeInfo(tenantId: string) {
const charge = await this.getChargeInfo(tenantId);
return {
card: charge.card,
iban: charge.iban,
account_holder: charge.accountHolder,
instruction: charge.instruction,
source: 'superadmin',
};
}
async remoteUpdateChargeInfo(tenantId: string, dto: RemoteSmsChargeInfoDto) {
const updated = await this.updateChargeInfo(tenantId, {
chargeCard: dto.card,
chargeIban: dto.iban,
chargeHolder: dto.account_holder,
chargeInstruction: dto.instruction,
});
return {
message: 'اطلاعات واریز شارژ ذخیره شد.',
charge_info: {
card: updated.card,
iban: updated.iban,
account_holder: updated.accountHolder,
instruction: updated.instruction,
},
source: 'superadmin',
};
}
async remoteCharge(tenantId: string, dto: RemoteSmsChargeDto) {
const amountRials = dto.amountRials ?? dto.amount;
if (amountRials == null || !Number.isFinite(amountRials) || amountRials === 0) {
throw new BadRequestException('مبلغ شارژ نامعتبر است');
}
await this.chargeWallet(tenantId, {
amountRials,
note: dto.note,
});
const account = await this.ensureAccount(tenantId);
return {
message: 'کیف پول با موفقیت شارژ شد.',
balance: account.balanceRials,
price_per_part: account.pricePerPartRials,
is_blocked: account.isBlocked,
source: 'superadmin',
};
}
async remoteUpdatePrice(tenantId: string, pricePerPart: number) {
await this.updateAccount(tenantId, {
pricePerPartRials: Math.max(0, Math.floor(pricePerPart) || 0),
});
const account = await this.ensureAccount(tenantId);
return {
message: 'قیمت هر پارت به‌روزرسانی شد.',
balance: account.balanceRials,
price_per_part: account.pricePerPartRials,
is_blocked: account.isBlocked,
source: 'superadmin',
};
}
async remoteSetBlocked(tenantId: string, isBlocked: boolean) {
await this.setBlocked(tenantId, isBlocked);
const account = await this.ensureAccount(tenantId);
return {
message: isBlocked ? 'ارسال پیامک مسدود شد.' : 'ارسال پیامک فعال شد.',
balance: account.balanceRials,
price_per_part: account.pricePerPartRials,
is_blocked: account.isBlocked,
source: 'superadmin',
};
}
async remoteLogsTable(
tenantId: string,
dto: SmsTableQueryDto,
rawBody: Record<string, unknown> = {},
) {
const result = await this.getLogsTable(tenantId, dto);
return this.toProductTable(
result,
result.rows.map((row) => ({
id: row.id,
receiver: row.receiver,
body: row.body,
parts: row.parts,
price_per_part: row.pricePerPart,
total_amount: row.totalAmount,
provider: row.provider,
source: row.source,
is_alert: row.isAlert,
created_at: row.createdAt,
})),
rawBody,
);
}
async remoteLedgerTable(
tenantId: string,
dto: SmsTableQueryDto,
rawBody: Record<string, unknown> = {},
) {
const result = await this.getLedgerTable(tenantId, dto);
return this.toProductTable(
result,
result.rows.map((row) => ({
id: row.id,
type: row.type,
amount: row.amount,
balance_after: row.balanceAfter,
price_per_part: row.pricePerPart,
parts: row.parts,
note: row.note,
created_by: row.createdBy,
created_at: row.createdAt,
})),
rawBody,
);
}
async remotePeriodReport(tenantId: string, fromDate?: string, toDate?: string) {
const report = await this.getPeriodReport(tenantId, fromDate, toDate);
return {
from_date: report.fromDate,
to_date: report.toDate,
balance: report.balance,
summary: report.summary,
by_provider: report.byProvider.map((row) => ({
provider: row.provider,
messages: row.messages,
parts: row.parts,
amount: row.amount,
})),
source: 'superadmin',
};
}
async remoteChargeReport(tenantId: string, fromDate?: string, toDate?: string) {
const report = await this.getChargeReport(tenantId, fromDate, toDate);
return {
from_date: report.fromDate,
to_date: report.toDate,
summary: report.summary,
items: report.items.map((row) => ({
id: row.id,
amount: row.amount,
balance_after: row.balanceAfter,
note: row.note,
created_by: row.createdBy,
created_at: row.createdAt,
})),
source: 'superadmin',
};
}
private toProductTable(
result: { page: number; pageSize: number; total: number },
rows: Record<string, unknown>[],
rawBody: Record<string, unknown>,
) {
return {
query: {
limit: result.pageSize,
filters: rawBody.filters ?? null,
filtersOpt: rawBody.filtersOpt ?? null,
search: rawBody.search ?? rawBody.text_search ?? null,
key: rawBody.key ?? null,
select: rawBody.select ?? null,
total: result.total,
sort: null,
page: result.page,
},
rows,
source: 'superadmin',
};
}
async getPeriodReport(tenantId: string, fromDate?: string, toDate?: string) {
const account = await this.ensureAccount(tenantId);
const range = this.resolveDateRange(fromDate, toDate, true);
@@ -561,14 +788,12 @@ export class SmsService {
const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } });
if (existing) return existing;
const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000];
const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
const balanceRials = demoBalances[hash % demoBalances.length];
// Start at zero — real balances come from product MySQL import
// (prisma/import-sms-from-product.ts), not demo numbers.
return this.prisma.smsAccount.create({
data: {
tenantId,
balanceRials,
balanceRials: 0,
pricePerPartRials: 2500,
alertCriticalRials: 10000,
alertWarningRials: 100000,