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