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 })
failed: number;
@ApiProperty({ example: 2 })
skipped: number;
@ApiProperty({ type: [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 { 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 })
+105 -8
View File
@@ -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<ServiceSkill> {
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<string>();
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<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[]> {
await this.ensureServiceExists(serviceId);
return this.serviceSkillsRepository.find({
@@ -239,6 +300,13 @@ export class ServicesService {
): Promise<ServiceSkill> {
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<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 {
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;
}
}