import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import type { LeadImportJobDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { parseExcelMapping, previewExcel, readMappedRows, type ExcelFieldOption, type ExcelPreviewResult, } from '../common/excel'; import { LeadStagesService } from './lead-stages.service'; import { LeadsService } from './leads.service'; const LEAD_ALIASES: Record = { title: 'title', name: 'title', shop: 'title', نام: 'title', نامفروشگاه: 'title', فروشگاه: 'title', عنوان: 'title', contactname: 'contactName', owner: 'contactName', مالک: 'contactName', مسئول: 'contactName', ناممالک: 'contactName', ناممخاطب: 'contactName', mobile: 'mobile', phone: 'mobile', tel: 'mobile', موبایل: 'mobile', شماره: 'mobile', شمارهتماس: 'mobile', تلفن: 'phone', تلفنثابت: 'phone', city: 'city', شهر: 'city', region: 'region', منطقه: 'region', محله: 'region', trade: 'trade', صنف: 'trade', رسته: 'trade', source: 'source', منبع: 'source', منبعلید: 'source', notes: 'notes', توضیحات: 'notes', یادداشت: 'notes', }; const LEAD_FIELDS: ExcelFieldOption[] = [ { key: 'title', label: 'نام / فروشگاه', required: true }, { key: 'mobile', label: 'موبایل', required: true }, { key: 'contactName', label: 'نام مخاطب / مالک' }, { key: 'phone', label: 'تلفن' }, { key: 'city', label: 'شهر' }, { key: 'region', label: 'منطقه' }, { key: 'trade', label: 'صنف' }, { key: 'source', label: 'منبع' }, { key: 'notes', label: 'توضیحات' }, ]; @Injectable() export class LeadsImportService { private readonly logger = new Logger(LeadsImportService.name); private running = false; constructor( private readonly prisma: PrismaService, private readonly stages: LeadStagesService, private readonly leads: LeadsService, ) {} preview(file: Express.Multer.File): Promise { return previewExcel(file, LEAD_ALIASES, LEAD_FIELDS); } async rowsFromFile( file: Express.Multer.File, mappingRaw?: string, ): Promise>> { const rows = await readMappedRows( file, LEAD_ALIASES, parseExcelMapping(mappingRaw), ); return rows.map((r) => this.normalizeRow(r)); } async enqueue( organizationId: string, createdById: string, rows: Array>, fileName?: string, ): Promise { await this.stages.ensureDefaults(organizationId); const normalized = rows.map((r) => this.normalizeRow(r)); const job = await this.prisma.leadImportJob.create({ data: { organizationId, createdById, fileName: fileName ?? null, status: 'pending', totalCount: normalized.length, rows: normalized as unknown as Prisma.InputJsonValue, }, }); return this.toDto(job); } async getJob( organizationId: string, id: string, ): Promise { const job = await this.prisma.leadImportJob.findFirst({ where: { id, organizationId }, }); return job ? this.toDto(job) : null; } @Cron('*/1 * * * *') async processDue(): Promise { if (this.running) return 0; this.running = true; try { const jobs = await this.prisma.leadImportJob.findMany({ where: { status: 'pending' }, orderBy: { createdAt: 'asc' }, take: 2, }); for (const job of jobs) { await this.processJob(job.id); } return jobs.length; } finally { this.running = false; } } private async processJob(id: string): Promise { const claimed = await this.prisma.leadImportJob.updateMany({ where: { id, status: 'pending' }, data: { status: 'processing', startedAt: new Date() }, }); if (!claimed.count) return; const job = await this.prisma.leadImportJob.findUnique({ where: { id } }); if (!job) return; const stages = await this.stages.list(job.organizationId); const defaultStageId = stages[0]?.id; if (!defaultStageId) { await this.prisma.leadImportJob.update({ where: { id }, data: { status: 'failed', finishedAt: new Date(), errorSamples: ['مرحله پیش‌فرض لید وجود ندارد'], }, }); return; } const rows = (job.rows as Array>) ?? []; const errorSamples: string[] = []; let success = 0; let duplicate = 0; let error = 0; let processed = 0; const BATCH = 50; for (let i = 0; i < rows.length; i += BATCH) { const chunk = rows.slice(i, i + BATCH); for (const row of chunk) { const result = await this.leads.createFromImportRow( job.organizationId, job.createdById, row, defaultStageId, ); processed += 1; if (result === 'ok') success += 1; else if (result === 'duplicate') duplicate += 1; else { error += 1; if (errorSamples.length < 20) { errorSamples.push( `ردیف ${processed}: ${row.title || row.mobile || 'نامعتبر'}`, ); } } } await this.prisma.leadImportJob.update({ where: { id }, data: { processedCount: processed, successCount: success, duplicateCount: duplicate, errorCount: error, errorSamples, }, }); } await this.prisma.leadImportJob.update({ where: { id }, data: { status: 'done', processedCount: processed, successCount: success, duplicateCount: duplicate, errorCount: error, errorSamples, finishedAt: new Date(), rows: [], }, }); this.logger.log( `Lead import ${id}: ok=${success} dup=${duplicate} err=${error}`, ); } private normalizeRow(raw: Record): Record { const get = (...keys: string[]) => { for (const k of keys) { const direct = raw[k]; if (direct != null && String(direct).trim()) return String(direct).trim(); const found = Object.entries(raw).find( ([key]) => key.trim().toLowerCase() === k.toLowerCase(), ); if (found?.[1] != null && String(found[1]).trim()) { return String(found[1]).trim(); } } return ''; }; return { title: get('title', 'نام', 'نام فروشگاه', 'فروشگاه', 'shop', 'name'), contactName: get('contactName', 'مالک', 'مسئول', 'نام مالک', 'owner'), mobile: get('mobile', 'موبایل', 'شماره', 'شماره تماس', 'phone', 'tel'), phone: get('phone', 'تلفن'), city: get('city', 'شهر'), region: get('region', 'منطقه', 'محله'), trade: get('trade', 'صنف', 'رسته'), source: get('source', 'منبع', 'منبع لید'), notes: get('notes', 'توضیحات', 'یادداشت'), }; } private toDto(job: { id: string; fileName: string | null; status: string; totalCount: number; processedCount: number; successCount: number; duplicateCount: number; errorCount: number; errorSamples: unknown; startedAt: Date | null; finishedAt: Date | null; createdAt: Date; }): LeadImportJobDto { const samples = Array.isArray(job.errorSamples) ? job.errorSamples.map((s) => String(s)) : []; return { id: job.id, fileName: job.fileName, status: job.status, totalCount: job.totalCount, processedCount: job.processedCount, successCount: job.successCount, duplicateCount: job.duplicateCount, errorCount: job.errorCount, errorSamples: samples, startedAt: job.startedAt?.toISOString() ?? null, finishedAt: job.finishedAt?.toISOString() ?? null, createdAt: job.createdAt.toISOString(), }; } }