Проект

Общее

Профиль

Task #52607

открыто

Получение меню ресторана

Добавил(а) Аноним 8 месяца назад. Обновлено 8 месяца назад.

Статус:
Новая
Приоритет:
Нормально
Назначена:
-
Дата начала:
02.04.2025
Срок:
Готовность:

0%

План:

Описание

Получение меню ресторана

Описание

Как пользователь, я хочу получить список блюд из выбранного ресторана, чтобы выбрать что-то для заказа.

Цель:

  • Увидеть доступные блюда, их состав и цены
  • Принять осознанное решение о заказе
  • Быстро найти подходящие блюда для моих предпочтений

Критерии приемки

Основной сценарий

  • Пользователь выбирает ресторан.
  • Приложение отправляет GET-запрос к API.
  • Сервер возвращает JSON-ответ с меню.

Пример ответа:

{
  "id_menu": 1,
  "name": "Постное",
  "dishes": [
    {
      "id": 1,
      "name": "Салат",
      "sostav": ["огурцы", "помидоры"],
      "price": 250.0
    }
  ]
}

Альтернативные сценарии

Не авторизован:

{
  "error": "Необходима авторизация" 
}

Меню не найдено:

{
  "error": "Меню не найдено" 
}

Технические детали

SQL-запрос:

SELECT f.id, f.name, f.composition, f.price, m.name AS menu_name
FROM food f
JOIN menus m ON f.menu_id = m.id
JOIN restaurants r ON m.restaurant_id = r.id
WHERE r.id = :restaurant_id;

Таблицы:

  • restaurants (рестораны)
  • menus (меню)
  • food (блюда)

Примечания:

  • Состав блюда - массив строк
  • Ответ в формате JSON
  • Время ответа < 2 секунд

Диаграммы:

picture081-1.png

picture081-2.png

picture081-3.png


Файлы

picture081-1.png (38,2 КБ) picture081-1.png , 02.04.2025 13:25
picture081-2.png (50,3 КБ) picture081-2.png , 02.04.2025 13:25
picture081-3.png (66,3 КБ) picture081-3.png , 02.04.2025 13:25
picture400-1.png (49,8 КБ) picture400-1.png Артур Нигматуллин, 02.04.2025 16:00

Обновлено 8 месяца назад

Вариант реализации метода на FastAPI:

from fastapi import FastAPI, Depends, HTTPException, Query, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.sql import func
from database import get_db
from models import Restaurant, Menu, Food
from schemas import DishResponse, MenuResponse, MenusResponse
from typing import List
from fastapi.responses import JSONResponse
from jose import JWTError, jwt
import logging
import time

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

SECRET_KEY = "your_secret_key" 
ALGORITHM = "HS256" 

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def verify_token(token: str = Depends(oauth2_scheme)):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id = payload.get("sub")
        if not user_id:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Требуется авторизация",
                headers={"WWW-Authenticate": "Bearer"},
            )
        return user_id
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Неверный токен",
            headers={"WWW-Authenticate": "Bearer"},
        )

def validate_restaurant(db: Session, restaurant_id: int):
    restaurant = db.get(Restaurant, restaurant_id)  # Используем db.get вместо query().get()
    if not restaurant:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Ресторан не найден" 
        )
    return restaurant

@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request, exc):
    logger.error(f"Database error: {exc}")
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={"detail": "Ошибка базы данных"},
    )

@app.get(
    "/menus/{restaurant_id}",
    response_model=MenusResponse,
    summary="Получить меню ресторана",
    description="Возвращает список меню с блюдами для указанного ресторана" 
)
def get_menu_with_dishes(
    restaurant_id: int,
    token: str = Depends(verify_token),
    db: Session = Depends(get_db),
    limit: int = Query(10, ge=1, le=100, description="Лимит элементов на странице"),
    offset: int = Query(0, ge=0, description="Смещение элементов"),
    min_price: float = Query(None, ge=0, description="Минимальная цена блюда"),
    max_price: float = Query(None, ge=0, description="Максимальная цена блюда"),
):
    logger.info(
        f"Fetching menus for restaurant_id={restaurant_id}, limit={limit}, offset={offset}, " 
        f"min_price={min_price}, max_price={max_price}" 
    )
    validate_restaurant(db, restaurant_id)

    # Фильтрация по цене
    filters = []
    if min_price is not None:
        filters.append(Food.price >= min_price)
    if max_price is not None:
        filters.append(Food.price <= max_price)

    # Подсчет total_count
    if filters:
        total_count = (
            db.query(func.count(Menu.id))
            .outerjoin(Food, Menu.foods)  # Учитываем меню без блюд
            .filter(Menu.restaurant_id == restaurant_id, *filters)
            .scalar()
        )
    else:
        total_count = (
            db.query(func.count(Menu.id))
            .filter(Menu.restaurant_id == restaurant_id)
            .scalar()
        )
    logger.info(f"Total count: {total_count}")

    # Упрощенный расчет количества страниц
    total_pages = -(-total_count // limit)  # Эквивалент ceil(total_count / limit)

    # Логирование статистики
    logger.info(
        f"Total count: {total_count}, Total pages: {total_pages}, Current page: {offset // limit + 1}" 
    )

    # Замер времени выполнения запроса
    start_time = time.perf_counter()

    # Получение ID меню через подзапрос
    subq = (
        db.query(Menu.id)
        .outerjoin(Food, Menu.foods)  # Учитываем меню без блюд
        .filter(Menu.restaurant_id == restaurant_id, *filters)
        .order_by(Menu.id)
        .limit(limit)
        .offset(offset)
        .subquery()
    )

    # Получаем меню с блюдами
    menus = (
        db.query(Menu)
        .options(joinedload(Menu.foods))  # Ленивая загрузка связанных блюд
        .filter(Menu.id.in_(db.query(subq.c.id)))
        .all()
    )

    execution_time = time.perf_counter() - start_time
    logger.info(f"Query execution time: {execution_time:.4f} seconds")

    if not menus:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Меню не найдено для данного ресторана" 
        )

    return {
        "total_count": total_count,
        "total_pages": total_pages,
        "current_page": offset // limit + 1,  # Номер текущей страницы
        "execution_time": execution_time,  # Время выполнения запроса
        "menus": [
            {
                "id_menu": menu.id,
                "name": menu.name,
                "dishes": [
                    {
                        "id": dish.id,
                        "name": dish.name,
                        "composition": dish.composition.split(", ") if dish.composition else [],
                        "price": dish.price
                    }
                    for dish in (menu.foods or [])  # Гарантируем, что `menu.foods` всегда список
                ]
            }
            for menu in menus
        ]
    }

Обновлено 8 месяца назад

Вариант реализации формы в PrimeVue:

picture400-1.png

<template>
  <div>
    <ConfirmDialog />

    <div class="p-grid p-fluid">
      <div class="p-col-12">
        <h2>Выбор ресторана и меню</h2>

        <!-- Выбор ресторана -->
        <div class="p-field">
          <label for="restaurant">Выберите ресторан</label>
          <Dropdown
            id="restaurant" 
            v-model="selectedRestaurant" 
            :options="restaurants" 
            option-label="name" 
            placeholder="Выберите ресторан" 
            @change="onRestaurantChange" 
          />
        </div>

        <!-- Выбор меню -->
        <div class="p-field" v-if="menus.length > 0">
          <label for="menu">Выберите меню</label>
          <Dropdown
            id="menu" 
            v-model="selectedMenu" 
            :options="menus" 
            option-label="name" 
            placeholder="Выберите меню" 
            @change="loadDishes" 
          />
        </div>

        <!-- Список блюд -->
        <div v-if="dishes.length > 0">
          <h3>Блюда</h3>
          <DataTable
            :value="dishes" 
            selection-mode="multiple" 
            v-model:selection="selectedDishes" 
            responsive-layout="scroll" 
          >
            <Column selection-mode="multiple" header-style="width: 3rem" />
            <Column field="name" header="Название блюда" />
            <Column field="composition" header="Состав">
              <template #body="slotProps">
                {{ slotProps.data.composition.join(', ') }}
              </template>
            </Column>
            <Column field="price" header="Цена">
              <template #body="slotProps">
                {{ formatCurrency(slotProps.data.price) }}
              </template>
            </Column>
          </DataTable>

          <Button
            label="Добавить в заказ" 
            icon="pi pi-plus" 
            @click="addToOrder" 
            class="p-mt-3" 
            :disabled="selectedDishes.length === 0" 
          />
        </div>

        <!-- Заказ -->
        <div v-if="orderDishes.length > 0" class="p-mt-4">
          <h3>Ваш заказ</h3>
          <DataTable :value="orderDishes" responsive-layout="scroll">
            <Column field="name" header="Название" />
            <Column field="composition" header="Состав">
              <template #body="slotProps">
                {{ slotProps.data.composition.join(', ') }}
              </template>
            </Column>
            <Column field="price" header="Цена">
              <template #body="slotProps">
                {{ formatCurrency(slotProps.data.price) }}
              </template>
            </Column>
            <Column header="Действия">
              <template #body="slotProps">
                <Button
                  icon="pi pi-trash" 
                  class="p-button-danger" 
                  @click="removeFromOrder(slotProps.data)" 
                />
              </template>
            </Column>
          </DataTable>
        </div>

        <Button
          label="Оформить заказ" 
          icon="pi pi-check" 
          @click="placeOrder" 
          class="p-mt-3" 
          :disabled="orderDishes.length === 0" 
        />
      </div>
    </div>
  </div>
</template>

<script>
import ConfirmDialog from 'primevue/confirmdialog';
import Dropdown from 'primevue/dropdown';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Button from 'primevue/button';

export default {
  components: {
    ConfirmDialog,
    Dropdown,
    DataTable,
    Column,
    Button,
  },
  data() {
    return {
      restaurants: [
        {
          id_rest: 1,
          name: 'Вегетарианский рай',
          menus: [
            {
              id_menu: 1,
              name: 'Постное меню',
              dishes: [
                {
                  id: 1,
                  name: 'Салат',
                  composition: ['огурцы', 'помидоры'],
                  price: 250,
                },
                {
                  id: 2,
                  name: 'Суп',
                  composition: ['картофель', 'морковь'],
                  price: 300,
                },
              ],
            },
            {
              id_menu: 2,
              name: 'Веганское меню',
              dishes: [
                {
                  id: 3,
                  name: "Греческий салат",
                  composition: ["сыр фета", "оливки", "помидоры"],
                  price: 350,
                },
                {
                  id: 4,
                  name: "Овощной суп",
                  composition: ["капуста", "лук", "морковь"],
                  price: 400,
                },
              ],
            },
          ],
        },
        {
          id_rest: 2,
          name: 'Мясной клуб',
          menus: [
            {
              id_menu: 3,
              name: 'Гриль меню',
              dishes: [
                {
                  id: 5,
                  name: 'Стейк',
                  composition: ['говядина', 'специи'],
                  price: 600,
                },
                {
                  id: 6,
                  name: 'Курица гриль',
                  composition: ['курица', 'чеснок'],
                  price: 500,
                },
              ],
            },
            {
              id_menu: 4,
              name: 'Десерты',
              dishes: [
                {
                  id: 7,
                  name: "Чизкейк",
                  composition: ["творог", "сахар", "сливки"],
                  price: 200,
                },
                {
                  id: 8,
                  name: "Тирамису",
                  composition: ["кофе", "маскарпоне", "печенье"],
                  price: 250,
                },
              ],
            },
          ],
        },
      ],
      selectedRestaurant: null,
      previousSelectedRestaurant: null,
      menus: [],
      selectedMenu: null,
      dishes: [],
      selectedDishes: [],
      orderDishes: [],
    };
  },
  watch: {
    selectedRestaurant(newVal, oldVal) {
      this.previousSelectedRestaurant = oldVal;
    },
  },
  methods: {
    async onRestaurantChange(event) {
      if (this.orderDishes.length > 0) {
        try {
          await this.$confirm.require({
            message: "При смене ресторана заказ будет очищен. Продолжить?",
            header: "Подтверждение",
            icon: "pi pi-exclamation-triangle",
            acceptLabel: "Да",
            rejectLabel: "Нет",
            acceptClass: "p-button-danger",
            accept: () => {
              // Сбрасываем заказ после подтверждения
              this.clearOrder();
              this.loadMenus(event.value);  // Загружаем меню нового ресторана
            },
            reject: () => {
              // Возвращаем старый ресторан, если отказались от смены
              this.$nextTick(() => {
                this.selectedRestaurant = this.previousSelectedRestaurant;
              });
            }
          });
        } catch (error) {
          console.log("Ошибка подтверждения:", error);
        }
      } else {
        this.loadMenus(event.value); // Просто загружаем меню, если заказ пуст
      }
    },
    loadMenus(restaurant) {
      this.selectedRestaurant = restaurant;
      this.menus = restaurant?.menus || [];
      this.selectedMenu = null;
      this.dishes = [];
    },
    loadDishes() {
      this.dishes = this.selectedMenu?.dishes || [];
      this.selectedDishes = this.orderDishes.filter((dish) =>
        this.dishes.some((d) => d.id === dish.id)
      );
    },
    formatCurrency(value) {
      return new Intl.NumberFormat('ru-RU', {
        style: 'currency',
        currency: 'RUB',
      }).format(value);
    },
    addToOrder() {
      const newDishes = this.selectedDishes.filter(
        (dish) => !this.orderDishes.some((d) => d.id === dish.id)
      );
      this.orderDishes = [...this.orderDishes, ...newDishes];
      this.selectedDishes = [];
    },
    removeFromOrder(dish) {
      this.orderDishes = this.orderDishes.filter((d) => d.id !== dish.id);
    },
    placeOrder() {
      console.log('Оформлен заказ:', {
        restaurant: this.selectedRestaurant.name,
        dishes: this.orderDishes,
      });
      alert('Заказ успешно оформлен!');
      this.clearOrder();
    },
    clearOrder() {
      this.orderDishes = [];
      this.selectedDishes = [];
    },
  },
};
</script>

<style scoped>
.p-field {
  margin-bottom: 1rem;
}

.p-mt-3 {
  margin-top: 1rem;
}

.p-mt-4 {
  margin-top: 1.5rem;
}
</style>

Обновлено 8 месяца назад

Вариант базы данных MySQL 8 CE:

-- Создание базы данных
CREATE DATABASE IF NOT EXISTS food_delivery;

USE food_delivery;

-- Создание таблиц
CREATE TABLE IF NOT EXISTS restaurants (
    id_rest INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    address VARCHAR(255),
    phone VARCHAR(20),
    active BOOLEAN DEFAULT TRUE
);

CREATE TABLE IF NOT EXISTS menus (
    id_menu INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    restaurant_id INT,
    FOREIGN KEY (restaurant_id) REFERENCES restaurants(id_rest)
);

CREATE TABLE IF NOT EXISTS foods (
    id_food INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    composition TEXT,
    price DECIMAL(10, 2) NOT NULL,
    menu_id INT,
    FOREIGN KEY (menu_id) REFERENCES menus(id_menu)
);

Экспортировать в Atom PDF

Go to top
Добавить изображение из буфера обмена (Максимальный размер: 100 МБ)