Add remote SMS panel sync API and product MySQL import script.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
@@ -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<string>();
|
||||
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<string, string>();
|
||||
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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) {
|
||||
return this.smsService.remoteLogsTable(
|
||||
tenant.id,
|
||||
this.toTableQuery(body),
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('ledger/table')
|
||||
ledgerTable(
|
||||
@RemoteTenant() tenant: RemoteTenantInfo,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
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<string, unknown>): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+231
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user