fix excel

This commit is contained in:
2026-07-12 19:26:02 +03:30
parent 571003377a
commit bca45e341e
6 changed files with 157 additions and 8 deletions
@@ -0,0 +1,5 @@
export function normalizeDigits(value: string): string {
return value
.replace(/[۰-۹]/g, (digit) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(digit)));
}
@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class DeduplicateServiceSkills1722800000000 implements MigrationInterface {
name = 'DeduplicateServiceSkills1722800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS "UQ_service_skills_service_title_normalized"
`);
}
}
@@ -16,6 +16,9 @@ export class ImportServiceSkillsResultDto {
@ApiProperty({ example: 1 }) @ApiProperty({ example: 1 })
failed: number; failed: number;
@ApiProperty({ example: 2 })
skipped: number;
@ApiProperty({ type: [ServiceSkill] }) @ApiProperty({ type: [ServiceSkill] })
skills: ServiceSkill[]; skills: ServiceSkill[];
@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class RemoveDuplicateServiceSkillsResultDto {
@ApiProperty({ example: 3 })
removed: number;
}
+12
View File
@@ -35,6 +35,7 @@ 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 { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
import { RemoveDuplicateServiceSkillsResultDto } from './dto/remove-duplicate-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -211,6 +212,17 @@ export class ServicesController {
return this.servicesService.importSkillsFromExcel(serviceId, file, user); 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() @Public()
@ApiPublicEndpoint('List skills for a service') @ApiPublicEndpoint('List skills for a service')
@ApiStandardOkResponse(ServiceSkill, { isArray: true }) @ApiStandardOkResponse(ServiceSkill, { isArray: true })
+102 -5
View File
@@ -10,11 +10,13 @@ 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';
import { normalizeDigits } from '../common/utils/normalize-digits.util';
import { Salon } from '../salons/entities/salon.entity'; 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 { ImportServiceSkillsResultDto } from './dto/import-service-skills-result.dto';
import { RemoveDuplicateServiceSkillsResultDto } from './dto/remove-duplicate-service-skills-result.dto';
import { import {
CreateSalonServiceDto, CreateSalonServiceDto,
FindSalonServicesQueryDto, FindSalonServicesQueryDto,
@@ -112,6 +114,7 @@ export class ServicesService {
): Promise<ServiceSkill> { ): Promise<ServiceSkill> {
this.ensureCanManage(user); this.ensureCanManage(user);
await this.findOne(serviceId); await this.findOne(serviceId);
await this.ensureUniqueSkillTitle(serviceId, createServiceSkillDto.title);
const skill = this.serviceSkillsRepository.create({ const skill = this.serviceSkillsRepository.create({
...createServiceSkillDto, ...createServiceSkillDto,
serviceId, serviceId,
@@ -152,6 +155,12 @@ export class ServicesService {
const errors: ImportServiceSkillsResultDto['errors'] = []; const errors: ImportServiceSkillsResultDto['errors'] = [];
const toCreate: Array<{ title: string; renewalDays: number }> = []; 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<string>();
let skipped = 0;
for (const row of rows) { for (const row of rows) {
const title = row.title?.trim() ?? ''; const title = row.title?.trim() ?? '';
@@ -180,19 +189,37 @@ export class ServicesService {
continue; continue;
} }
const normalizedTitle = this.normalizeSkillTitle(title);
if (
existingTitles.has(normalizedTitle) ||
seenInFile.has(normalizedTitle)
) {
skipped += 1;
continue;
}
seenInFile.add(normalizedTitle);
toCreate.push({ title, renewalDays }); toCreate.push({ title, renewalDays });
} }
if (toCreate.length === 0) { if (toCreate.length === 0) {
if (errors.length > 0) {
throw new BadRequestException( throw new BadRequestException(
errors.length > 0 errors
? errors
.map((error) => `ردیف ${error.row}: ${error.message}`) .map((error) => `ردیف ${error.row}: ${error.message}`)
.join('؛ ') .join('؛ '),
: 'هیچ ردیف معتبری برای ثبت یافت نشد',
); );
} }
return {
created: 0,
failed: 0,
skipped,
skills: [],
errors: [],
};
}
const saved = await this.serviceSkillsRepository.manager.transaction( const saved = await this.serviceSkillsRepository.manager.transaction(
async (manager) => { async (manager) => {
const repository = manager.getRepository(ServiceSkill); const repository = manager.getRepository(ServiceSkill);
@@ -206,11 +233,45 @@ export class ServicesService {
return { return {
created: saved.length, created: saved.length,
failed: errors.length, failed: errors.length,
skipped,
skills: saved, skills: saved,
errors, errors,
}; };
} }
async removeDuplicateSkills(
serviceId: string,
user: AuthUser,
): Promise<RemoveDuplicateServiceSkillsResultDto> {
this.ensureCanManage(user);
await this.findOne(serviceId);
const skills = await this.findSkills(serviceId);
const keepIds = new Set<string>();
const seenTitles = new Set<string>();
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<ServiceSkill[]> { async findSkills(serviceId: string): Promise<ServiceSkill[]> {
await this.ensureServiceExists(serviceId); await this.ensureServiceExists(serviceId);
return this.serviceSkillsRepository.find({ return this.serviceSkillsRepository.find({
@@ -239,6 +300,13 @@ export class ServicesService {
): Promise<ServiceSkill> { ): Promise<ServiceSkill> {
this.ensureCanManage(user); this.ensureCanManage(user);
const skill = await this.findSkill(serviceId, skillId); const skill = await this.findSkill(serviceId, skillId);
if (updateServiceSkillDto.title) {
await this.ensureUniqueSkillTitle(
serviceId,
updateServiceSkillDto.title,
skillId,
);
}
Object.assign(skill, updateServiceSkillDto); Object.assign(skill, updateServiceSkillDto);
await this.serviceSkillsRepository.save(skill); await this.serviceSkillsRepository.save(skill);
return this.findSkill(serviceId, skillId); return this.findSkill(serviceId, skillId);
@@ -454,6 +522,32 @@ export class ServicesService {
return rows; 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<void> {
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 { private normalizeHeader(value: ExcelJS.CellValue): string {
const text = this.cellToString(value)?.trim().toLowerCase() ?? ''; const text = this.cellToString(value)?.trim().toLowerCase() ?? '';
return text.replace(/\s+/g, ' '); return text.replace(/\s+/g, ' ');
@@ -482,10 +576,13 @@ export class ServicesService {
if (typeof value === 'number') { if (typeof value === 'number') {
return value; 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) { if (typeof value === 'object' && 'result' in value) {
return this.cellToNumber(value.result as ExcelJS.CellValue); 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; return Number.isFinite(parsed) ? parsed : null;
} }
} }