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
+105 -1
View File
@@ -54,7 +54,8 @@
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
"ulid": "^3.0.1",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
@@ -8146,6 +8147,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -8908,6 +8918,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -9149,6 +9172,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
@@ -9357,6 +9389,18 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -11027,6 +11071,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -15923,6 +15976,18 @@
"node": ">= 0.6"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-utils": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@@ -17601,6 +17666,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -17736,6 +17819,27 @@
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2 -1
View File
@@ -79,7 +79,8 @@
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
"ulid": "^3.0.1",
"uuid": "^13.0.0"
"uuid": "^13.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
+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;
}