286 lines
8.1 KiB
TypeScript
286 lines
8.1 KiB
TypeScript
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<string, string> = {
|
|
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<ExcelPreviewResult> {
|
|
return previewExcel(file, LEAD_ALIASES, LEAD_FIELDS);
|
|
}
|
|
|
|
async rowsFromFile(
|
|
file: Express.Multer.File,
|
|
mappingRaw?: string,
|
|
): Promise<Array<Record<string, string>>> {
|
|
const rows = await readMappedRows(
|
|
file,
|
|
LEAD_ALIASES,
|
|
parseExcelMapping(mappingRaw),
|
|
);
|
|
return rows.map((r) => this.normalizeRow(r));
|
|
}
|
|
|
|
async enqueue(
|
|
organizationId: string,
|
|
createdById: string,
|
|
rows: Array<Record<string, unknown>>,
|
|
fileName?: string,
|
|
): Promise<LeadImportJobDto> {
|
|
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<LeadImportJobDto | null> {
|
|
const job = await this.prisma.leadImportJob.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
return job ? this.toDto(job) : null;
|
|
}
|
|
|
|
@Cron('*/1 * * * *')
|
|
async processDue(): Promise<number> {
|
|
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<void> {
|
|
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<Record<string, string>>) ?? [];
|
|
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<string, unknown>): Record<string, string> {
|
|
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(),
|
|
};
|
|
}
|
|
}
|