import { Injectable } from '@nestjs/common'; import * as ExcelJS from 'exceljs'; import { PrismaService } from '../prisma/prisma.service'; import { formatIranDate, formatIranDateTime, iranDaysUntil, } from '../common/iran-time.util'; const PRODUCT_LABEL: Record = { hamkar: 'همکار', khanegi: 'خانگی', other: 'سایت‌های دیگر', }; @Injectable() export class ExportService { constructor(private readonly prisma: PrismaService) {} async buildWorkbook(): Promise<{ buffer: Buffer; filename: string }> { const tenants = await this.prisma.tenant.findMany({ orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], include: { smsAccount: true, serverAccounts: { orderBy: { createdAt: 'asc' } }, pluginSubscriptions: { orderBy: { createdAt: 'asc' } }, supportSubscriptions: { orderBy: { createdAt: 'asc' } }, }, }); const workbook = new ExcelJS.Workbook(); workbook.creator = 'SuperAdmin Abshode'; workbook.created = new Date(); this.addProjectsSheet(workbook, tenants); this.addServersSheet(workbook, tenants); this.addPluginsSheet(workbook, tenants); this.addSupportsSheet(workbook, tenants); this.addSmsSheet(workbook, tenants); const arrayBuffer = await workbook.xlsx.writeBuffer(); const buffer = Buffer.from(arrayBuffer); const stamp = formatIranDate(new Date()).replace(/\//g, '-'); return { buffer, filename: `superadmin-export-${stamp}.xlsx`, }; } private addProjectsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('پروژه‌ها', { views: [{ rightToLeft: true }], }); sheet.columns = [ { header: 'محصول', key: 'product', width: 14 }, { header: 'نام فارسی', key: 'nameFa', width: 24 }, { header: 'نام لاتین', key: 'nameEn', width: 18 }, { header: 'slug', key: 'slug', width: 18 }, { header: 'دامنه اصلی', key: 'domain', width: 22 }, { header: 'دامنه‌های اضافی', key: 'domains', width: 28 }, { header: 'فعال', key: 'isActive', width: 8 }, { header: 'مسدود دسترسی سرور', key: 'serverAccessBlocked', width: 16 }, { header: 'ورژن اپ', key: 'appVersion', width: 10 }, { header: 'ظاهر خانگی', key: 'uiVariant', width: 12 }, { header: 'node_server', key: 'nodeServer', width: 28 }, { header: 'node_ws', key: 'nodeWs', width: 24 }, { header: 'app_url', key: 'appUrl', width: 24 }, { header: 'admin_url', key: 'adminUrl', width: 24 }, { header: 'رنگ اصلی', key: 'primaryColor', width: 12 }, { header: 'رنگ ثانویه', key: 'secondaryColor', width: 12 }, { header: 'رنگ پس‌زمینه', key: 'backgroundColor', width: 12 }, { header: 'لوگو', key: 'avatarUrl', width: 28 }, { header: 'تعداد سرور', key: 'serversCount', width: 10 }, { header: 'تعداد افزونه', key: 'pluginsCount', width: 10 }, { header: 'تعداد پشتیبانی', key: 'supportsCount', width: 12 }, { header: 'موجودی پیامک (تومان)', key: 'smsBalanceToman', width: 16 }, { header: 'پیامک مسدود', key: 'smsBlocked', width: 12 }, { header: 'یادداشت', key: 'notes', width: 30 }, { header: 'تاریخ ایجاد', key: 'createdAt', width: 18 }, { header: 'آخرین به‌روزرسانی', key: 'updatedAt', width: 18 }, ]; this.styleHeader(sheet); for (const t of tenants) { const balanceRials = t.smsAccount?.balanceRials; sheet.addRow({ product: PRODUCT_LABEL[t.product] || t.product, nameFa: t.nameFa, nameEn: t.nameEn || '', slug: t.slug, domain: t.domain, domains: this.formatDomains(t.domains), isActive: t.isActive ? 'بله' : 'خیر', serverAccessBlocked: t.serverAccessBlocked ? 'بله' : 'خیر', appVersion: t.appVersion || '', uiVariant: t.product === 'khanegi' ? t.uiVariant : '', nodeServer: t.nodeServer || '', nodeWs: t.nodeWs || '', appUrl: t.appUrl || '', adminUrl: t.adminUrl || '', primaryColor: t.primaryColor || '', secondaryColor: t.secondaryColor || '', backgroundColor: t.backgroundColor || '', avatarUrl: t.avatarUrl || '', serversCount: t.serverAccounts.length, pluginsCount: t.pluginSubscriptions.length, supportsCount: t.supportSubscriptions.length, smsBalanceToman: balanceRials == null ? '' : Math.round(balanceRials / 10), smsBlocked: t.smsAccount?.isBlocked ? 'بله' : t.smsAccount ? 'خیر' : '', notes: t.notes || '', createdAt: formatIranDateTime(t.createdAt), updatedAt: formatIranDateTime(t.updatedAt), }); } } private addServersSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('سرورها', { views: [{ rightToLeft: true }], }); sheet.columns = [ { header: 'محصول', key: 'product', width: 14 }, { header: 'نام پروژه', key: 'tenantName', width: 24 }, { header: 'دامنه', key: 'domain', width: 22 }, { header: 'نام سرور', key: 'serverName', width: 20 }, { header: 'اختصاصی', key: 'isDedicated', width: 10 }, { header: 'مشخصات', key: 'specs', width: 28 }, { header: 'قیمت (تومان)', key: 'priceToman', width: 14 }, { header: 'مدت (ماه)', key: 'planMonths', width: 10 }, { header: 'تاریخ شارژ', key: 'chargedAt', width: 14 }, { header: 'تاریخ انقضا', key: 'expiresAt', width: 14 }, { header: 'روز تا انقضا', key: 'daysLeft', width: 12 }, { header: 'هشدار (روز قبل)', key: 'alertDaysBefore', width: 14 }, { header: 'توضیحات', key: 'description', width: 28 }, ]; this.styleHeader(sheet); for (const t of tenants) { for (const s of t.serverAccounts) { sheet.addRow({ product: PRODUCT_LABEL[t.product] || t.product, tenantName: t.nameFa, domain: t.domain, serverName: s.serverName || '', isDedicated: s.isDedicated ? 'بله' : 'خیر', specs: s.specs || '', priceToman: s.priceRials == null ? '' : Math.round(s.priceRials / 10), planMonths: s.planMonths ?? '', chargedAt: s.chargedAt ? formatIranDate(s.chargedAt) : '', expiresAt: s.expiresAt ? formatIranDate(s.expiresAt) : '', daysLeft: s.expiresAt ? this.daysLeftLabel(s.expiresAt) : '', alertDaysBefore: s.alertDaysBefore, description: s.description || '', }); } } } private addPluginsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('افزونه‌ها', { views: [{ rightToLeft: true }], }); sheet.columns = [ { header: 'محصول', key: 'product', width: 14 }, { header: 'نام پروژه', key: 'tenantName', width: 24 }, { header: 'دامنه', key: 'domain', width: 22 }, { header: 'نام افزونه', key: 'name', width: 22 }, { header: 'کلید تنظیمات', key: 'settingKey', width: 18 }, { header: 'فعال', key: 'isActive', width: 8 }, { header: 'مشخصات', key: 'specs', width: 24 }, { header: 'قیمت (تومان)', key: 'priceToman', width: 14 }, { header: 'مدت (ماه)', key: 'planMonths', width: 10 }, { header: 'تاریخ شارژ', key: 'chargedAt', width: 14 }, { header: 'تاریخ انقضا', key: 'expiresAt', width: 14 }, { header: 'روز تا انقضا', key: 'daysLeft', width: 12 }, { header: 'قطع خودکار', key: 'autoCutOnExpiry', width: 12 }, { header: 'هشدار (روز قبل)', key: 'alertDaysBefore', width: 14 }, { header: 'توضیحات', key: 'description', width: 28 }, ]; this.styleHeader(sheet); for (const t of tenants) { for (const p of t.pluginSubscriptions) { sheet.addRow({ product: PRODUCT_LABEL[t.product] || t.product, tenantName: t.nameFa, domain: t.domain, name: p.name, settingKey: p.settingKey || '', isActive: p.isActive ? 'بله' : 'خیر', specs: p.specs || '', priceToman: p.priceRials == null ? '' : Math.round(p.priceRials / 10), planMonths: p.planMonths ?? '', chargedAt: p.chargedAt ? formatIranDate(p.chargedAt) : '', expiresAt: p.expiresAt ? formatIranDate(p.expiresAt) : '', daysLeft: p.expiresAt ? this.daysLeftLabel(p.expiresAt) : '', autoCutOnExpiry: p.autoCutOnExpiry ? 'بله' : 'خیر', alertDaysBefore: p.alertDaysBefore, description: p.description || '', }); } } } private addSupportsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('پشتیبانی', { views: [{ rightToLeft: true }], }); sheet.columns = [ { header: 'محصول', key: 'product', width: 14 }, { header: 'نام پروژه', key: 'tenantName', width: 24 }, { header: 'دامنه', key: 'domain', width: 22 }, { header: 'نام پشتیبانی', key: 'name', width: 22 }, { header: 'فعال', key: 'isActive', width: 8 }, { header: 'رایگان', key: 'isFree', width: 8 }, { header: 'مشخصات', key: 'specs', width: 24 }, { header: 'قیمت (تومان)', key: 'priceToman', width: 14 }, { header: 'مدت (ماه)', key: 'planMonths', width: 10 }, { header: 'تاریخ شارژ', key: 'chargedAt', width: 14 }, { header: 'تاریخ انقضا', key: 'expiresAt', width: 14 }, { header: 'روز تا انقضا', key: 'daysLeft', width: 12 }, { header: 'هشدار (روز قبل)', key: 'alertDaysBefore', width: 14 }, { header: 'توضیحات', key: 'description', width: 28 }, ]; this.styleHeader(sheet); for (const t of tenants) { for (const s of t.supportSubscriptions) { sheet.addRow({ product: PRODUCT_LABEL[t.product] || t.product, tenantName: t.nameFa, domain: t.domain, name: s.name, isActive: s.isActive ? 'بله' : 'خیر', isFree: s.isFree ? 'بله' : 'خیر', specs: s.specs || '', priceToman: s.priceRials == null ? '' : Math.round(s.priceRials / 10), planMonths: s.planMonths ?? '', chargedAt: s.chargedAt ? formatIranDate(s.chargedAt) : '', expiresAt: s.expiresAt ? formatIranDate(s.expiresAt) : '', daysLeft: s.expiresAt ? this.daysLeftLabel(s.expiresAt) : '', alertDaysBefore: s.alertDaysBefore, description: s.description || '', }); } } } private addSmsSheet(workbook: ExcelJS.Workbook, tenants: Awaited>) { const sheet = workbook.addWorksheet('پیامک', { views: [{ rightToLeft: true }], }); sheet.columns = [ { header: 'محصول', key: 'product', width: 14 }, { header: 'نام پروژه', key: 'tenantName', width: 24 }, { header: 'دامنه', key: 'domain', width: 22 }, { header: 'موجودی (تومان)', key: 'balanceToman', width: 14 }, { header: 'تعرفه هر بخش (تومان)', key: 'pricePerPartToman', width: 16 }, { header: 'مسدود', key: 'isBlocked', width: 10 }, { header: 'آستانه مسدود خودکار (تومان)', key: 'autoBlockBelowToman', width: 18 }, { header: 'آستانه قرمز (تومان)', key: 'alertCriticalToman', width: 16 }, { header: 'آستانه زرد (تومان)', key: 'alertWarningToman', width: 16 }, { header: 'درگاه', key: 'gatewayProvider', width: 14 }, { header: 'ارسال امروز (پیام)', key: 'todayMessages', width: 14 }, { header: 'ارسال امروز (بخش)', key: 'todayParts', width: 14 }, { header: 'هزینه امروز (تومان)', key: 'todayCostToman', width: 14 }, { header: 'ارسال ماه (پیام)', key: 'monthMessages', width: 14 }, { header: 'ارسال ماه (بخش)', key: 'monthParts', width: 14 }, { header: 'هزینه ماه (تومان)', key: 'monthCostToman', width: 14 }, ]; this.styleHeader(sheet); for (const t of tenants) { const a = t.smsAccount; if (!a) continue; sheet.addRow({ product: PRODUCT_LABEL[t.product] || t.product, tenantName: t.nameFa, domain: t.domain, balanceToman: Math.round(a.balanceRials / 10), pricePerPartToman: Math.round(a.pricePerPartRials / 10), isBlocked: a.isBlocked ? 'بله' : 'خیر', autoBlockBelowToman: a.autoBlockBelowRials == null ? '' : Math.round(a.autoBlockBelowRials / 10), alertCriticalToman: Math.round(a.alertCriticalRials / 10), alertWarningToman: Math.round(a.alertWarningRials / 10), gatewayProvider: a.gatewayProvider || '', todayMessages: a.todayMessages, todayParts: a.todayParts, todayCostToman: Math.round(a.todayCostRials / 10), monthMessages: a.monthMessages, monthParts: a.monthParts, monthCostToman: Math.round(a.monthCostRials / 10), }); } } /** Used only for typing the Prisma include result. */ private load() { return this.prisma.tenant.findMany({ include: { smsAccount: true, serverAccounts: true, pluginSubscriptions: true, supportSubscriptions: true, }, }); } private styleHeader(sheet: ExcelJS.Worksheet) { const row = sheet.getRow(1); row.font = { bold: true }; row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; row.height = 22; } private formatDomains(raw: unknown): string { if (!raw) return ''; if (Array.isArray(raw)) return raw.map(String).filter(Boolean).join('، '); if (typeof raw === 'string') { try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String).filter(Boolean).join('، ') : raw; } catch { return raw; } } return ''; } private daysLeftLabel(expiresAt: Date): string { return String(iranDaysUntil(expiresAt)); } }