update up
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-26 11:07:55 +03:30
parent c439fdd6f9
commit c96df8748f
8 changed files with 234 additions and 21 deletions
+4
View File
@@ -714,6 +714,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 {
+4 -1
View File
@@ -21,7 +21,10 @@ export class AiController {
@UseGuards(OptionalAuthGuard)
@Post('public/ai/chat')
@ApiOperation({ summary: 'Ask the restaurant AI assistant about the menu' })
@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',
@@ -18,8 +18,20 @@ export interface AiChatCompletionResponse {
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;
+118 -7
View File
@@ -16,7 +16,11 @@ 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, AiChatResult } from '../interface/ai-chat.interface';
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';
@@ -53,7 +57,10 @@ export class AiService {
const foods = await this.em.find(
Food,
{ restaurant: restaurant.id, isActive: true },
{ fields: ['title', 'desc', 'content'], orderBy: { order: 'asc' } },
{
fields: ['id', 'title', 'desc', 'content', 'price', 'images', 'discount', 'isSpecialOffer'],
orderBy: { order: 'asc' },
},
);
const menuContext = this.buildMenuContext(foods);
@@ -62,7 +69,11 @@ export class AiService {
'تو دستیار هوشمند منوی رستوران هستی.',
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
'خیلی کوتاه جواب بده',
'خیلی کوتاه جواب بده.',
'وقتی غذایی پیشنهاد می‌دهی، فقط از شناسه‌های (id) موجود در منو استفاده کن.',
'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:',
'{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}',
'اگر غذایی پیشنهاد نمی‌کنی، foodIds را آرایه خالی بگذار.',
'',
'منوی رستوران:',
menuContext,
@@ -70,11 +81,16 @@ export class AiService {
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,
});
}
@@ -136,14 +152,16 @@ export class AiService {
user?: User | null;
question: string;
aiResponse: AiChatCompletionResponse;
answerOverride?: string;
foods?: AiChatOfferedFood[];
}): Promise<AiChatResult> {
const { restaurant, user = null, question, aiResponse } = params;
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 = choice?.message?.content?.trim();
const answer = (answerOverride ?? choice?.message?.content)?.trim();
if (!answer) {
this.logger.error(
@@ -188,6 +206,7 @@ export class AiService {
return {
answer,
foods,
consumedTokens,
cost,
promptTokens,
@@ -212,7 +231,9 @@ export class AiService {
return restaurant;
}
private buildMenuContext(foods: Array<Pick<Food, 'title' | 'desc' | 'content'>>): string {
private buildMenuContext(
foods: Array<Pick<Food, 'id' | 'title' | 'desc' | 'content' | 'price'>>,
): string {
if (!foods.length) {
return 'منوی رستوران در حال حاضر خالی است.';
}
@@ -221,14 +242,104 @@ export class AiService {
.map((food, index) => {
const contents = food.content?.length ? food.content.join('، ') : 'نامشخص';
return [
`${index + 1}. نام: ${food.title ?? 'بدون نام'}`,
`${index + 1}. id: ${food.id}`,
`نام: ${food.title ?? 'بدون نام'}`,
`توضیحات: ${food.desc ?? 'ندارد'}`,
`محتویات: ${contents}`,
`قیمت: ${food.price ?? 'نامشخص'}`,
].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;
+1 -1
View File
@@ -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);
@@ -788,6 +810,38 @@ export class OrdersService {
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 ?? '',
},
});
}
private assertCartHasItems(cart: Cart) {
if (!cart.items || cart.items.length === 0) {
throw new BadRequestException(OrderMessage.CART_EMPTY);