Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8225a3818c | |||
| 827864b19c | |||
| b59541b6f7 | |||
| 40e78b4ccd | |||
| ea629fcbf6 | |||
| c96df8748f | |||
| c439fdd6f9 | |||
| 27c45974f4 |
+14
@@ -0,0 +1,14 @@
|
||||
curl --location 'https://arvancloudai.ir/gateway/models/DeepSeek-V4-Flash/GShkYZ-j5g8GPs2hizngJUlGKcqhDw14rUWb0wu-eLcYjfqUVlvxAhbcBdjg5iH_FSs0uHY0ypJj94AIKmr-gotsFuKm2FqCGbNXJcBi7-2lTu3Ep_cQp_eCSvmo9y4GWUU8j26WQN7pvm-MgH-L9aH-wEdU-qsyqgSslgaPdPigDJz29XNbVmBX5uw3fMpyrJi_I8myhEAoQ9IwpPblDvyHJHgaMAHFX5NZ77PWfyrxCUWkCnlsLZwUUnQ3YEjujmNm77mDqQQ/v1/chat/completions' \
|
||||
-H 'Authorization: apikey 4fe6d150-7951-58f5-b813-2e4bd7e74734' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model":"DeepSeek-V4-Flash-f5unr",
|
||||
"messages":[
|
||||
{
|
||||
"role":"user",
|
||||
"content":"چرا آسمان آبی است؟"
|
||||
}
|
||||
],
|
||||
"max_tokens":1000,
|
||||
"temperature":0.8
|
||||
}'
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../dmenu-admin"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
"folders": [
|
||||
{
|
||||
"path": ".",
|
||||
},
|
||||
{
|
||||
"path": "../dmenu-admin",
|
||||
},
|
||||
],
|
||||
"settings": {},
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260723090000_addAiChats extends Migration {
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`
|
||||
create table "ai_chats" (
|
||||
"id" char(26) not null,
|
||||
"created_at" timestamptz not null default now(),
|
||||
"updated_at" timestamptz not null default now(),
|
||||
"deleted_at" timestamptz null,
|
||||
"restaurant_id" char(26) not null,
|
||||
"user_id" char(26) null,
|
||||
"question" text not null,
|
||||
"consumed_tokens" int not null,
|
||||
"cost" numeric(10,0) not null,
|
||||
constraint "ai_chats_pkey" primary key ("id")
|
||||
);
|
||||
`);
|
||||
this.addSql(`create index "ai_chats_created_at_index" on "ai_chats" ("created_at");`);
|
||||
this.addSql(`create index "ai_chats_deleted_at_index" on "ai_chats" ("deleted_at");`);
|
||||
this.addSql(`create index "ai_chats_restaurant_id_index" on "ai_chats" ("restaurant_id");`);
|
||||
this.addSql(`
|
||||
alter table "ai_chats"
|
||||
add constraint "ai_chats_restaurant_id_foreign"
|
||||
foreign key ("restaurant_id") references "restaurants" ("id") on update cascade;
|
||||
`);
|
||||
this.addSql(`
|
||||
alter table "ai_chats"
|
||||
add constraint "ai_chats_user_id_foreign"
|
||||
foreign key ("user_id") references "users" ("id") on update cascade on delete set null;
|
||||
`);
|
||||
|
||||
this.addSql(`
|
||||
alter table "restaurant_credit_transactions"
|
||||
drop constraint if exists "restaurant_credit_transactions_reason_check";
|
||||
`);
|
||||
this.addSql(`
|
||||
alter table "restaurant_credit_transactions"
|
||||
add constraint "restaurant_credit_transactions_reason_check"
|
||||
check ("reason" in ('sms_send', 'deposit', 'adjustment', 'ai_chat'));
|
||||
`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`
|
||||
alter table "restaurant_credit_transactions"
|
||||
drop constraint if exists "restaurant_credit_transactions_reason_check";
|
||||
`);
|
||||
this.addSql(`
|
||||
alter table "restaurant_credit_transactions"
|
||||
add constraint "restaurant_credit_transactions_reason_check"
|
||||
check ("reason" in ('sms_send', 'deposit', 'adjustment'));
|
||||
`);
|
||||
|
||||
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_user_id_foreign";`);
|
||||
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_restaurant_id_foreign";`);
|
||||
this.addSql(`drop table if exists "ai_chats" cascade;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20260726120000_addEnableAiChatToRestaurants extends Migration {
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(
|
||||
`alter table "restaurants" add column "enable_ai_chat" boolean not null default false;`,
|
||||
);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table "restaurants" drop column "enable_ai_chat";`);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module';
|
||||
import { InventoryModule } from './modules/inventory/inventory.module';
|
||||
import { IconsModule } from './modules/icons/icons.module';
|
||||
import { SliderModule } from './modules/slider/slider.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { cacheConfig } from './config/cache.config';
|
||||
|
||||
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
|
||||
InventoryModule,
|
||||
IconsModule,
|
||||
SliderModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
||||
@@ -600,6 +600,9 @@ export const enum SettingMessageEnum {
|
||||
|
||||
export const enum AiMessage {
|
||||
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
|
||||
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
|
||||
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
|
||||
AI_CHAT_DISABLED = 'چت هوش مصنوعی برای این رستوران فعال نیست',
|
||||
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
|
||||
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
|
||||
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
|
||||
@@ -712,6 +715,10 @@ export const enum OrderMessage {
|
||||
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
|
||||
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
|
||||
ADD_PAYMENT_NO_BALANCE = 'ماندهای برای پرداخت وجود ندارد',
|
||||
PLEASE_COME_SMS_QUEUED = 'پیامک دعوت به مراجعه در صف ارسال قرار گرفت',
|
||||
PLEASE_COME_SMS_PATTERN_MISSING = 'الگوی پیامک دعوت به مراجعه تنظیم نشده است',
|
||||
PLEASE_COME_USER_MISSING = 'این سفارش کاربری ندارد',
|
||||
PLEASE_COME_PHONE_MISSING = 'شماره تلفن کاربر یافت نشد',
|
||||
}
|
||||
|
||||
export const enum CartMessage {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AiController } from './controllers/ai.controller';
|
||||
import { AiService } from './providers/ai.service';
|
||||
import { AiChatRepository } from './repositories/ai-chat.repository';
|
||||
import { AiChat } from './entities/ai-chat.entity';
|
||||
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||
import { AiCrone } from './crone/ai.crone';
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AiChatRepository, AiCrone],
|
||||
exports: [AiChatRepository],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { AiService } from '../providers/ai.service';
|
||||
import { ChatDto } from '../dto/chat.dto';
|
||||
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
||||
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
|
||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||
|
||||
@ApiTags('ai')
|
||||
@Controller()
|
||||
export class AiController {
|
||||
constructor(private readonly aiService: AiService) {}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('public/ai/chat')
|
||||
@ApiOperation({
|
||||
summary: 'Ask the restaurant AI assistant about the menu',
|
||||
description: 'Returns an answer plus any offered foods populated from AI-selected food IDs',
|
||||
})
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiHeader({
|
||||
name: 'Authorization',
|
||||
required: false,
|
||||
description: 'Bearer token (optional authentication)',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@ApiBody({ type: ChatDto })
|
||||
async chat(
|
||||
@Body() dto: ChatDto,
|
||||
@RestSlug() slug: string,
|
||||
@RestId() restId?: string,
|
||||
@UserId() userId?: string,
|
||||
) {
|
||||
return this.aiService.chat(dto, {
|
||||
restId: restId || undefined,
|
||||
userId: userId || undefined,
|
||||
slug,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.UPDATE_RESTAURANT)
|
||||
@Get('admin/ai/chats')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get paginated list of AI chats for the restaurant' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'search', required: false, type: String })
|
||||
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||
async findAllPaginated(@RestId() restId: string, @Query() dto: FindAiChatsDto) {
|
||||
return this.aiService.findAllPaginated(restId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_FOODS)
|
||||
@Post('admin/ai/food-description')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate a two-line food description from a food name' })
|
||||
@ApiBody({ type: GenerateFoodDescriptionDto })
|
||||
async generateFoodDescription(@Body() dto: GenerateFoodDescriptionDto, @RestId() restId: string) {
|
||||
return this.aiService.generateFoodDescription(dto, restId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_FOODS)
|
||||
@Post('admin/ai/food-content')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate food ingredients/contents from a food name' })
|
||||
@ApiBody({ type: GenerateFoodContentDto })
|
||||
async generateFoodContent(@Body() dto: GenerateFoodContentDto, @RestId() restId: string) {
|
||||
return this.aiService.generateFoodContent(dto, restId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { CreateRequestContext } from '@mikro-orm/core';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { AiChat } from '../entities/ai-chat.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AiCrone {
|
||||
private readonly logger = new Logger(AiCrone.name);
|
||||
|
||||
constructor(private readonly em: EntityManager) {}
|
||||
|
||||
// run every day at 03:00 (3:00 AM)
|
||||
@Cron('0 3 * * *', {
|
||||
name: 'deleteOldAiChats',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
@CreateRequestContext()
|
||||
async handleCron() {
|
||||
try {
|
||||
this.logger.debug('Starting daily AI chat cleanup (removing questions older than 1 month)');
|
||||
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
const oldAiChats = await this.em.find(AiChat, {
|
||||
createdAt: { $lt: oneMonthAgo },
|
||||
});
|
||||
|
||||
if (!oldAiChats || oldAiChats.length === 0) {
|
||||
this.logger.debug('No old AI chats found to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${oldAiChats.length} AI chats older than 1 month to delete`);
|
||||
|
||||
await this.em.removeAndFlush(oldAiChats);
|
||||
|
||||
this.logger.log(`Successfully deleted ${oldAiChats.length} old AI chats`);
|
||||
} catch (err) {
|
||||
this.logger.error(`AiCrone failed: ${err?.message}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChatDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@ApiProperty({
|
||||
example: 'کدام غذا برای گیاهخواران مناسب است؟',
|
||||
description: 'User question for the restaurant AI assistant',
|
||||
})
|
||||
question!: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IsOptional, IsNumber, IsString, IsIn, Min } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||
type SortOrder = (typeof sortOrderOptions)[number];
|
||||
|
||||
export class FindAiChatsDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
|
||||
limit?: number = 10;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ example: 'گیاهخوار', description: 'Search in question text' })
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
|
||||
orderBy?: string = 'createdAt';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(sortOrderOptions)
|
||||
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
|
||||
order?: SortOrder = 'desc';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GenerateFoodContentDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@ApiProperty({
|
||||
example: 'چلو کباب کوبیده',
|
||||
description: 'Food name to generate ingredients/contents for',
|
||||
})
|
||||
foodName!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class GenerateFoodDescriptionDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@ApiProperty({
|
||||
example: 'چلو کباب کوبیده',
|
||||
description: 'Food name to generate a short description for',
|
||||
})
|
||||
foodName!: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity({ tableName: 'ai_chats' })
|
||||
@Index({ properties: ['restaurant'] })
|
||||
export class AiChat extends BaseEntity {
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
user?: User | null;
|
||||
|
||||
@Property({ type: 'text' })
|
||||
question!: string;
|
||||
|
||||
@Property({ type: 'int' })
|
||||
consumedTokens!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
cost!: number;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface AiChatCompletionUsage {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface AiChatCompletionChoice {
|
||||
finish_reason?: string | null;
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: string | null;
|
||||
reasoning_content?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AiChatCompletionResponse {
|
||||
choices?: AiChatCompletionChoice[];
|
||||
usage?: AiChatCompletionUsage;
|
||||
}
|
||||
|
||||
export interface AiChatOfferedFood {
|
||||
id: string;
|
||||
title?: string;
|
||||
desc?: string;
|
||||
content?: string[];
|
||||
price?: number;
|
||||
images?: string[];
|
||||
discount: number;
|
||||
isSpecialOffer: boolean;
|
||||
}
|
||||
|
||||
export interface AiChatResult {
|
||||
answer: string;
|
||||
foods: AiChatOfferedFood[];
|
||||
consumedTokens: number;
|
||||
cost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { AxiosError } from 'axios';
|
||||
import { catchError, firstValueFrom, throwError } from 'rxjs';
|
||||
import { ChatDto } from '../dto/chat.dto';
|
||||
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||
import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto';
|
||||
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||
import { AiChat } from '../entities/ai-chat.entity';
|
||||
import { AiChatRepository } from '../repositories/ai-chat.repository';
|
||||
import {
|
||||
AiChatCompletionResponse,
|
||||
AiChatOfferedFood,
|
||||
AiChatResult,
|
||||
} from '../interface/ai-chat.interface';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { RestaurantWalletService } from '../../restaurants/providers/restaurant-wallet.service';
|
||||
import {
|
||||
RestaurantCreditTransactionReason,
|
||||
RestaurantCreditTransactionType,
|
||||
} from '../../restaurants/interface/restaurant-credit-transaction.interface';
|
||||
import { AiMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly restaurantWalletService: RestaurantWalletService,
|
||||
private readonly aiChatRepository: AiChatRepository,
|
||||
) { }
|
||||
|
||||
findAllPaginated(restId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
|
||||
return this.aiChatRepository.findAllPaginated(restId, dto);
|
||||
}
|
||||
|
||||
async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise<AiChatResult> {
|
||||
const restaurant = await this.resolveRestaurant(params.restId, params.slug);
|
||||
|
||||
if (!restaurant.enableAiChat) {
|
||||
throw new BadRequestException(AiMessage.AI_CHAT_DISABLED);
|
||||
}
|
||||
|
||||
const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null;
|
||||
|
||||
await this.ensureHasCredit(restaurant.id);
|
||||
|
||||
const foods = await this.em.find(
|
||||
Food,
|
||||
{ restaurant: restaurant.id, isActive: true },
|
||||
{
|
||||
fields: ['id', 'title', 'desc', 'content', 'price', 'images', 'discount', 'isSpecialOffer'],
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
);
|
||||
|
||||
const menuContext = this.buildMenuContext(foods);
|
||||
const aiResponse = await this.callAiApi({
|
||||
systemPrompt: [
|
||||
'تو دستیار هوشمند منوی رستوران هستی.',
|
||||
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
|
||||
'یک توضیح کوتاه دو خطی هم اگه لازم بود اضافه کن',
|
||||
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
|
||||
'وقتی غذایی پیشنهاد میدهی، فقط از شناسههای (id) موجود در منو استفاده کن.',
|
||||
'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:',
|
||||
'{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}',
|
||||
'اگر غذایی پیشنهاد نمیکنی، foodIds را آرایه خالی بگذار.',
|
||||
'هیچ وقت id غذا را داخل متن قرار نده.',
|
||||
'',
|
||||
'منوی رستوران:',
|
||||
menuContext,
|
||||
].join('\n'),
|
||||
userPrompt: dto.question,
|
||||
});
|
||||
|
||||
const parsed = this.parseChatAiContent(aiResponse);
|
||||
const offeredFoods = this.populateOfferedFoods(parsed.foodIds, foods);
|
||||
|
||||
return this.persistAiUsage({
|
||||
restaurant,
|
||||
user,
|
||||
question: dto.question,
|
||||
aiResponse,
|
||||
answerOverride: parsed.answer,
|
||||
foods: offeredFoods,
|
||||
});
|
||||
}
|
||||
|
||||
async generateFoodDescription(dto: GenerateFoodDescriptionDto, restId: string): Promise<AiChatResult> {
|
||||
const restaurant = await this.resolveRestaurant(restId);
|
||||
await this.ensureHasCredit(restaurant.id);
|
||||
|
||||
const aiResponse = await this.callAiApi({
|
||||
systemPrompt: [
|
||||
'تو نویسنده منوی رستوران هستی.',
|
||||
'برای نام غذای دادهشده، دقیقاً یک توضیح کوتاه دو خطی به فارسی بنویس.',
|
||||
'فقط همان دو خط را برگردان؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
|
||||
'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.',
|
||||
].join('\n'),
|
||||
userPrompt: `نام غذا: ${dto.foodName}`,
|
||||
maxTokens: 20000,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return this.persistAiUsage({
|
||||
restaurant,
|
||||
question: `تولید توضیح برای غذا: ${dto.foodName}`,
|
||||
aiResponse,
|
||||
});
|
||||
}
|
||||
|
||||
async generateFoodContent(dto: GenerateFoodContentDto, restId: string): Promise<AiChatResult> {
|
||||
const restaurant = await this.resolveRestaurant(restId);
|
||||
await this.ensureHasCredit(restaurant.id);
|
||||
|
||||
const aiResponse = await this.callAiApi({
|
||||
systemPrompt: [
|
||||
'تو نویسنده منوی رستوران هستی.',
|
||||
'برای نام غذای دادهشده، محتویات و مواد تشکیلدهنده را به فارسی بنویس.',
|
||||
'فقط لیست مواد را با ویرگول فارسی (،) جدا کن؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
|
||||
'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.',
|
||||
].join('\n'),
|
||||
userPrompt: `نام غذا: ${dto.foodName}`,
|
||||
maxTokens: 20000,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return this.persistAiUsage({
|
||||
restaurant,
|
||||
question: `تولید محتویات برای غذا: ${dto.foodName}`,
|
||||
aiResponse,
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureHasCredit(restaurantId: string): Promise<void> {
|
||||
const balance = await this.restaurantWalletService.getCurrentBalance(restaurantId);
|
||||
if (balance <= 0) {
|
||||
throw new BadRequestException(AiMessage.INSUFFICIENT_CREDIT);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistAiUsage(params: {
|
||||
restaurant: Restaurant;
|
||||
user?: User | null;
|
||||
question: string;
|
||||
aiResponse: AiChatCompletionResponse;
|
||||
answerOverride?: string;
|
||||
foods?: AiChatOfferedFood[];
|
||||
}): Promise<AiChatResult> {
|
||||
const { restaurant, user = null, question, aiResponse, answerOverride, foods = [] } = params;
|
||||
|
||||
const promptTokens = Number(aiResponse.usage?.prompt_tokens ?? 0);
|
||||
const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0);
|
||||
const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens);
|
||||
const choice = aiResponse.choices?.[0];
|
||||
const answer = (answerOverride ?? choice?.message?.content)?.trim();
|
||||
|
||||
if (!answer) {
|
||||
this.logger.error(
|
||||
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
|
||||
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
|
||||
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
|
||||
}
|
||||
|
||||
// Prices in .env are per 1,000,000 tokens
|
||||
const promptTokenPricePerMillion = Number(this.configService.getOrThrow<string>('AI_PROMPT_TOKEN_PRICE_PER_MILLION'));
|
||||
const completionTokenPricePerMillion = Number(
|
||||
this.configService.getOrThrow<string>('AI_COMPLETION_TOKEN_PRICE_PER_MILLION'),
|
||||
);
|
||||
const cost = Math.ceil(
|
||||
(promptTokens * promptTokenPricePerMillion + completionTokens * completionTokenPricePerMillion) /
|
||||
1_000_000,
|
||||
);
|
||||
|
||||
await this.em.transactional(async (em) => {
|
||||
const aiChat = em.create(AiChat, {
|
||||
restaurant,
|
||||
user,
|
||||
question,
|
||||
consumedTokens,
|
||||
cost,
|
||||
});
|
||||
await em.persistAndFlush(aiChat);
|
||||
|
||||
if (cost > 0) {
|
||||
await this.restaurantWalletService.createTransaction({
|
||||
restaurant,
|
||||
amount: cost,
|
||||
type: RestaurantCreditTransactionType.DEBIT,
|
||||
reason: RestaurantCreditTransactionReason.AI_CHAT,
|
||||
insufficientBalanceMessage: AiMessage.INSUFFICIENT_CREDIT,
|
||||
em,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
answer,
|
||||
foods,
|
||||
consumedTokens,
|
||||
cost,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRestaurant(restId?: string, slug?: string): Promise<Restaurant> {
|
||||
if (!restId && !slug) {
|
||||
throw new BadRequestException('شناسه یا اسلاگ رستوران الزامی است');
|
||||
}
|
||||
|
||||
const restaurant = await this.em.findOne(Restaurant, {
|
||||
...(restId ? { id: restId } : {}),
|
||||
...(slug ? { slug } : {}),
|
||||
});
|
||||
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
private buildMenuContext(
|
||||
foods: Array<Pick<Food, 'id' | 'title' >>,
|
||||
): string {
|
||||
if (!foods.length) {
|
||||
return 'منوی رستوران در حال حاضر خالی است.';
|
||||
}
|
||||
|
||||
return foods
|
||||
.map((food, index) => {
|
||||
return [
|
||||
`${index + 1}. id: ${food.id}`,
|
||||
`نام: ${food.title ?? 'بدون نام'}`
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
private parseChatAiContent(aiResponse: AiChatCompletionResponse): {
|
||||
answer: string;
|
||||
foodIds: string[];
|
||||
} {
|
||||
const choice = aiResponse.choices?.[0];
|
||||
const raw = choice?.message?.content?.trim();
|
||||
|
||||
if (!raw) {
|
||||
this.logger.error(
|
||||
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
|
||||
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
|
||||
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
|
||||
}
|
||||
|
||||
const jsonPayload = this.extractJsonObject(raw);
|
||||
if (!jsonPayload) {
|
||||
return { answer: raw, foodIds: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonPayload) as { answer?: unknown; foodIds?: unknown };
|
||||
const answer = typeof parsed.answer === 'string' ? parsed.answer.trim() : raw;
|
||||
const foodIds = Array.isArray(parsed.foodIds)
|
||||
? parsed.foodIds.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
: [];
|
||||
|
||||
return { answer: answer || raw, foodIds };
|
||||
} catch {
|
||||
this.logger.warn(`Failed to parse AI chat JSON response: ${raw.slice(0, 200)}`);
|
||||
return { answer: raw, foodIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private extractJsonObject(raw: string): string | null {
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fenced?.[1]) {
|
||||
return fenced[1].trim();
|
||||
}
|
||||
|
||||
const start = raw.indexOf('{');
|
||||
const end = raw.lastIndexOf('}');
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw.slice(start, end + 1);
|
||||
}
|
||||
|
||||
private populateOfferedFoods(
|
||||
foodIds: string[],
|
||||
menuFoods: Array<
|
||||
Pick<Food, 'id' | 'title' | 'desc' | 'content' | 'price' | 'images' | 'discount' | 'isSpecialOffer'>
|
||||
>,
|
||||
): AiChatOfferedFood[] {
|
||||
if (!foodIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const foodMap = new Map(menuFoods.map((food) => [food.id, food]));
|
||||
const seen = new Set<string>();
|
||||
|
||||
return foodIds.reduce<AiChatOfferedFood[]>((acc, foodId) => {
|
||||
if (seen.has(foodId)) {
|
||||
return acc;
|
||||
}
|
||||
seen.add(foodId);
|
||||
|
||||
const food = foodMap.get(foodId);
|
||||
if (!food) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push({
|
||||
id: food.id,
|
||||
title: food.title,
|
||||
desc: food.desc,
|
||||
content: food.content,
|
||||
price: food.price,
|
||||
images: food.images,
|
||||
discount: food.discount,
|
||||
isSpecialOffer: food.isSpecialOffer,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
private async callAiApi(params: {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
}): Promise<AiChatCompletionResponse> {
|
||||
const apiUrl = this.configService.getOrThrow<string>('AI_API_URL');
|
||||
const apiKey = this.configService.getOrThrow<string>('AI_API_KEY');
|
||||
const model = this.configService.getOrThrow<string>('AI_MODEL');
|
||||
const maxTokens = params.maxTokens ?? Number(this.configService.get('AI_MAX_TOKENS') ?? 1000);
|
||||
const temperature = params.temperature ?? Number(this.configService.get('AI_TEMPERATURE') ?? 0.8);
|
||||
|
||||
try {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService
|
||||
.post<AiChatCompletionResponse>(
|
||||
apiUrl,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: params.systemPrompt },
|
||||
{ role: 'user', content: params.userPrompt },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
// DeepSeek V4 thinking is on by default; CoT can exhaust max_tokens and leave content empty.
|
||||
thinking: { type: 'disabled' },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `apikey ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
catchError((err: AxiosError) => {
|
||||
this.logger.error(`AI API request failed: ${err.message}`, err.stack);
|
||||
return throwError(() => err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return data;
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`AI API error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw new ServiceUnavailableException(AiMessage.LLM_SERVICE_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { FilterQuery } from '@mikro-orm/core';
|
||||
import { AiChat } from '../entities/ai-chat.entity';
|
||||
import { FindAiChatsDto } from '../dto/find-ai-chats.dto';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AiChatRepository extends EntityRepository<AiChat> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, AiChat);
|
||||
}
|
||||
|
||||
async findAllPaginated(restaurantId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
|
||||
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<AiChat> = {
|
||||
restaurant: { id: restaurantId },
|
||||
};
|
||||
|
||||
if (search) {
|
||||
where.question = { $ilike: `%${search}%` };
|
||||
}
|
||||
|
||||
const [data, total] = await this.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['user'],
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,6 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
|
||||
CartCalculationService,
|
||||
CartItemService,
|
||||
],
|
||||
exports: [CartService],
|
||||
exports: [CartService, CartValidationService],
|
||||
})
|
||||
export class CartModule { }
|
||||
|
||||
@@ -109,33 +109,51 @@ export class CartValidationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert address is inside restaurant service area
|
||||
* Whether the restaurant has a usable GeoJSON polygon service area.
|
||||
* Empty / incomplete polygons are treated as "not set".
|
||||
*/
|
||||
hasConfiguredServiceArea(
|
||||
serviceArea?: { type: 'Polygon'; coordinates: number[][][] } | null,
|
||||
): boolean {
|
||||
if (!serviceArea?.coordinates || !Array.isArray(serviceArea.coordinates)) return false;
|
||||
|
||||
const ring = serviceArea.coordinates[0];
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||
|
||||
return ring.every(
|
||||
coord =>
|
||||
Array.isArray(coord) &&
|
||||
coord.length >= 2 &&
|
||||
Number.isFinite(Number(coord[0])) &&
|
||||
Number.isFinite(Number(coord[1])),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert address is inside restaurant service area.
|
||||
* Skips entirely when the restaurant has not configured a service area.
|
||||
*/
|
||||
async assertAddressInsideServiceArea(
|
||||
restaurantId: string,
|
||||
latitude?: number | null,
|
||||
longitude?: number | null,
|
||||
): Promise<void> {
|
||||
const restaurant = await this.getRestaurantOrFail(restaurantId);
|
||||
|
||||
// If no service area is defined, assume service is available everywhere
|
||||
if (!this.hasConfiguredServiceArea(restaurant.serviceArea)) return;
|
||||
|
||||
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
|
||||
}
|
||||
|
||||
const restaurant = await this.getRestaurantOrFail(restaurantId);
|
||||
|
||||
const serviceArea = restaurant.serviceArea;
|
||||
// If no service area is defined, assume service is available everywhere
|
||||
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
|
||||
|
||||
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
||||
const rings = serviceArea.coordinates;
|
||||
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
||||
|
||||
const ring = rings[0];
|
||||
const ring = restaurant.serviceArea!.coordinates[0];
|
||||
const point: [number, number] = [Number(longitude), Number(latitude)];
|
||||
|
||||
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
||||
const polygon: [number, number][] = ring.map(
|
||||
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
|
||||
coord => [Number(coord[0]), Number(coord[1])] as [number, number],
|
||||
);
|
||||
|
||||
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FoodOrderReportDto } from '../dto/food-order-report.dto';
|
||||
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
|
||||
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
||||
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
||||
import { OrderMessage } from 'src/common/enums/message.enum';
|
||||
|
||||
@ApiTags('orders')
|
||||
@ApiBearerAuth()
|
||||
@@ -184,6 +185,16 @@ export class OrdersController {
|
||||
return this.ordersService.refundOrder(orderId, restId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_ORDERS)
|
||||
@Post('admin/orders/:orderId/please-come')
|
||||
@ApiOperation({ summary: 'Send please-come SMS to the order user' })
|
||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||
async sendPleaseComeSms(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||
await this.ordersService.sendPleaseComeSms(orderId, restId);
|
||||
return { message: OrderMessage.PLEASE_COME_SMS_QUEUED };
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_ORDERS)
|
||||
@Patch('admin/orders/:orderId/:status')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { OrderItem } from '../entities/order-item.entity';
|
||||
@@ -8,6 +9,7 @@ import { UserRestaurant } from '../../users/entities/user-restuarant.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { CartService } from '../../cart/providers/cart.service';
|
||||
import { CartValidationService } from '../../cart/providers/cart-validation.service';
|
||||
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||
import { Cart } from '../../cart/interfaces/cart.interface';
|
||||
@@ -36,6 +38,7 @@ import { CashShiftsService } from './cash-shifts.service';
|
||||
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
|
||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
||||
import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum';
|
||||
import { SmsQueueService } from 'src/modules/notifications/services/sms-queue.service';
|
||||
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||
|
||||
type ValidatedCartForOrder = {
|
||||
@@ -65,11 +68,14 @@ export class OrdersService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly cartService: CartService,
|
||||
private readonly cartValidationService: CartValidationService,
|
||||
private readonly orderRepository: OrderRepository,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly cashShiftsService: CashShiftsService,
|
||||
private readonly smsQueueService: SmsQueueService,
|
||||
private readonly configService: ConfigService,
|
||||
) { }
|
||||
|
||||
async checkout(userId: string, restaurantId: string) {
|
||||
@@ -180,6 +186,14 @@ export class OrdersService {
|
||||
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
|
||||
: null;
|
||||
|
||||
if (userAddress) {
|
||||
await this.cartValidationService.assertAddressInsideServiceArea(
|
||||
restaurantId,
|
||||
userAddress.latitude,
|
||||
userAddress.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
|
||||
|
||||
// Validate admin discount against item totals (not persisted — DB recomputes order money fields)
|
||||
@@ -434,6 +448,14 @@ export class OrdersService {
|
||||
this.assertMeetsMinOrderForDelivery(cart, delivery);
|
||||
this.assertDeliveryMethodRequirements(cart, delivery);
|
||||
|
||||
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && cart.userAddress) {
|
||||
await this.cartValidationService.assertAddressInsideServiceArea(
|
||||
restaurantId,
|
||||
cart.userAddress.latitude,
|
||||
cart.userAddress.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
||||
this.assertPaymentMethodEnabled(paymentMethod);
|
||||
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
|
||||
@@ -782,12 +804,45 @@ export class OrdersService {
|
||||
const order = await this.em.findOne(
|
||||
Order,
|
||||
{ id: orderId, restaurant: { id: restId } },
|
||||
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] },
|
||||
{ populate: ['paymentMethod', 'deliveryMethod', 'user', 'restaurant'] },
|
||||
);
|
||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
return order;
|
||||
}
|
||||
|
||||
async sendPleaseComeSms(orderId: string, restId: string): Promise<void> {
|
||||
const order = await this.getOrderOrFail(orderId, restId);
|
||||
const user = order.user;
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException(OrderMessage.PLEASE_COME_USER_MISSING);
|
||||
}
|
||||
|
||||
if (!user.phone) {
|
||||
throw new BadRequestException(OrderMessage.PLEASE_COME_PHONE_MISSING);
|
||||
}
|
||||
|
||||
const templateId = this.configService.get<string>('SMS_PATTERN_PLEASE_COME');
|
||||
if (!templateId) {
|
||||
throw new BadRequestException(OrderMessage.PLEASE_COME_SMS_PATTERN_MISSING);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Queueing please-come SMS for order ${orderId} to user ${user.id} (${user.phone})`,
|
||||
);
|
||||
|
||||
await this.smsQueueService.enqueue({
|
||||
phone: user.phone,
|
||||
templateId,
|
||||
restaurantId: restId,
|
||||
quantity: 1,
|
||||
params: {
|
||||
name: user.firstName ?? '',
|
||||
rest: order.restaurant?.name ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private assertCartHasItems(cart: Cart) {
|
||||
if (!cart.items || cart.items.length === 0) {
|
||||
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
||||
|
||||
@@ -52,4 +52,9 @@ export class CreateRestaurantDto {
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableAiChat?: boolean;
|
||||
|
||||
}
|
||||
|
||||
@@ -51,4 +51,9 @@ export class SetupRestaurantDto {
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableAiChat?: boolean;
|
||||
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ export class Restaurant extends BaseEntity {
|
||||
@Property({ default: true })
|
||||
isActive: boolean = true;
|
||||
|
||||
@Property({ default: false })
|
||||
enableAiChat: boolean = false;
|
||||
|
||||
@Property({ nullable: true })
|
||||
establishedYear?: number;
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
|
||||
SMS_SEND = 'sms_send',
|
||||
DEPOSIT = 'deposit',
|
||||
ADJUSTMENT = 'adjustment',
|
||||
AI_CHAT = 'ai_chat',
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ export class RestaurantsService {
|
||||
establishedYear: dto.establishedYear,
|
||||
phones: dto.phone ? [dto.phone] : undefined,
|
||||
isActive: true,
|
||||
enableAiChat: dto.enableAiChat ?? false,
|
||||
plan: dto.plan,
|
||||
domain: `https://dmenu.danakcorp.com/${slug}`,
|
||||
subscriptionId: dto.subscriptionId,
|
||||
|
||||
@@ -14,6 +14,7 @@ export class RestaurantsSeeder {
|
||||
subscriptionStartDate: new Date('2026-01-01'),
|
||||
subscriptionEndDate: new Date('2026-01-07'),
|
||||
isActive: true,
|
||||
enableAiChat: false,
|
||||
});
|
||||
em.persist(restaurant);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user