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 })
|
@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;
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user