diff --git a/.env.example b/.env.example index 8e1cd7c..a563350 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ CENTRAL_SMS_PLATFORM="superadmin" # رمز مشترک SMS ریموت (HMAC + دامنه) — همان مقدار روی همه نسخه‌های همکار/خانگی SMS_REMOTE_SHARED_SECRET="replace-with-long-random-shared-secret" +# همگام‌سازی / مهاجرت SMS از MySQL یک نسخه (هر بار یک دامنه) +# SMS_IMPORT_DOMAIN=montpar.ir +# SMS_IMPORT_PRODUCT=hamkar +# SMS_IMPORT_DRY_RUN=1 +# SMS_IMPORT_HISTORY=1 HAMKAR_DB_HOST=127.0.0.1 HAMKAR_DB_PORT=3306 HAMKAR_DB_USER=root diff --git a/package.json b/package.json index 241264c..58b6d9c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "prisma:backfill-appearance": "ts-node --transpile-only prisma/backfill-appearance-from-registry.ts", "prisma:seed-hamkar-defaults": "ts-node --transpile-only prisma/seed-hamkar-default-snapshots.ts", "prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts", - "prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts" + "prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts", + "prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts" }, "prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" diff --git a/prisma/import-sms-from-product.ts b/prisma/import-sms-from-product.ts new file mode 100644 index 0000000..ea4e6ce --- /dev/null +++ b/prisma/import-sms-from-product.ts @@ -0,0 +1,410 @@ +/** + * Import real SMS wallet data from one Hamkar/Khanegi MySQL into SuperAdmin. + * + * Each sold version has its own MySQL (sms_wallets is single-row per server). + * Run once per version (SSH tunnel / on that DB host), matching SuperAdmin tenant by domain. + * + * Env: + * SMS_IMPORT_DOMAIN=montpar.ir # required (or SMS_IMPORT_TENANT_ID) + * SMS_IMPORT_PRODUCT=hamkar|khanegi # default: hamkar (charge-info key mapping) + * SMS_IMPORT_HISTORY=1 # also copy ledger + send logs (default 1) + * SMS_IMPORT_DRY_RUN=1 # print only, no writes + * SMS_IMPORT_REPLACE_HISTORY=1 # delete existing SA ledger/logs before copy (default 1 when HISTORY=1) + * + * DB (hamkar defaults from HAMKAR_DB_*; override with SMS_IMPORT_DB_*): + * SMS_IMPORT_DB_HOST / PORT / USER / PASSWORD / DATABASE + * + * Usage: + * cd apps/api + * npm run prisma:import-sms-from-product + */ +import * as mysql from 'mysql2/promise'; +import { PrismaClient, SmsLedgerType } from '@prisma/client'; + +const prisma = new PrismaClient(); + +type WalletRow = { + balance: number | string; + price_per_part: number | string; + is_blocked: number | boolean; + alert_days_sent?: number; + last_alert_date?: Date | string | null; +}; + +type SettingRow = { key: string; value: string | null }; + +type LedgerRow = { + type: string; + amount: number | string; + balance_after: number | string; + price_per_part: number | string | null; + parts: number | string | null; + note: string | null; + created_by: number | string | null; + created_at: Date | string; +}; + +type SendLogRow = { + receiver: string; + body: string; + parts: number | string; + price_per_part: number | string; + total_amount: number | string; + provider: string | null; + source: string | null; + is_alert: number | boolean; + created_at: Date | string; +}; + +function env(name: string, fallback = '') { + return String(process.env[name] ?? fallback).trim(); +} + +function asInt(value: unknown, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? Math.trunc(n) : fallback; +} + +function asBool(value: unknown) { + return value === true || value === 1 || value === '1'; +} + +function asDate(value: Date | string) { + const d = value instanceof Date ? value : new Date(value); + return Number.isNaN(d.getTime()) ? new Date() : d; +} + +function normalizeHost(value?: string | null) { + if (!value) return ''; + return String(value) + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .split('/')[0] + .split(':')[0]; +} + +function mapLedgerType(raw: string): SmsLedgerType { + const t = String(raw || '').toLowerCase(); + if (t === 'charge') return 'charge'; + if (t === 'alert_debit' || t === 'debit') return 'debit'; + if (t === 'adjust') return 'adjust'; + return 'debit'; +} + +async function tableExists(conn: mysql.Connection, table: string) { + const db = + env('SMS_IMPORT_DB_DATABASE') || + env('HAMKAR_DB_DATABASE') || + env('KHANEGI_DB_DATABASE'); + const [rows] = await conn.query( + `SELECT 1 AS ok + FROM information_schema.tables + WHERE table_schema = ? AND table_name = ? + LIMIT 1`, + [db, table], + ); + return Array.isArray(rows) && rows.length > 0; +} + +async function findTenant(domainOrId: string) { + if (env('SMS_IMPORT_TENANT_ID')) { + const byId = await prisma.tenant.findUnique({ + where: { id: env('SMS_IMPORT_TENANT_ID') }, + }); + if (!byId) throw new Error(`Tenant id not found: ${env('SMS_IMPORT_TENANT_ID')}`); + return byId; + } + + const host = normalizeHost(domainOrId); + if (!host) throw new Error('SMS_IMPORT_DOMAIN or SMS_IMPORT_TENANT_ID is required'); + + const tenants = await prisma.tenant.findMany({ + where: { isActive: true }, + }); + + const hit = tenants.find((t) => { + const candidates = new Set(); + const primary = normalizeHost(t.domain); + if (primary) candidates.add(primary); + const extraRaw = t.domains as unknown; + const extra = Array.isArray(extraRaw) + ? extraRaw + : typeof extraRaw === 'string' + ? (() => { + try { + const parsed = JSON.parse(extraRaw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + })() + : []; + for (const d of extra) { + const n = normalizeHost(String(d)); + if (n) candidates.add(n); + } + return candidates.has(host); + }); + + if (!hit) throw new Error(`No SuperAdmin tenant matched domain: ${host}`); + return hit; +} + +function chargeKeys(product: string) { + if (product === 'khanegi') { + return { + card: 'sms_charge_card_number', + iban: 'sms_charge_sheba', + holder: 'sms_charge_account_holder', + instruction: 'sms_charge_note', + }; + } + return { + card: 'sms_charge_card', + iban: 'sms_charge_iban', + holder: 'sms_charge_account_holder', + instruction: 'sms_charge_instruction', + }; +} + +async function main() { + const domain = env('SMS_IMPORT_DOMAIN'); + const product = (env('SMS_IMPORT_PRODUCT', 'hamkar') || 'hamkar').toLowerCase(); + const dryRun = env('SMS_IMPORT_DRY_RUN') === '1'; + const withHistory = env('SMS_IMPORT_HISTORY', '1') !== '0'; + const replaceHistory = env('SMS_IMPORT_REPLACE_HISTORY', withHistory ? '1' : '0') === '1'; + + const host = + env('SMS_IMPORT_DB_HOST') || + (product === 'khanegi' ? env('KHANEGI_DB_HOST') : env('HAMKAR_DB_HOST')) || + '127.0.0.1'; + const port = Number( + env('SMS_IMPORT_DB_PORT') || + (product === 'khanegi' ? env('KHANEGI_DB_PORT') : env('HAMKAR_DB_PORT')) || + '3306', + ); + const user = + env('SMS_IMPORT_DB_USER') || + (product === 'khanegi' ? env('KHANEGI_DB_USER') : env('HAMKAR_DB_USER')) || + 'root'; + const password = + env('SMS_IMPORT_DB_PASSWORD') || + (product === 'khanegi' ? env('KHANEGI_DB_PASSWORD') : env('HAMKAR_DB_PASSWORD')) || + ''; + const database = + env('SMS_IMPORT_DB_DATABASE') || + (product === 'khanegi' ? env('KHANEGI_DB_DATABASE') : env('HAMKAR_DB_DATABASE')); + + if (!database) { + throw new Error('MySQL database name is required (SMS_IMPORT_DB_DATABASE / HAMKAR_DB_DATABASE)'); + } + + const tenant = await findTenant(domain); + console.log( + JSON.stringify( + { + dryRun, + product, + withHistory, + replaceHistory, + tenant: { + id: tenant.id, + nameFa: tenant.nameFa, + domain: tenant.domain, + product: tenant.product, + }, + mysql: { host, port, user, database }, + }, + null, + 2, + ), + ); + + const conn = await mysql.createConnection({ + host, + port, + user, + password, + database, + dateStrings: false, + }); + + try { + if (!(await tableExists(conn, 'sms_wallets'))) { + console.log('SKIP: sms_wallets table missing — billing not installed on this version.'); + return; + } + + let smsPanel = ''; + if (await tableExists(conn, 'settings')) { + const [panelRows] = await conn.query( + `SELECT \`value\` AS v FROM settings WHERE \`key\` = 'sms_panel' LIMIT 1`, + ); + const first = Array.isArray(panelRows) ? (panelRows[0] as { v?: string }) : null; + smsPanel = String(first?.v ?? '').trim(); + } + if (smsPanel === 'me') { + console.log('SKIP: sms_panel=me (customer own 0098 panel) — SuperAdmin billing not used.'); + return; + } + + const [walletRowsRaw] = await conn.query( + `SELECT balance, price_per_part, is_blocked, alert_days_sent, last_alert_date + FROM sms_wallets + ORDER BY id ASC + LIMIT 1`, + ); + const walletRows = walletRowsRaw as WalletRow[]; + if (!walletRows.length) { + console.log('SKIP: sms_wallets is empty — no real wallet yet.'); + return; + } + + const wallet = walletRows[0]; + const keys = chargeKeys(product); + const settings = new Map(); + if (await tableExists(conn, 'settings')) { + const [settingRowsRaw] = await conn.query( + `SELECT \`key\`, \`value\` FROM settings WHERE \`key\` IN (?, ?, ?, ?)`, + [keys.card, keys.iban, keys.holder, keys.instruction], + ); + for (const r of settingRowsRaw as SettingRow[]) { + settings.set(r.key, r.value == null ? '' : String(r.value)); + } + } + + const payload = { + balanceRials: asInt(wallet.balance), + pricePerPartRials: asInt(wallet.price_per_part), + isBlocked: asBool(wallet.is_blocked), + chargeCard: settings.get(keys.card) || null, + chargeIban: settings.get(keys.iban) || null, + chargeHolder: settings.get(keys.holder) || null, + chargeInstruction: settings.get(keys.instruction) || null, + }; + + console.log('Wallet source →', payload); + + let ledgers: LedgerRow[] = []; + let sendLogs: SendLogRow[] = []; + + if (withHistory) { + if (await tableExists(conn, 'sms_ledger')) { + const [rows] = await conn.query( + `SELECT type, amount, balance_after, price_per_part, parts, note, created_by, created_at + FROM sms_ledger + ORDER BY id ASC`, + ); + ledgers = rows as LedgerRow[]; + } + if (await tableExists(conn, 'sms_send_logs')) { + const [rows] = await conn.query( + `SELECT receiver, body, parts, price_per_part, total_amount, provider, source, is_alert, created_at + FROM sms_send_logs + ORDER BY id ASC`, + ); + sendLogs = rows as SendLogRow[]; + } + console.log(`History source → ledger=${ledgers.length}, send_logs=${sendLogs.length}`); + } + + if (dryRun) { + console.log('DRY RUN: no writes to SuperAdmin.'); + return; + } + + const account = await prisma.smsAccount.upsert({ + where: { tenantId: tenant.id }, + create: { + tenantId: tenant.id, + ...payload, + alertCriticalRials: 10000, + alertWarningRials: 100000, + }, + update: { + ...payload, + }, + }); + + if (withHistory) { + if (replaceHistory) { + await prisma.smsSendLog.deleteMany({ where: { smsAccountId: account.id } }); + await prisma.smsLedger.deleteMany({ where: { smsAccountId: account.id } }); + } + + const ledgerChunk = 200; + for (let i = 0; i < ledgers.length; i += ledgerChunk) { + const slice = ledgers.slice(i, i + ledgerChunk); + await prisma.smsLedger.createMany({ + data: slice.map((row) => ({ + smsAccountId: account.id, + type: mapLedgerType(row.type), + amountRials: asInt(row.amount), + balanceAfterRials: asInt(row.balance_after), + parts: row.parts == null ? null : asInt(row.parts), + note: row.note, + createdById: row.created_by == null ? null : String(row.created_by), + createdAt: asDate(row.created_at), + })), + }); + } + + const logChunk = 200; + for (let i = 0; i < sendLogs.length; i += logChunk) { + const slice = sendLogs.slice(i, i + logChunk); + await prisma.smsSendLog.createMany({ + data: slice.map((row) => ({ + smsAccountId: account.id, + receiver: String(row.receiver || '').slice(0, 32) || '—', + body: String(row.body || '—').slice(0, 2000), + parts: Math.max(1, asInt(row.parts, 1)), + pricePerPartRials: asInt(row.price_per_part), + totalAmountRials: asInt(row.total_amount), + provider: row.provider ? String(row.provider).slice(0, 64) : null, + source: row.source ? String(row.source).slice(0, 64) : null, + isAlert: asBool(row.is_alert), + createdAt: asDate(row.created_at), + })), + }); + } + } + + await prisma.auditLog.create({ + data: { + action: 'sms.import.from_product', + entityType: 'sms_account', + entityId: account.id, + tenantId: tenant.id, + after: { + product, + domain: tenant.domain, + balanceRials: payload.balanceRials, + pricePerPartRials: payload.pricePerPartRials, + isBlocked: payload.isBlocked, + ledgerCount: ledgers.length, + sendLogCount: sendLogs.length, + mysql: { host, database }, + }, + }, + }); + + console.log('OK: SuperAdmin SMS account updated from product MySQL.', { + smsAccountId: account.id, + balanceRials: payload.balanceRials, + ledger: ledgers.length, + sendLogs: sendLogs.length, + }); + } finally { + await conn.end(); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/sms/dto/sms-account.dto.ts b/src/sms/dto/sms-account.dto.ts index 1bc2de3..bb23125 100644 --- a/src/sms/dto/sms-account.dto.ts +++ b/src/sms/dto/sms-account.dto.ts @@ -7,7 +7,7 @@ import { Max, Min, } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Type, Transform } from 'class-transformer'; export class UpdateSmsAccountDto { @IsOptional() @@ -144,3 +144,56 @@ export class RemoteSmsConsumeDto { isAlert?: boolean; } +/** Product panel → charge wallet (amount or amountRials) */ +export class RemoteSmsChargeDto { + @IsOptional() + @Type(() => Number) + @IsInt() + amount?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + amountRials?: number; + + @IsOptional() + @IsString() + note?: string; +} + +export class RemoteSmsPriceDto { + @Type(() => Number) + @IsInt() + @Min(0) + price_per_part!: number; +} + +export class RemoteSmsBlockDto { + @Transform(({ value }) => { + if (value === true || value === 'true' || value === 1 || value === '1') return true; + if (value === false || value === 'false' || value === 0 || value === '0') return false; + return value; + }) + @IsBoolean() + is_blocked!: boolean; +} + +/** Product charge-info payload (Hamkar/Khanegi snake_case) */ +export class RemoteSmsChargeInfoDto { + @IsOptional() + @IsString() + card?: string; + + @IsOptional() + @IsString() + iban?: string; + + @IsOptional() + @IsString() + account_holder?: string; + + @IsOptional() + @IsString() + instruction?: string; +} + diff --git a/src/sms/remote-sms.controller.ts b/src/sms/remote-sms.controller.ts index 3a5c051..d82852b 100644 --- a/src/sms/remote-sms.controller.ts +++ b/src/sms/remote-sms.controller.ts @@ -1,7 +1,22 @@ -import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; import { RemoteTenant } from '../auth/remote-tenant.decorator'; import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard'; -import { RemoteSmsConsumeDto } from './dto/sms-account.dto'; +import { + RemoteSmsBlockDto, + RemoteSmsChargeDto, + RemoteSmsChargeInfoDto, + RemoteSmsConsumeDto, + RemoteSmsPriceDto, + SmsTableQueryDto, +} from './dto/sms-account.dto'; import { SmsService } from './sms.service'; type RemoteTenantInfo = { @@ -13,14 +28,15 @@ type RemoteTenantInfo = { }; /** - * Product apps → SuperAdmin SMS wallet. + * Product apps → SuperAdmin SMS wallet + panel sync. * * Auth (preferred): shared HMAC * X-Abshode-Domain, X-Abshode-Timestamp, X-Abshode-Signature * Fallback: Authorization Bearer {Tenant.remoteApiToken} * - * GET /api/remote/sms/status?parts=1 - * POST /api/remote/sms/consume + * Gate: GET /api/remote/sms/status + * Consume: POST /api/remote/sms/consume + * Panel: overview / charge-info / charge / price / block / logs / ledger / report */ @Controller('remote/sms') @UseGuards(RemoteSmsAuthGuard) @@ -45,4 +61,131 @@ export class RemoteSmsController { ) { return this.smsService.remoteConsume(tenant.id, dto); } + + @Get('overview') + overview(@RemoteTenant() tenant: RemoteTenantInfo) { + return this.smsService.remoteOverview(tenant.id); + } + + @Get('balance') + balance(@RemoteTenant() tenant: RemoteTenantInfo) { + return this.smsService.remoteBalance(tenant.id); + } + + @Get('charge-info') + chargeInfo(@RemoteTenant() tenant: RemoteTenantInfo) { + return this.smsService.remoteChargeInfo(tenant.id); + } + + @Put('charge-info') + updateChargeInfo( + @RemoteTenant() tenant: RemoteTenantInfo, + @Body() dto: RemoteSmsChargeInfoDto, + ) { + return this.smsService.remoteUpdateChargeInfo(tenant.id, dto); + } + + @Post('charge') + charge( + @RemoteTenant() tenant: RemoteTenantInfo, + @Body() dto: RemoteSmsChargeDto, + ) { + return this.smsService.remoteCharge(tenant.id, dto); + } + + @Put('price') + updatePrice( + @RemoteTenant() tenant: RemoteTenantInfo, + @Body() dto: RemoteSmsPriceDto, + ) { + return this.smsService.remoteUpdatePrice(tenant.id, dto.price_per_part); + } + + @Put('block') + updateBlock( + @RemoteTenant() tenant: RemoteTenantInfo, + @Body() dto: RemoteSmsBlockDto, + ) { + return this.smsService.remoteSetBlocked(tenant.id, dto.is_blocked); + } + + @Post('logs/table') + logsTable( + @RemoteTenant() tenant: RemoteTenantInfo, + @Body() body: Record, + ) { + return this.smsService.remoteLogsTable( + tenant.id, + this.toTableQuery(body), + body, + ); + } + + @Post('ledger/table') + ledgerTable( + @RemoteTenant() tenant: RemoteTenantInfo, + @Body() body: Record, + ) { + return this.smsService.remoteLedgerTable( + tenant.id, + this.toTableQuery(body), + body, + ); + } + + @Get('report') + report( + @RemoteTenant() tenant: RemoteTenantInfo, + @Query('from_date') fromDate?: string, + @Query('to_date') toDate?: string, + @Query('fromDate') fromDateCamel?: string, + @Query('toDate') toDateCamel?: string, + ) { + return this.smsService.remotePeriodReport( + tenant.id, + fromDate || fromDateCamel, + toDate || toDateCamel, + ); + } + + @Get('report/charges') + chargeReport( + @RemoteTenant() tenant: RemoteTenantInfo, + @Query('from_date') fromDate?: string, + @Query('to_date') toDate?: string, + @Query('fromDate') fromDateCamel?: string, + @Query('toDate') toDateCamel?: string, + ) { + return this.smsService.remoteChargeReport( + tenant.id, + fromDate || fromDateCamel, + toDate || toDateCamel, + ); + } + + private toTableQuery(body: Record): SmsTableQueryDto { + const page = Number(body.page) || 1; + const pageSize = + Number(body.pageSize) || Number(body.limit) || 20; + const search = + (typeof body.search === 'string' && body.search) || + (typeof body.text_search === 'string' && body.text_search) || + undefined; + const fromDate = + (typeof body.from_date === 'string' && body.from_date) || + (typeof body.fromDate === 'string' && body.fromDate) || + undefined; + const toDate = + (typeof body.to_date === 'string' && body.to_date) || + (typeof body.toDate === 'string' && body.toDate) || + undefined; + + return { + page: Math.max(1, page), + pageSize: Math.min(100, Math.max(1, pageSize)), + search, + fromDate, + toDate, + }; + } } diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index d962455..9f4aac3 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -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 = {}, + ) { + 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 = {}, + ) { + 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[], + rawBody: Record, + ) { + 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,