Creating import&export excel
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ActivityType, ImportContactsResult } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { assigneeWhere } from '../common/record-scope';
|
||||
import { normalizePhone } from '../contacts/normalize';
|
||||
import {
|
||||
formatClock,
|
||||
formatJalaliDate,
|
||||
parseJalaliDateTime,
|
||||
readMappedRows,
|
||||
stripHtml,
|
||||
writeXlsx,
|
||||
} from '../common/excel';
|
||||
|
||||
const ACTIVITY_COLUMNS = [
|
||||
{ header: '_type', key: '_type', width: 12 },
|
||||
{ header: 'نوع فعالیت', key: 'kind', width: 16 },
|
||||
{ header: 'عنوان فعالیت', key: 'title', width: 24 },
|
||||
{ header: 'متن فعالیت', key: 'body', width: 36 },
|
||||
{ header: 'نتیجه فعالیت', key: 'result', width: 20 },
|
||||
{ header: 'مسئول اجرا فعالیت', key: 'assignee', width: 18 },
|
||||
{ header: 'ثبت کننده فعالیت', key: 'creator', width: 18 },
|
||||
{ header: 'تاریخ برنامه ریزی شده اجرا فعالیت', key: 'dueDate', width: 18 },
|
||||
{ header: 'زمان برنامه ریزی شده اجرا فعالیت', key: 'dueTime', width: 14 },
|
||||
{ header: 'وضعیت اجرای فعالیت', key: 'status', width: 16 },
|
||||
{ header: 'تاریخ انجام شدن فعالیت', key: 'doneDate', width: 16 },
|
||||
{ header: 'کد معامله فعالیت', key: 'dealCode', width: 14 },
|
||||
{ header: 'کد کارت فعالیت', key: 'cardCode', width: 14 },
|
||||
{ header: 'زمان انجام شدن فعالیت', key: 'doneTime', width: 14 },
|
||||
{ header: 'اشخاص فعالیت', key: 'personName', width: 18 },
|
||||
{ header: 'کد اشخاص فعالیت', key: 'personCode', width: 14 },
|
||||
{ header: 'موبایل اشخاص فعالیت', key: 'personMobile', width: 16 },
|
||||
{ header: 'شرکت های فعالیت', key: 'companyName', width: 20 },
|
||||
{ header: 'کد شرکت های فعالیت', key: 'companyCode', width: 14 },
|
||||
{ header: 'معامله فعالیت_1', key: 'dealTitle', width: 24 },
|
||||
{ header: 'کارت فعالیت_1', key: 'cardTitle', width: 20 },
|
||||
{ header: 'هشتگ', key: 'hashtag', width: 16 },
|
||||
];
|
||||
|
||||
const ACTIVITY_ALIASES: Record<string, string> = {
|
||||
_type: '_type',
|
||||
type: '_type',
|
||||
نوعفعالیت: 'kind',
|
||||
عنوانفعالیت: 'title',
|
||||
عنوان: 'title',
|
||||
متنفعالیت: 'body',
|
||||
نتیجهفعالیت: 'result',
|
||||
مسئولاجرافعالیت: 'assignee',
|
||||
ثبتکنندهفعالیت: 'creator',
|
||||
تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate',
|
||||
زمانبرنامهریزیشدهاجرافعالیت: 'dueTime',
|
||||
وضعیتاجرایفعالیت: 'status',
|
||||
تاریخانجامشدنفعالیت: 'doneDate',
|
||||
زمانانجامشدنفعالیت: 'doneTime',
|
||||
کدمعاملهفعالیت: 'dealCode',
|
||||
کدکارتفعالیت: 'cardCode',
|
||||
اشخاصفعالیت: 'personName',
|
||||
کداشخاصفعالیت: 'personCode',
|
||||
موبایلاشخاصفعالیت: 'personMobile',
|
||||
شرکتهایفعالیت: 'companyName',
|
||||
کدشرکتهایفعالیت: 'companyCode',
|
||||
معاملهفعالیت1: 'dealTitle',
|
||||
کارتفعالیت1: 'cardTitle',
|
||||
هشتگ: 'hashtag',
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<ActivityType, string> = {
|
||||
call: 'تماس با مشتری',
|
||||
meeting: 'جلسه',
|
||||
task: 'پیگیری',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
|
||||
function parseType(raw?: string): ActivityType {
|
||||
const v = (raw ?? '').trim();
|
||||
if (/تماس|call/i.test(v)) return 'call';
|
||||
if (/جلسه|ملاقات|meeting/i.test(v)) return 'meeting';
|
||||
if (/یادداشت|پیام|note/i.test(v)) return 'note';
|
||||
return 'task';
|
||||
}
|
||||
|
||||
function parseStatus(raw?: string): 'open' | 'done' | 'canceled' {
|
||||
const v = (raw ?? '').trim();
|
||||
if (/انجام\s*شده|done/i.test(v)) return 'done';
|
||||
if (/لغو|canceled|کنسل/i.test(v)) return 'canceled';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
if (status === 'done') return 'انجام شده';
|
||||
if (status === 'canceled') return 'لغو شده';
|
||||
return 'انجام نشده';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ActivitiesExcelService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async export(actor: Actor): Promise<Buffer> {
|
||||
const rows = await this.prisma.activity.findMany({
|
||||
where: { organizationId: actor.organizationId, ...assigneeWhere(actor) },
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
assignee: { select: { fullName: true } },
|
||||
createdBy: { select: { fullName: true } },
|
||||
},
|
||||
orderBy: [{ dueAt: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 10000,
|
||||
});
|
||||
|
||||
return writeXlsx(
|
||||
'Activity',
|
||||
ACTIVITY_COLUMNS,
|
||||
rows.map((row) => ({
|
||||
_type: 'Activity',
|
||||
kind: TYPE_LABEL[row.type],
|
||||
title: row.title,
|
||||
body: row.description ?? '',
|
||||
result: '',
|
||||
assignee: row.assignee?.fullName ?? '',
|
||||
creator: row.createdBy?.fullName ?? '',
|
||||
dueDate: formatJalaliDate(row.dueAt),
|
||||
dueTime: formatClock(row.dueAt),
|
||||
status: statusLabel(row.status),
|
||||
doneDate: formatJalaliDate(row.completedAt),
|
||||
dealCode: row.deal?.id ?? '',
|
||||
cardCode: '',
|
||||
doneTime: formatClock(row.completedAt),
|
||||
personName: row.person
|
||||
? `${row.person.firstName} ${row.person.lastName}`.trim()
|
||||
: '',
|
||||
personCode: row.person?.id ?? '',
|
||||
personMobile: row.person?.mobile ?? '',
|
||||
companyName: row.company?.name ?? '',
|
||||
companyCode: row.company?.id ?? '',
|
||||
dealTitle: row.deal?.title ?? '',
|
||||
cardTitle: '',
|
||||
hashtag: '',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async import(actor: Actor, file: Express.Multer.File): Promise<ImportContactsResult> {
|
||||
const rows = await readMappedRows(file, ACTIVITY_ALIASES);
|
||||
const result: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const rowNum = i + 2;
|
||||
if ((row._type || '').trim() && row._type.trim().toLowerCase() !== 'activity') {
|
||||
continue;
|
||||
}
|
||||
const title = (row.title || row.kind || '').trim();
|
||||
if (title.length < 2) {
|
||||
result.errors.push({ row: rowNum, message: 'عنوان فعالیت الزامی است' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = stripHtml([row.body, row.result, row.hashtag, row.cardTitle]
|
||||
.filter(Boolean)
|
||||
.join('\n'));
|
||||
const dueAt = parseJalaliDateTime(row.dueDate, row.dueTime);
|
||||
const doneAt = parseJalaliDateTime(row.doneDate, row.doneTime);
|
||||
const status = parseStatus(row.status);
|
||||
|
||||
try {
|
||||
const personId = await this.findPersonId(actor.organizationId, row);
|
||||
const companyId = await this.findCompanyId(actor.organizationId, row.companyName);
|
||||
const dealId = await this.findDealId(actor.organizationId, row.dealTitle);
|
||||
const assigneeId =
|
||||
(await this.findUserId(actor.organizationId, row.assignee)) ?? actor.userId;
|
||||
const creatorId =
|
||||
(await this.findUserId(actor.organizationId, row.creator)) ?? actor.userId;
|
||||
|
||||
const duplicate = dueAt
|
||||
? await this.prisma.activity.findFirst({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
dueAt,
|
||||
personId: personId ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (duplicate) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
title,
|
||||
type: parseType(row.kind),
|
||||
status,
|
||||
description: body || null,
|
||||
dueAt,
|
||||
completedAt: status === 'done' ? doneAt ?? new Date() : null,
|
||||
reminderNotifyApp: false,
|
||||
reminderNotifySms: false,
|
||||
personId,
|
||||
companyId,
|
||||
dealId,
|
||||
assigneeId,
|
||||
createdById: creatorId,
|
||||
},
|
||||
});
|
||||
result.created += 1;
|
||||
} catch (e) {
|
||||
result.errors.push({
|
||||
row: rowNum,
|
||||
message: e instanceof Error ? e.message : 'خطای ناشناخته',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findPersonId(organizationId: string, row: Record<string, string>) {
|
||||
const mobile = normalizePhone(row.personMobile);
|
||||
if (mobile) {
|
||||
const byMobile = await this.prisma.person.findFirst({
|
||||
where: { organizationId, mobile: { contains: mobile.replace(/^\+98|^98|^0/, '') } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byMobile) return byMobile.id;
|
||||
}
|
||||
const name = (row.personName ?? '').trim();
|
||||
if (name) {
|
||||
const parts = name.split(/\s+/);
|
||||
const byName = await this.prisma.person.findFirst({
|
||||
where: {
|
||||
organizationId,
|
||||
OR: [
|
||||
{ lastName: { contains: name, mode: 'insensitive' } },
|
||||
{ firstName: { contains: parts[0]!, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (byName) return byName.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findCompanyId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const row = await this.prisma.company.findFirst({
|
||||
where: { organizationId, name: { equals: n, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
private async findDealId(organizationId: string, title?: string) {
|
||||
const t = title?.trim();
|
||||
if (!t) return null;
|
||||
const row = await this.prisma.deal.findFirst({
|
||||
where: { organizationId, title: { equals: t, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
private async findUserId(organizationId: string, name?: string) {
|
||||
const n = name?.trim();
|
||||
if (!n) return null;
|
||||
const row = await this.prisma.user.findFirst({
|
||||
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return row?.id ?? null;
|
||||
}
|
||||
}
|
||||
@@ -7,32 +7,43 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import {
|
||||
CreateActivityDto,
|
||||
ListActivitiesQueryDto,
|
||||
UpdateActivityDto,
|
||||
} from './dto';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@Controller('activities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ActivitiesController {
|
||||
constructor(private readonly activitiesService: ActivitiesService) {}
|
||||
constructor(
|
||||
private readonly activitiesService: ActivitiesService,
|
||||
private readonly excelService: ActivitiesExcelService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
summary(@CurrentUser() user: Actor) {
|
||||
return this.activitiesService.summary(user.organizationId, user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Query() query: ListActivitiesQueryDto,
|
||||
) {
|
||||
return this.activitiesService.list(
|
||||
@@ -54,13 +65,41 @@ export class ActivitiesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
async export(
|
||||
@CurrentUser() user: Actor,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const buffer = await this.excelService.export(user);
|
||||
res.set({
|
||||
'Content-Type':
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${brand.filePrefix}-activities.xlsx"`,
|
||||
});
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 15_000_000 },
|
||||
}),
|
||||
)
|
||||
import(
|
||||
@CurrentUser() user: Actor,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
return this.excelService.import(user, file);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
get(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateActivityDto) {
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateActivityDto) {
|
||||
return this.activitiesService.create(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
@@ -71,7 +110,7 @@ export class ActivitiesController {
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateActivityDto,
|
||||
) {
|
||||
@@ -79,12 +118,12 @@ export class ActivitiesController {
|
||||
}
|
||||
|
||||
@Patch(':id/complete')
|
||||
complete(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
complete(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.activitiesService.complete(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.activitiesService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ActivitiesController } from './activities.controller';
|
||||
import { ActivitiesService } from './activities.service';
|
||||
import { ActivitiesExcelService } from './activities-excel.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AutomationsModule } from '../automations/automations.module';
|
||||
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
|
||||
@@ -8,7 +9,7 @@ import { MessageAutomationsModule } from '../message-automations/message-automat
|
||||
@Module({
|
||||
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
|
||||
controllers: [ActivitiesController],
|
||||
providers: [ActivitiesService],
|
||||
providers: [ActivitiesService, ActivitiesExcelService],
|
||||
exports: [ActivitiesService],
|
||||
})
|
||||
export class ActivitiesModule {}
|
||||
|
||||
Reference in New Issue
Block a user