add clients by excel

This commit is contained in:
2026-06-18 22:48:25 +03:30
parent 4ac1a19d2f
commit 2ce395d1f8
8 changed files with 370 additions and 4 deletions
+7
View File
@@ -775,6 +775,13 @@ export const enum PagerMessage {
export const enum UserSuccessMessage {
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
USERS_IMPORTED_SUCCESS = 'کاربران با موفقیت وارد شدند',
}
export const enum UserImportMessage {
FILE_REQUIRED = 'فایل اکسل الزامی است',
PHONE_REQUIRED = 'شماره تلفن الزامی است',
INVALID_PHONE = 'فرمت شماره تلفن صحیح نیست',
}
export const enum ContactMessage {
@@ -1,5 +1,6 @@
import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader } from '@nestjs/swagger';
import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param, UploadedFile, UseInterceptors } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader, ApiConsumes } from '@nestjs/swagger';
import { FileInterceptor, type File } from '@nest-lab/fastify-multer';
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
import { UserService } from '../providers/user.service';
import { UpdateUserDto } from '../dto/update-user.dto';
@@ -16,6 +17,7 @@ import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { WalletService } from '../providers/wallet.service';
import { FindUserByPhoneDto } from '../dto/find-user-by-phone.dto';
import { ImportUsersFromExcelDto } from '../dto/import-users-from-excel.dto';
@ApiTags('User')
@Controller()
@@ -193,4 +195,21 @@ export class UsersController {
) {
return this.userService.findByPhone(query.phone);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@ApiHeader(API_HEADER_SLUG)
@ApiOperation({ summary: 'Import users from Excel file into restaurant customers' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
@ApiBody({ type: ImportUsersFromExcelDto })
@Post('admin/users/import')
async importUsersFromExcel(@RestId() restId: string, @UploadedFile() file: File) {
const result = await this.userService.importUsersFromExcel(restId, file);
return {
message: UserSuccessMessage.USERS_IMPORTED_SUCCESS,
...result,
};
}
}
@@ -0,0 +1,7 @@
import type { File } from '@nest-lab/fastify-multer';
import { ApiProperty } from '@nestjs/swagger';
export class ImportUsersFromExcelDto {
@ApiProperty({ type: 'string', format: 'binary', nullable: false })
file!: File;
}
@@ -0,0 +1,21 @@
export interface ImportUserRow {
rowNumber: number;
phone: string;
firstName?: string;
lastName?: string;
gender?: boolean;
}
export interface ImportUserRowError {
row: number;
phone?: string;
message: string;
}
export interface ImportUsersResult {
total: number;
usersCreated: number;
usersLinked: number;
usersAlreadyLinked: number;
errors: ImportUserRowError[];
}
@@ -1,5 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { FilterQuery, RequiredEntityData } from '@mikro-orm/core';
import type { File } from '@nest-lab/fastify-multer';
import { User } from '../entities/user.entity';
import { UserAddress } from '../entities/user-address.entity';
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
@@ -20,6 +21,9 @@ import { WalletTransactionReason, WalletTransactionType } from '../interface/wal
import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { UserRestaurantRepository } from '../repositories/user-restuarant.repository';
import { UserRestaurant } from '../entities/user-restuarant.entity';
import { ImportUsersResult } from '../interfaces/import-users.interface';
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
import { UserImportMessage } from 'src/common/enums/message.enum';
@Injectable()
export class UserService {
@@ -420,4 +424,97 @@ export class UserService {
});
}
async importUsersFromExcel(restId: string, file: File): Promise<ImportUsersResult> {
if (!file?.buffer?.length) {
throw new BadRequestException(UserImportMessage.FILE_REQUIRED);
}
assertExcelMimeType(file.mimetype);
const rows = parseUsersExcel(file.buffer);
const restaurant = await this.restaurantRepository.findOne({ id: restId });
if (!restaurant) {
throw new NotFoundException(`Restaurant with ID ${restId} not found.`);
}
const result: ImportUsersResult = {
total: rows.length,
usersCreated: 0,
usersLinked: 0,
usersAlreadyLinked: 0,
errors: [],
};
for (const row of rows) {
try {
if (!row.phone) {
result.errors.push({
row: row.rowNumber,
message: UserImportMessage.PHONE_REQUIRED,
});
continue;
}
const normalizedPhone = normalizePhone(row.phone);
if (!normalizedPhone) {
result.errors.push({
row: row.rowNumber,
phone: row.phone,
message: UserImportMessage.INVALID_PHONE,
});
continue;
}
let user = await this.userRepository.findOne({ phone: normalizedPhone });
if (!user) {
const createData = {
phone: normalizedPhone,
firstName: row.firstName?.trim() || '[نام]',
lastName: row.lastName?.trim() || undefined,
gender: row.gender,
birthDate: undefined,
marriageDate: undefined,
} as unknown as RequiredEntityData<User>;
user = this.userRepository.create(createData);
await this.em.persistAndFlush(user);
result.usersCreated++;
} else {
const updates: Partial<User> = {};
if (row.firstName?.trim()) {
updates.firstName = row.firstName.trim();
}
if (row.lastName?.trim()) {
updates.lastName = row.lastName.trim();
}
if (row.gender !== undefined) {
updates.gender = row.gender;
}
if (Object.keys(updates).length > 0) {
this.em.assign(user, updates);
await this.em.flush();
}
}
const existingLink = await this.userRestaurantRepository.findOne({ user, restaurant });
if (existingLink) {
result.usersAlreadyLinked++;
continue;
}
await this.addUserToRestaurant(user, restaurant);
result.usersLinked++;
} catch (error) {
result.errors.push({
row: row.rowNumber,
phone: row.phone,
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return result;
}
}
@@ -0,0 +1,110 @@
import { BadRequestException } from '@nestjs/common';
import * as XLSX from 'xlsx';
import { normalizePhone } from 'src/modules/utils/phone.util';
import { ImportUserRow } from '../interfaces/import-users.interface';
export const MAX_IMPORT_ROWS = 500;
const ALLOWED_MIME_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/csv',
'application/csv',
]);
function normalizeHeader(header: string): string {
return String(header).trim();
}
function cellToString(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
export function parseGender(value: unknown): boolean | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
const str = String(value).trim().toLowerCase();
if (['true', '1', 'male', 'm', 'man', 'مرد'].includes(str)) {
return true;
}
if (['false', '0', 'female', 'f', 'woman', 'زن'].includes(str)) {
return false;
}
return undefined;
}
function getField(row: Record<string, unknown>, name: string): unknown {
const key = Object.keys(row).find(k => normalizeHeader(k).toLowerCase() === name.toLowerCase());
return key ? row[key] : undefined;
}
function isRowEmpty(row: Record<string, unknown>): boolean {
const phone = cellToString(getField(row, 'phone'));
const firstName = cellToString(getField(row, 'firstName'));
const lastName = cellToString(getField(row, 'lastName'));
const gender = getField(row, 'gender');
return !phone && !firstName && !lastName && (gender === undefined || gender === null || gender === '');
}
export function assertExcelMimeType(mimetype: string | undefined): void {
if (!mimetype || !ALLOWED_MIME_TYPES.has(mimetype)) {
throw new BadRequestException('File must be an Excel (.xlsx, .xls) or CSV file');
}
}
export function parseUsersExcel(buffer: Buffer): ImportUserRow[] {
const workbook = XLSX.read(buffer, { type: 'buffer' });
if (!workbook.SheetNames.length) {
throw new BadRequestException('Excel file has no sheets');
}
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' });
if (rawRows.length > MAX_IMPORT_ROWS) {
throw new BadRequestException(`Excel file contains more than ${MAX_IMPORT_ROWS} rows`);
}
if (rawRows.length === 0) {
return [];
}
const hasPhoneColumn = Object.keys(rawRows[0]).some(
key => normalizeHeader(key).toLowerCase() === 'phone',
);
if (!hasPhoneColumn) {
throw new BadRequestException('Excel file must have a "phone" column');
}
const rows: ImportUserRow[] = [];
rawRows.forEach((row, index) => {
if (isRowEmpty(row)) {
return;
}
rows.push({
rowNumber: index + 2,
phone: normalizePhone(cellToString(getField(row, 'phone'))),
firstName: cellToString(getField(row, 'firstName')) || undefined,
lastName: cellToString(getField(row, 'lastName')) || undefined,
gender: parseGender(getField(row, 'gender')),
});
});
if (rows.length > MAX_IMPORT_ROWS) {
throw new BadRequestException(`Excel file contains more than ${MAX_IMPORT_ROWS} data rows`);
}
return rows;
}