added service with excel

This commit is contained in:
2026-07-10 14:09:41 +03:30
parent 33f0afaf10
commit 7c0f3a1d0e
5 changed files with 1107 additions and 14 deletions
+791 -13
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -39,6 +39,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"exceljs": "^4.4.0",
"multer": "^2.2.0", "multer": "^2.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { ServiceSkill } from '../entities/service-skill.entity';
export class ImportServiceSkillRowErrorDto {
@ApiProperty({ example: 3 })
row: number;
@ApiProperty({ example: 'عنوان مهارت الزامی است' })
message: string;
}
export class ImportServiceSkillsResultDto {
@ApiProperty({ example: 5 })
created: number;
@ApiProperty({ example: 1 })
failed: number;
@ApiProperty({ type: [ServiceSkill] })
skills: ServiceSkill[];
@ApiProperty({ type: [ImportServiceSkillRowErrorDto] })
errors: ImportServiceSkillRowErrorDto[];
}
+66 -1
View File
@@ -8,9 +8,21 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
Res,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiProduces,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import { memoryStorage } from 'multer';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator'; import { Permissions } from '../auth/decorators/permissions.decorator';
@@ -22,6 +34,7 @@ import { PermissionCode } from '../authorization/enums/permission-code.enum';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
import { CreateServiceDto } from './dto/create-service.dto'; import { CreateServiceDto } from './dto/create-service.dto';
import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -39,6 +52,8 @@ import {
import { ServiceSkill } from './entities/service-skill.entity'; import { ServiceSkill } from './entities/service-skill.entity';
import { Service } from './entities/service.entity'; import { Service } from './entities/service.entity';
const MAX_EXCEL_SIZE_BYTES = 5 * 1024 * 1024;
@ApiTags('Services') @ApiTags('Services')
@ApiBearerAuth(SWAGGER_JWT_AUTH) @ApiBearerAuth(SWAGGER_JWT_AUTH)
@ApiStandardController() @ApiStandardController()
@@ -99,6 +114,24 @@ export class ServicesController {
return this.servicesService.removeServiceForSalon(serviceId, user); return this.servicesService.removeServiceForSalon(serviceId, user);
} }
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_WRITE)
@ApiProduces(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
@Get('skills/import-template')
async downloadSkillsImportTemplate(@Res() res: Response) {
const buffer = await this.servicesService.buildSkillsImportTemplate();
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition':
'attachment; filename="service-skills-import-template.xlsx"',
'Content-Length': buffer.length,
});
res.send(buffer);
}
@Public() @Public()
@ApiPublicEndpoint('Get service by ID') @ApiPublicEndpoint('Get service by ID')
@ApiStandardOkResponse(Service) @ApiStandardOkResponse(Service)
@@ -146,6 +179,38 @@ export class ServicesController {
); );
} }
@Roles(UserRole.ADMIN)
@Permissions(PermissionCode.SERVICES_WRITE)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Excel file (.xlsx) with skill title and renewalDays columns',
},
},
},
})
@ApiStandardOkResponse(ImportServiceSkillsResultDto)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: MAX_EXCEL_SIZE_BYTES },
}),
)
@Post(':serviceId/skills/import')
importSkills(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.importSkillsFromExcel(serviceId, file, user);
}
@Public() @Public()
@ApiPublicEndpoint('List skills for a service') @ApiPublicEndpoint('List skills for a service')
@ApiStandardOkResponse(ServiceSkill, { isArray: true }) @ApiStandardOkResponse(ServiceSkill, { isArray: true })
+225
View File
@@ -6,6 +6,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import ExcelJS from 'exceljs';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { ServicePolicy } from '../authorization/policies/service.policy'; import { ServicePolicy } from '../authorization/policies/service.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthUser } from '../auth/interfaces/auth-user.interface';
@@ -13,6 +14,7 @@ import { Salon } from '../salons/entities/salon.entity';
import { UserRole } from '../users/enums/user-role.enum'; import { UserRole } from '../users/enums/user-role.enum';
import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
import { CreateServiceDto } from './dto/create-service.dto'; import { CreateServiceDto } from './dto/create-service.dto';
import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -23,6 +25,27 @@ import { SalonServiceOffering } from './entities/salon-service-offering.entity';
import { ServiceSkill } from './entities/service-skill.entity'; import { ServiceSkill } from './entities/service-skill.entity';
import { Service } from './entities/service.entity'; import { Service } from './entities/service.entity';
const TITLE_HEADERS = new Set(['عنوان مهارت', 'title', 'عنوان']);
const RENEWAL_HEADERS = new Set([
'دوره بازگشت (روز)',
'دوره بازگشت',
'renewaldays',
'renewal_days',
'renewal days',
]);
const SAMPLE_SKILL_ROWS: Array<{ title: string; renewalDays: number }> = [
{ title: 'کاشت ناخن ژل', renewalDays: 30 },
{ title: 'مانیکور ساده', renewalDays: 21 },
{ title: 'پدیکور', renewalDays: 30 },
];
const EXCEL_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/octet-stream',
]);
@Injectable() @Injectable()
export class ServicesService { export class ServicesService {
constructor( constructor(
@@ -97,6 +120,97 @@ export class ServicesService {
return this.findSkill(serviceId, saved.id); return this.findSkill(serviceId, saved.id);
} }
async buildSkillsImportTemplate(): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('مهارت‌ها');
sheet.columns = [
{ header: 'عنوان مهارت', key: 'title', width: 40 },
{ header: 'دوره بازگشت (روز)', key: 'renewalDays', width: 22 },
];
sheet.getRow(1).font = { bold: true };
SAMPLE_SKILL_ROWS.forEach((row) => sheet.addRow(row));
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
async importSkillsFromExcel(
serviceId: string,
file: Express.Multer.File | undefined,
user: AuthUser,
): Promise<ImportServiceSkillsResultDto> {
this.ensureCanManage(user);
await this.findOne(serviceId);
this.ensureExcelFile(file);
const rows = await this.parseSkillsExcel(file.buffer);
if (rows.length === 0) {
throw new BadRequestException('فایل اکسل هیچ ردیف معتبری ندارد');
}
const errors: ImportServiceSkillsResultDto['errors'] = [];
const toCreate: Array<{ title: string; renewalDays: number }> = [];
for (const row of rows) {
const title = row.title?.trim() ?? '';
const renewalDays = row.renewalDays;
if (!title) {
errors.push({ row: row.rowNumber, message: 'عنوان مهارت الزامی است' });
continue;
}
if (title.length > 200) {
errors.push({
row: row.rowNumber,
message: 'عنوان مهارت نباید بیشتر از ۲۰۰ کاراکتر باشد',
});
continue;
}
if (
renewalDays === null ||
!Number.isInteger(renewalDays) ||
renewalDays < 1
) {
errors.push({
row: row.rowNumber,
message: 'دوره بازگشت باید عدد صحیح و حداقل ۱ باشد',
});
continue;
}
toCreate.push({ title, renewalDays });
}
if (toCreate.length === 0) {
throw new BadRequestException(
errors.length > 0
? errors
.map((error) => `ردیف ${error.row}: ${error.message}`)
.join('؛ ')
: 'هیچ ردیف معتبری برای ثبت یافت نشد',
);
}
const saved = await this.serviceSkillsRepository.manager.transaction(
async (manager) => {
const repository = manager.getRepository(ServiceSkill);
const entities = toCreate.map((item) =>
repository.create({ ...item, serviceId }),
);
return repository.save(entities);
},
);
return {
created: saved.length,
failed: errors.length,
skills: saved,
errors,
};
}
async findSkills(serviceId: string): Promise<ServiceSkill[]> { async findSkills(serviceId: string): Promise<ServiceSkill[]> {
await this.ensureServiceExists(serviceId); await this.ensureServiceExists(serviceId);
return this.serviceSkillsRepository.find({ return this.serviceSkillsRepository.find({
@@ -263,4 +377,115 @@ export class ServicesService {
throw new NotFoundException(`Service #${serviceId} not found`); throw new NotFoundException(`Service #${serviceId} not found`);
} }
} }
private ensureExcelFile(file: Express.Multer.File | undefined): asserts file is Express.Multer.File {
if (!file) {
throw new BadRequestException('فایل اکسل الزامی است');
}
const extension = file.originalname.split('.').pop()?.toLowerCase();
const isExcelExtension = extension === 'xlsx' || extension === 'xls';
const isExcelMime = EXCEL_MIME_TYPES.has(file.mimetype);
if (!isExcelExtension && !isExcelMime) {
throw new BadRequestException('فقط فایل‌های Excel (.xlsx) مجاز هستند');
}
}
private async parseSkillsExcel(
buffer: Buffer,
): Promise<
Array<{ rowNumber: number; title: string | null; renewalDays: number | null }>
> {
const workbook = new ExcelJS.Workbook();
// exceljs Buffer typings conflict with Node 22+ Buffer generics
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
const sheet = workbook.worksheets[0];
if (!sheet) {
throw new BadRequestException('فایل اکسل خالی است');
}
const headerRow = sheet.getRow(1);
let titleCol = 0;
let renewalCol = 0;
headerRow.eachCell((cell, colNumber) => {
const header = this.normalizeHeader(cell.value);
if (TITLE_HEADERS.has(header)) {
titleCol = colNumber;
}
if (RENEWAL_HEADERS.has(header)) {
renewalCol = colNumber;
}
});
if (!titleCol || !renewalCol) {
throw new BadRequestException(
'ستون‌های «عنوان مهارت» و «دوره بازگشت (روز)» در ردیف اول الزامی هستند',
);
}
const rows: Array<{
rowNumber: number;
title: string | null;
renewalDays: number | null;
}> = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) {
return;
}
const titleValue = this.cellToString(row.getCell(titleCol).value);
const renewalValue = this.cellToNumber(row.getCell(renewalCol).value);
if (!titleValue && renewalValue === null) {
return;
}
rows.push({
rowNumber,
title: titleValue,
renewalDays: renewalValue,
});
});
return rows;
}
private normalizeHeader(value: ExcelJS.CellValue): string {
const text = this.cellToString(value)?.trim().toLowerCase() ?? '';
return text.replace(/\s+/g, ' ');
}
private cellToString(value: ExcelJS.CellValue): string | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim() || null;
}
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim() || null;
}
if (typeof value === 'object' && 'result' in value) {
return this.cellToString(value.result as ExcelJS.CellValue);
}
return String(value).trim() || null;
}
private cellToNumber(value: ExcelJS.CellValue): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'object' && 'result' in value) {
return this.cellToNumber(value.result as ExcelJS.CellValue);
}
const parsed = Number(String(value).trim());
return Number.isFinite(parsed) ? parsed : null;
}
} }