From bca45e341e76d85b63d51dcc356914524049939c Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 12 Jul 2026 19:26:02 +0330 Subject: [PATCH] fix excel --- src/common/utils/normalize-digits.util.ts | 5 + .../1722800000000-DeduplicateServiceSkills.ts | 26 ++++ .../dto/import-service-skills-result.dto.ts | 3 + ...ove-duplicate-service-skills-result.dto.ts | 6 + src/services/services.controller.ts | 12 ++ src/services/services.service.ts | 113 ++++++++++++++++-- 6 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 src/common/utils/normalize-digits.util.ts create mode 100644 src/database/migrations/1722800000000-DeduplicateServiceSkills.ts create mode 100644 src/services/dto/remove-duplicate-service-skills-result.dto.ts diff --git a/src/common/utils/normalize-digits.util.ts b/src/common/utils/normalize-digits.util.ts new file mode 100644 index 0000000..9ded802 --- /dev/null +++ b/src/common/utils/normalize-digits.util.ts @@ -0,0 +1,5 @@ +export function normalizeDigits(value: string): string { + return value + .replace(/[۰-۹]/g, (digit) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit))) + .replace(/[٠-٩]/g, (digit) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(digit))); +} diff --git a/src/database/migrations/1722800000000-DeduplicateServiceSkills.ts b/src/database/migrations/1722800000000-DeduplicateServiceSkills.ts new file mode 100644 index 0000000..1a0e301 --- /dev/null +++ b/src/database/migrations/1722800000000-DeduplicateServiceSkills.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DeduplicateServiceSkills1722800000000 implements MigrationInterface { + name = 'DeduplicateServiceSkills1722800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM "service_skills" s1 + USING "service_skills" s2 + WHERE s1."serviceId" = s2."serviceId" + AND LOWER(TRIM(s1.title)) = LOWER(TRIM(s2.title)) + AND s1."createdAt" > s2."createdAt" + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_service_skills_service_title_normalized" + ON "service_skills" ("serviceId", (LOWER(TRIM("title")))) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "UQ_service_skills_service_title_normalized" + `); + } +} diff --git a/src/services/dto/import-service-skills-result.dto.ts b/src/services/dto/import-service-skills-result.dto.ts index e94942a..fe837bb 100644 --- a/src/services/dto/import-service-skills-result.dto.ts +++ b/src/services/dto/import-service-skills-result.dto.ts @@ -16,6 +16,9 @@ export class ImportServiceSkillsResultDto { @ApiProperty({ example: 1 }) failed: number; + @ApiProperty({ example: 2 }) + skipped: number; + @ApiProperty({ type: [ServiceSkill] }) skills: ServiceSkill[]; diff --git a/src/services/dto/remove-duplicate-service-skills-result.dto.ts b/src/services/dto/remove-duplicate-service-skills-result.dto.ts new file mode 100644 index 0000000..8d314a1 --- /dev/null +++ b/src/services/dto/remove-duplicate-service-skills-result.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class RemoveDuplicateServiceSkillsResultDto { + @ApiProperty({ example: 3 }) + removed: number; +} diff --git a/src/services/services.controller.ts b/src/services/services.controller.ts index 20bcc41..4255341 100644 --- a/src/services/services.controller.ts +++ b/src/services/services.controller.ts @@ -35,6 +35,7 @@ import { UserRole } from '../users/enums/user-role.enum'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceDto } from './dto/create-service.dto'; import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto'; +import { RemoveDuplicateServiceSkillsResultDto } from './dto/remove-duplicate-service-skills-result.dto'; import { CreateSalonServiceDto, FindSalonServicesQueryDto, @@ -211,6 +212,17 @@ export class ServicesController { return this.servicesService.importSkillsFromExcel(serviceId, file, user); } + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SERVICES_DELETE) + @ApiStandardOkResponse(RemoveDuplicateServiceSkillsResultDto) + @Delete(':serviceId/skills/duplicates') + removeDuplicateSkills( + @Param('serviceId', ParseUUIDPipe) serviceId: string, + @CurrentUser() user: AuthUser, + ) { + return this.servicesService.removeDuplicateSkills(serviceId, user); + } + @Public() @ApiPublicEndpoint('List skills for a service') @ApiStandardOkResponse(ServiceSkill, { isArray: true }) diff --git a/src/services/services.service.ts b/src/services/services.service.ts index 9450d61..278ab45 100644 --- a/src/services/services.service.ts +++ b/src/services/services.service.ts @@ -10,11 +10,13 @@ import ExcelJS from 'exceljs'; import { In, Repository } from 'typeorm'; import { ServicePolicy } from '../authorization/policies/service.policy'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { normalizeDigits } from '../common/utils/normalize-digits.util'; import { Salon } from '../salons/entities/salon.entity'; import { UserRole } from '../users/enums/user-role.enum'; import { CreateServiceSkillDto } from './dto/create-service-skill.dto'; import { CreateServiceDto } from './dto/create-service.dto'; import { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto'; +import { RemoveDuplicateServiceSkillsResultDto } from './dto/remove-duplicate-service-skills-result.dto'; import { CreateSalonServiceDto, FindSalonServicesQueryDto, @@ -112,6 +114,7 @@ export class ServicesService { ): Promise { this.ensureCanManage(user); await this.findOne(serviceId); + await this.ensureUniqueSkillTitle(serviceId, createServiceSkillDto.title); const skill = this.serviceSkillsRepository.create({ ...createServiceSkillDto, serviceId, @@ -152,6 +155,12 @@ export class ServicesService { const errors: ImportServiceSkillsResultDto['errors'] = []; const toCreate: Array<{ title: string; renewalDays: number }> = []; + const existingSkills = await this.findSkills(serviceId); + const existingTitles = new Set( + existingSkills.map((skill) => this.normalizeSkillTitle(skill.title)), + ); + const seenInFile = new Set(); + let skipped = 0; for (const row of rows) { const title = row.title?.trim() ?? ''; @@ -180,17 +189,35 @@ export class ServicesService { continue; } + const normalizedTitle = this.normalizeSkillTitle(title); + if ( + existingTitles.has(normalizedTitle) || + seenInFile.has(normalizedTitle) + ) { + skipped += 1; + continue; + } + + seenInFile.add(normalizedTitle); toCreate.push({ title, renewalDays }); } if (toCreate.length === 0) { - throw new BadRequestException( - errors.length > 0 - ? errors - .map((error) => `ردیف ${error.row}: ${error.message}`) - .join('؛ ') - : 'هیچ ردیف معتبری برای ثبت یافت نشد', - ); + if (errors.length > 0) { + throw new BadRequestException( + errors + .map((error) => `ردیف ${error.row}: ${error.message}`) + .join('؛ '), + ); + } + + return { + created: 0, + failed: 0, + skipped, + skills: [], + errors: [], + }; } const saved = await this.serviceSkillsRepository.manager.transaction( @@ -206,11 +233,45 @@ export class ServicesService { return { created: saved.length, failed: errors.length, + skipped, skills: saved, errors, }; } + async removeDuplicateSkills( + serviceId: string, + user: AuthUser, + ): Promise { + this.ensureCanManage(user); + await this.findOne(serviceId); + + const skills = await this.findSkills(serviceId); + const keepIds = new Set(); + const seenTitles = new Set(); + + for (const skill of skills) { + const normalizedTitle = this.normalizeSkillTitle(skill.title); + if (seenTitles.has(normalizedTitle)) { + continue; + } + + seenTitles.add(normalizedTitle); + keepIds.add(skill.id); + } + + const duplicateIds = skills + .filter((skill) => !keepIds.has(skill.id)) + .map((skill) => skill.id); + + if (duplicateIds.length === 0) { + return { removed: 0 }; + } + + await this.serviceSkillsRepository.delete({ id: In(duplicateIds) }); + return { removed: duplicateIds.length }; + } + async findSkills(serviceId: string): Promise { await this.ensureServiceExists(serviceId); return this.serviceSkillsRepository.find({ @@ -239,6 +300,13 @@ export class ServicesService { ): Promise { this.ensureCanManage(user); const skill = await this.findSkill(serviceId, skillId); + if (updateServiceSkillDto.title) { + await this.ensureUniqueSkillTitle( + serviceId, + updateServiceSkillDto.title, + skillId, + ); + } Object.assign(skill, updateServiceSkillDto); await this.serviceSkillsRepository.save(skill); return this.findSkill(serviceId, skillId); @@ -454,6 +522,32 @@ export class ServicesService { return rows; } + private normalizeSkillTitle(title: string): string { + return normalizeDigits(title).trim().toLowerCase().replace(/\s+/g, ' '); + } + + private async ensureUniqueSkillTitle( + serviceId: string, + title: string, + excludeSkillId?: string, + ): Promise { + const normalizedTitle = this.normalizeSkillTitle(title); + const existingSkills = await this.serviceSkillsRepository.find({ + where: { serviceId }, + select: { id: true, title: true }, + }); + + const duplicate = existingSkills.find( + (skill) => + skill.id !== excludeSkillId && + this.normalizeSkillTitle(skill.title) === normalizedTitle, + ); + + if (duplicate) { + throw new ConflictException('مهارت با این نام قبلاً ثبت شده است'); + } + } + private normalizeHeader(value: ExcelJS.CellValue): string { const text = this.cellToString(value)?.trim().toLowerCase() ?? ''; return text.replace(/\s+/g, ' '); @@ -482,10 +576,13 @@ export class ServicesService { if (typeof value === 'number') { return value; } + if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') { + return this.cellToNumber(value.text); + } if (typeof value === 'object' && 'result' in value) { return this.cellToNumber(value.result as ExcelJS.CellValue); } - const parsed = Number(String(value).trim()); + const parsed = Number(normalizeDigits(String(value).trim())); return Number.isFinite(parsed) ? parsed : null; } }