added service with excel
This commit is contained in:
Generated
+791
-13
File diff suppressed because it is too large
Load Diff
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user