@@ -714,6 +714,10 @@ export const enum OrderMessage {
|
|||||||
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
|
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
|
||||||
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
|
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
|
||||||
ADD_PAYMENT_NO_BALANCE = 'ماندهای برای پرداخت وجود ندارد',
|
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 {
|
export const enum CartMessage {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export class AiController {
|
|||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post('public/ai/chat')
|
@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(API_HEADER_SLUG)
|
||||||
@ApiHeader({
|
@ApiHeader({
|
||||||
name: 'Authorization',
|
name: 'Authorization',
|
||||||
|
|||||||
@@ -18,8 +18,20 @@ export interface AiChatCompletionResponse {
|
|||||||
usage?: AiChatCompletionUsage;
|
usage?: AiChatCompletionUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiChatOfferedFood {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
desc?: string;
|
||||||
|
content?: string[];
|
||||||
|
price?: number;
|
||||||
|
images?: string[];
|
||||||
|
discount: number;
|
||||||
|
isSpecialOffer: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiChatResult {
|
export interface AiChatResult {
|
||||||
answer: string;
|
answer: string;
|
||||||
|
foods: AiChatOfferedFood[];
|
||||||
consumedTokens: number;
|
consumedTokens: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
promptTokens: number;
|
promptTokens: number;
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto
|
|||||||
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
|
||||||
import { AiChat } from '../entities/ai-chat.entity';
|
import { AiChat } from '../entities/ai-chat.entity';
|
||||||
import { AiChatRepository } from '../repositories/ai-chat.repository';
|
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 { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { User } from '../../users/entities/user.entity';
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
@@ -53,7 +57,10 @@ export class AiService {
|
|||||||
const foods = await this.em.find(
|
const foods = await this.em.find(
|
||||||
Food,
|
Food,
|
||||||
{ restaurant: restaurant.id, isActive: true },
|
{ 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);
|
const menuContext = this.buildMenuContext(foods);
|
||||||
@@ -62,7 +69,11 @@ export class AiService {
|
|||||||
'تو دستیار هوشمند منوی رستوران هستی.',
|
'تو دستیار هوشمند منوی رستوران هستی.',
|
||||||
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
|
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
|
||||||
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
|
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
|
||||||
'خیلی کوتاه جواب بده',
|
'خیلی کوتاه جواب بده.',
|
||||||
|
'وقتی غذایی پیشنهاد میدهی، فقط از شناسههای (id) موجود در منو استفاده کن.',
|
||||||
|
'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:',
|
||||||
|
'{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}',
|
||||||
|
'اگر غذایی پیشنهاد نمیکنی، foodIds را آرایه خالی بگذار.',
|
||||||
'',
|
'',
|
||||||
'منوی رستوران:',
|
'منوی رستوران:',
|
||||||
menuContext,
|
menuContext,
|
||||||
@@ -70,11 +81,16 @@ export class AiService {
|
|||||||
userPrompt: dto.question,
|
userPrompt: dto.question,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parsed = this.parseChatAiContent(aiResponse);
|
||||||
|
const offeredFoods = this.populateOfferedFoods(parsed.foodIds, foods);
|
||||||
|
|
||||||
return this.persistAiUsage({
|
return this.persistAiUsage({
|
||||||
restaurant,
|
restaurant,
|
||||||
user,
|
user,
|
||||||
question: dto.question,
|
question: dto.question,
|
||||||
aiResponse,
|
aiResponse,
|
||||||
|
answerOverride: parsed.answer,
|
||||||
|
foods: offeredFoods,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,14 +152,16 @@ export class AiService {
|
|||||||
user?: User | null;
|
user?: User | null;
|
||||||
question: string;
|
question: string;
|
||||||
aiResponse: AiChatCompletionResponse;
|
aiResponse: AiChatCompletionResponse;
|
||||||
|
answerOverride?: string;
|
||||||
|
foods?: AiChatOfferedFood[];
|
||||||
}): Promise<AiChatResult> {
|
}): 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 promptTokens = Number(aiResponse.usage?.prompt_tokens ?? 0);
|
||||||
const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0);
|
const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0);
|
||||||
const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens);
|
const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens);
|
||||||
const choice = aiResponse.choices?.[0];
|
const choice = aiResponse.choices?.[0];
|
||||||
const answer = choice?.message?.content?.trim();
|
const answer = (answerOverride ?? choice?.message?.content)?.trim();
|
||||||
|
|
||||||
if (!answer) {
|
if (!answer) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -188,6 +206,7 @@ export class AiService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
answer,
|
answer,
|
||||||
|
foods,
|
||||||
consumedTokens,
|
consumedTokens,
|
||||||
cost,
|
cost,
|
||||||
promptTokens,
|
promptTokens,
|
||||||
@@ -212,7 +231,9 @@ export class AiService {
|
|||||||
return restaurant;
|
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) {
|
if (!foods.length) {
|
||||||
return 'منوی رستوران در حال حاضر خالی است.';
|
return 'منوی رستوران در حال حاضر خالی است.';
|
||||||
}
|
}
|
||||||
@@ -221,14 +242,104 @@ export class AiService {
|
|||||||
.map((food, index) => {
|
.map((food, index) => {
|
||||||
const contents = food.content?.length ? food.content.join('، ') : 'نامشخص';
|
const contents = food.content?.length ? food.content.join('، ') : 'نامشخص';
|
||||||
return [
|
return [
|
||||||
`${index + 1}. نام: ${food.title ?? 'بدون نام'}`,
|
`${index + 1}. id: ${food.id}`,
|
||||||
|
`نام: ${food.title ?? 'بدون نام'}`,
|
||||||
`توضیحات: ${food.desc ?? 'ندارد'}`,
|
`توضیحات: ${food.desc ?? 'ندارد'}`,
|
||||||
`محتویات: ${contents}`,
|
`محتویات: ${contents}`,
|
||||||
|
`قیمت: ${food.price ?? 'نامشخص'}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
})
|
})
|
||||||
.join('\n\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: {
|
private async callAiApi(params: {
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
userPrompt: string;
|
userPrompt: string;
|
||||||
|
|||||||
@@ -48,6 +48,6 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
|
|||||||
CartCalculationService,
|
CartCalculationService,
|
||||||
CartItemService,
|
CartItemService,
|
||||||
],
|
],
|
||||||
exports: [CartService],
|
exports: [CartService, CartValidationService],
|
||||||
})
|
})
|
||||||
export class CartModule { }
|
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(
|
async assertAddressInsideServiceArea(
|
||||||
restaurantId: string,
|
restaurantId: string,
|
||||||
latitude?: number | null,
|
latitude?: number | null,
|
||||||
longitude?: number | null,
|
longitude?: number | null,
|
||||||
): Promise<void> {
|
): 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) {
|
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||||
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
|
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
|
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
||||||
const rings = serviceArea.coordinates;
|
const ring = restaurant.serviceArea!.coordinates[0];
|
||||||
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
|
||||||
|
|
||||||
const ring = rings[0];
|
|
||||||
const point: [number, number] = [Number(longitude), Number(latitude)];
|
const point: [number, number] = [Number(longitude), Number(latitude)];
|
||||||
|
|
||||||
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
||||||
const polygon: [number, number][] = ring.map(
|
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)) {
|
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 { DailyOrderReportDto } from '../dto/daily-order-report.dto';
|
||||||
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
|
||||||
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
|
||||||
|
import { OrderMessage } from 'src/common/enums/message.enum';
|
||||||
|
|
||||||
@ApiTags('orders')
|
@ApiTags('orders')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -184,6 +185,16 @@ export class OrdersController {
|
|||||||
return this.ordersService.refundOrder(orderId, restId, dto);
|
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)
|
@UseGuards(AdminAuthGuard)
|
||||||
@Permissions(Permission.MANAGE_ORDERS)
|
@Permissions(Permission.MANAGE_ORDERS)
|
||||||
@Patch('admin/orders/:orderId/:status')
|
@Patch('admin/orders/:orderId/:status')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
import { Order } from '../entities/order.entity';
|
import { Order } from '../entities/order.entity';
|
||||||
import { OrderItem } from '../entities/order-item.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 { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { CartService } from '../../cart/providers/cart.service';
|
import { CartService } from '../../cart/providers/cart.service';
|
||||||
|
import { CartValidationService } from '../../cart/providers/cart-validation.service';
|
||||||
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||||
import { Cart } from '../../cart/interfaces/cart.interface';
|
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 { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
|
||||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
||||||
import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum';
|
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 OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||||
|
|
||||||
type ValidatedCartForOrder = {
|
type ValidatedCartForOrder = {
|
||||||
@@ -65,11 +68,14 @@ export class OrdersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly cartService: CartService,
|
private readonly cartService: CartService,
|
||||||
|
private readonly cartValidationService: CartValidationService,
|
||||||
private readonly orderRepository: OrderRepository,
|
private readonly orderRepository: OrderRepository,
|
||||||
private readonly paymentsService: PaymentsService,
|
private readonly paymentsService: PaymentsService,
|
||||||
private readonly inventoryService: InventoryService,
|
private readonly inventoryService: InventoryService,
|
||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
private readonly cashShiftsService: CashShiftsService,
|
private readonly cashShiftsService: CashShiftsService,
|
||||||
|
private readonly smsQueueService: SmsQueueService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async checkout(userId: string, restaurantId: string) {
|
async checkout(userId: string, restaurantId: string) {
|
||||||
@@ -180,6 +186,14 @@ export class OrdersService {
|
|||||||
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
|
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (userAddress) {
|
||||||
|
await this.cartValidationService.assertAddressInsideServiceArea(
|
||||||
|
restaurantId,
|
||||||
|
userAddress.latitude,
|
||||||
|
userAddress.longitude,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
|
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
|
||||||
|
|
||||||
// Validate admin discount against item totals (not persisted — DB recomputes order money fields)
|
// 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.assertMeetsMinOrderForDelivery(cart, delivery);
|
||||||
this.assertDeliveryMethodRequirements(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);
|
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
||||||
this.assertPaymentMethodEnabled(paymentMethod);
|
this.assertPaymentMethodEnabled(paymentMethod);
|
||||||
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
|
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
|
||||||
@@ -788,6 +810,38 @@ export class OrdersService {
|
|||||||
return order;
|
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) {
|
private assertCartHasItems(cart: Cart) {
|
||||||
if (!cart.items || cart.items.length === 0) {
|
if (!cart.items || cart.items.length === 0) {
|
||||||
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
||||||
|
|||||||
Reference in New Issue
Block a user