fix excel
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user