#import "@local/nure:0.1.0": *

#show: pz-lb.with(
  title: [Продовольчий магазин "Весна"],
  subject: "СМП",
  doctype: "ЛБ",
  worknumber: 2,
  edu_program: "ПЗПІ",
  university: "ХНУРЕ",
  mentors: (
    (
      name: "Сокорчук І. П.",
      degree: "Старший викладач кафедри ПІ",
      gender: "m",
    ),
  ),
  authors: (
    (
      name: "Ситник Є. С.",
      course: 2,
      edu: "ПЗПІ",
      gender: "m",
      group: "23-2",
    ),
  ),
)

#v(-spacing)
== Історія змін
#figure(
  table(
    align: left,
    columns: (auto, 1fr, 1fr, 3fr),
    table.header([№], [Дата], [Версія звіту], [Опис змін та виправлень]),
    [1], [03.03.2025], [0.1], [Створено звіт],
  ),
)

== Мета роботи
Лабораторна робота полягає у розробці консольного застосунку "Продовольчий магазин "Весна"" засобами мови програмування "PHP".

== Хід роботи
#v(-spacing)
=== Архітектура програми

Для реалізації консольного застосунку було обрано об'єктно-орієнтований підхід з використанням наступних компонентів:

==== Класи та їх призначення:

- *DB* -- клас для роботи з базою даних "SQLite", що забезпечує збереження даних про товари, кошик користувача та налаштування профілю;
- *App* -- основний клас застосунку, що реалізує логіку роботи програми та взаємодію з користувачем;
- *DbException* та *AppException* -- класи винятків для обробки помилок.

==== Enum State:
Для управління станами програми використовується перелічення "State" з наступними значеннями:
- *Hello* -- привітальний екран;
- *Menu* -- головне меню;
- *Items* -- вибір товарів;
- *Checkout* -- формування рахунку;
- *Settings* -- налаштування профілю;
- *Exit* -- вихід з програми.

=== Структура бази даних

Програма використовує базу даних SQLite з трьома таблицями:

- *settings* -- зберігає інформацію про користувача (ім'я та вік);
- *items* -- містить каталог товарів з їх назвами та цінами;
- *cart* -- зберігає товари, додані до кошика, з їх кількістю.

=== Основний функціонал

==== Головне меню:
Програма відображає заголовок магазину та пропонує користувачу чотири основні опції:
- вибір товарів для покупки;
- перегляд підсумкового рахунку;
- налаштування профілю користувача;
- вихід з програми.

==== Вибір товарів:
При виборі першого пункту меню користувач потрапляє в режим покупки товарів, де:
- відображається список доступних товарів з цінами;
- можна вибрати товар за номером та вказати кількість;
- товари додаються до кошика;
- при введенні кількості "0" товар видаляється з кошика;
- реалізована валідація введених даних.

==== Підсумковий рахунок:
Другий пункт меню формує детальний рахунок з інформацією про:
- номер, назву та ціну кожного товару;
- кількість товару в кошику;
- загальну вартість кожного товару;
- підсумкову суму до сплати.

==== Налаштування профілю:
Третій пункт дозволяє користувачу вказати своє ім'я та вік з валідацією:
- ім'я повинно містити хоча б одну літеру;
- вік повинен бути в діапазоні від 7 до 150 років.

=== Технічні деталі

Програма написана з дотриманням сучасних стандартів "PHP":
- використання строгої типізації;
- документування методів за допомогою "PHPDoc";
- дотримання принципів "SOLID";
- розділення логіки на окремі методи для кращої читабельності.

== Висновки

Під час даної лабораторної роботи я вивчив основні принципи роботи з "PHP" для написання консольних програм. Зокрема, було освоєно:

- створення об'єктно-орієнтованих консольних застосунків на "PHP";
- роботу з базою даних "SQLite" через "PDO";
- обробку користувацького вводу та валідацію даних;
- використання перелічень ("enum") для управління станами програми;
- реалізацію системи обробки винятків;
- форматування виводу в консолі для створення зручного інтерфейсу.

Програмне забезпечення, реалізоване протягом даної лабораторної роботи, повністю відповідає поставленим вимогам та забезпечує всі необхідні функції для роботи з продовольчим магазином. Застосунок має зручний інтерфейс, надійну систему валідації даних та ефективно працює з базою даних для збереження інформації про товари та користувача.

#set raw(tab-size: 8)
#show raw.where(block: true): code => {
  // set text(11pt, font: "Caladea", top-edge: 1em, bottom-edge: 0em)
  set text(11pt, top-edge: 1em, bottom-edge: 0em)
  set par(leading: 0.17em)

  grid(
    columns: (auto, auto),
    column-gutter: 1em,
    row-gutter: 0.17em,
    align: (right, raw.align),
    ..for line in code.lines {
      (
        text(fill: gray)[#line.number],
        {
          set text(weight: "semibold")
          line.body
        },
      )
    },
  )
}
#show: appendices_style
= Відеозапис
Відеозапис презентації результатів лабораторної роботи: https://youtu.be/CMGeL1OdyvI

*Хронологічний опис відеозапису:*

00:00 -- Вступ та опис завдання

00:19 -- Архітектура програми

00:39 -- Робота з базою даних

01:43 -- Реалізація станів програми

02:17 -- Формування списку товарів

03:13 -- Додавання товарів до кошика

04:15 -- Робота з кошиком

05:10 -- Налаштування профілю користувача

05:38 -- Демонстрація роботи програми

= Програмний код
== Скрипт з реалізацією програми
GitHub репозиторій: https://github.com/NureSytnykYehor/smp-pzpi-23-2-sytnyk-yehor/blob/main/Lab2/smp-pzpi-23-2-sytnyk-yehor-lab2/smp-pzpi-23-2-sytnyk-yehor-lab2-code

#v(spacing)

```
#!/usr/bin/php

<?php

class DbException extends Exception {}
class AppException extends Exception {}

enum State
{
    case Hello;
    case Menu;
    case Items;
    case Checkout;
    case Settins;
    case Exit;
}

class DB
{
    private $pdo;

    /**
     * Initializes database
     *
     * @param string $db_path
     * @throws DbException If there's a database error.
     */
    public function __construct($db_path)
    {
        try {
            $this->pdo = new PDO("sqlite:" . $db_path);
            $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        } catch (PDOException $e) {
            throw new DbException("Connection to DB failed.\nCaused by: " . $e->getMessage());
        }

        try {
            $this->pdo->exec("
                CREATE TABLE IF NOT EXISTS settings (
                    name TEXT,
                    age TEXT
                );
            ");

            if ($this->pdo->query("SELECT COUNT(*) FROM settings;")->fetchColumn() == 0) {
                $this->pdo->exec("INSERT INTO settings (name, age) VALUES ('user', 0);");
            }
        } catch (PDOException $e) {
            throw new DbException("Error initialising settings table.\nCaused by: " . $e->getMessage());
        }

        try {
            $this->pdo->exec("
                CREATE TABLE IF NOT EXISTS items (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    name TEXT NOT NULL,
                    price REAL NOT NULL
                );
            ");

            if ($this->pdo->query("SELECT COUNT(id) FROM items;")->fetchColumn() == 0) {
                $this->pdo->exec("
                    INSERT INTO items (name, price) VALUES ('Молоко пастеризоване', 12);
                    INSERT INTO items (name, price) VALUES ('Хліб чорний', 9);
                    INSERT INTO items (name, price) VALUES ('Сир білий', 21);
                    INSERT INTO items (name, price) VALUES ('Сметана 20%', 25);
                    INSERT INTO items (name, price) VALUES ('Кефір 1%', 19);
                    INSERT INTO items (name, price) VALUES ('Вода газована', 18);
                    INSERT INTO items (name, price) VALUES ('Печиво \"Весна\"', 14);
                ");
            }
        } catch (PDOException $e) {
            throw new DbException("Error initialising items table.\nCaused by: " . $e->getMessage());
        }

        try {
            $this->pdo->exec("
                CREATE TABLE IF NOT EXISTS cart (
                    id INTEGER NOT NULL UNIQUE,
                    count INTEGER NOT NULL,
                    FOREIGN KEY(id) REFERENCES item(id)
                );
            ");
        } catch (PDOException $e) {
            throw new DbException("Error initialising cart table.\nCaused by: " . $e->getMessage());
        }
    }

    /**
     * Updates user information in the database
     *
     * @param string $name
     * @param int $age
     * @return void
     * @throws DbException If there's a database error.
     */
    public function update_user($name, $age): void
    {
        try {
            $stmt = $this->pdo->prepare("UPDATE settings SET name = :name, age = :age;");

            $stmt->bindParam(':name', $name, PDO::PARAM_STR);
            $stmt->bindParam(':age', $age, PDO::PARAM_INT);

            $stmt->execute();
        } catch (PDOException $e) {
            throw new DbException("Error updating user info.\nCaused by: " . $e->getMessage());
        }
    }

    /**
     * Fetches all items from the database.
     *
     * @return array[]
     * @throws DbException If there's a database error.
     */
    public function get_items(): array
    {
        try {
            $stmt = $this->pdo->query("SELECT id, name, price FROM items ORDER BY id;");
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            throw new DbException("Error retrieving data from the items table.\nCaused by: " . $e->getMessage());
        }
    }

    /**
     * Fetches all items in the cart from the database without price info.
     *
     * @return array[]
     * @throws DbException If there's a database error.
     */
    public function get_cart_no_price(): array
    {
        try {
            $stmt = $this->pdo->query(
                "SELECT name, count FROM cart
                INNER JOIN items ON cart.id = items.id
                ORDER BY cart.id;"
            );
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            throw new DbException("Error inserting init data in the items table.\nCaused by: " . $e->getMessage());
        }
    }

    /**
     * Fetches all items in the cart from the database.
     *
     * @return array[]
     * @throws DbException If there's a database error.
     */
    public function get_cart(): array
    {
        try {
            $stmt = $this->pdo->query(
                "SELECT
                    cart.id, name, price, count, price*count as total_price
                FROM cart
                INNER JOIN items ON cart.id = items.id
                ORDER BY cart.id;"
            );
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            throw new DbException("Error inserting init data in the items table.\nCaused by: " . $e->getMessage());
        }
    }

    /**
     * Add item to the cart
     *
     * @param int $id
     * @param int $count
     *
     * @return bool
     * @throws DbException If there's a database error.
     */
    public function add_to_cart($id, $count): bool
    {
        try {
            $stmt = $this->pdo->prepare(
                "INSERT INTO cart
                    (id, count)
                VALUES
                    (:id, :count)
                ON CONFLICT(id)
                    DO UPDATE SET
                        count = :count
                WHERE id = :id;"
            );
            return $stmt->execute(['id' => $id, 'count' => $count]);
        } catch (PDOException $e) {
            throw new DbException("Error adding item to the cart.\nCaused by: " . $e->getMessage());
        }
    }

    /**
     * Remove an item from the cart
     *
     * @param int $id
     *
     * @return bool
     * @throws DbException If there's a database error.
     */
    public function remove_from_cart($id): bool
    {
        try {
            $stmt = $this->pdo->prepare("DELETE FROM cart WHERE id = :id");
            return $stmt->execute(['id' => $id]);
        } catch (PDOException $e) {
            throw new DbException("Error removimg item from the cart.\nCaused by: " . $e->getMessage());
        }
    }
}

class App
{
    private $db;
    private $state;

    private $menu_ops = <<<'END'
    1 Вибрати товари
    2 Отримати підсумковий рахунок
    3 Налаштувати свій профіль
    0 Вийти з програми
    END;
    private $hello = <<<'END'
    ################################
    # ПРОДОВОЛЬЧИЙ МАГАЗИН "ВЕСНА" #
    ################################
    END;


    /**
     * @param string $db_path
     */
    public function __construct($db_path)
    {
        try {
            $this->db = new DB($db_path);
            $this->state = State::Hello;
        } catch (DbException $e) {
            throw new AppException("Error initializing app.\nCaused by: " . $e);
        }
    }

    public function poll(): void
    {
        while ($this->state != State::Exit) {
            switch ($this->state) {
                case State::Hello:
                    $this->hello();
                    break;
                case State::Menu:
                    $this->menu();
                    break;
                case State::Items:
                    $this->items();
                    break;
                case State::Checkout:
                    $this->checkout();
                    break;
                case State::Settins:
                    $this->settings();
                    break;

                default:
                    break;
            }
        }
    }

    private function menu(): void
    {
        echo "\n";
        echo "$this->menu_ops\n";

        $op = readline('Введіть команду: ');
        switch ($op) {
            case '1':
                $this->state = State::Items;
                break;
            case '2':
                $this->state = State::Checkout;
                break;
            case '3':
                $this->state = State::Settins;
                break;
            case '0':
                $this->state = State::Exit;
                break;

            default:
                echo "ПОМИЛКА! Введіть правильну команду\n";
                break;
        }

        echo "\n";
    }
    private function hello(): void
    {
        echo "$this->hello\n";
        $this->state = State::Menu;
    }
    private function items(): void
    {
        $items = $this->db->get_items();
        array_unshift($items, ['id' => "№", 'name' => "НАЗВА", 'price' => "ЦІНА"]);
        array_push($items, ['id' => " ", 'name' => "-----------", 'price' => ""]);
        array_push($items, ['id' => "0", 'name' => "ПОВЕРНУТИСЯ", 'price' => ""]);
        $columns = $this->count_columns($items);

        while (true) {
            $this->print_lits($items, $columns);

            $id = readline("Виберіть товар: ");

            if ($id == '0') {
                break;
            }

            echo "\n";

            $selected = null;
            foreach ($items as $item) {
                if ($item['id'] === (int)$id)
                    $selected = $item;
            }

            if ($selected == null) {
                echo "ПОМИЛКА! ВКАЗАНО НЕПРАВИЛЬНИЙ НОМЕР ТОВАРУ\n";
                continue;
            }

            echo "Вибрано: {$selected['name']}\n";

            $count = (int)readline("Введіть кількість, штук: ");

            if ($count > 100) {
                echo "ПОМИЛКА! Не можна додати більше 100 одиниць товару в кошик\n";
                continue;
            }

            if ($count < 0) {
                echo "ПОМИЛКА! Кількість не може бути від'ємною\n";
                continue;
            }

            if ($count == 0) {
                echo "ВИДАЛЯЮ З КОШИКА\n";
                $this->db->remove_from_cart($id);
            } else {
                $this->db->add_to_cart($id, $count);
            }

            $cart = $this->db->get_cart_no_price();
            if (count($cart) == 0) {
                echo "КОШИК ПОРОЖНІЙ\n";
            } else {
                echo "\nУ КОШИКУ:\n";
                array_unshift($cart, ['name' => "НАЗВА", 'count' => "КІЛЬКІСТЬ"]);
                $cart_columns = $this->count_columns($cart);
                $this->print_lits($cart, $cart_columns);
                echo "\n";
            }
        }

        $this->state = State::Menu;
    }
    private function checkout(): void
    {
        $cart = $this->db->get_cart();
        if (count($cart) == 0) {
            echo "КОШИК ПОРОЖНІЙ\n";
            $this->state = State::Menu;
            return;
        } else {
            echo "У КОШИКУ:\n";
            array_unshift($cart, ['id' => "№", 'name' => "НАЗВА", 'price' => "ЦІНА", 'count' => "КІЛЬКІСТЬ", 'total_price' => "ВАРТІСТЬ"]);
            $cart_columns = $this->count_columns($cart);
            $this->print_lits($cart, $cart_columns);
        }

        $total_price = array_reduce($cart, function ($carry, $item) {
            return $carry + (int)$item['total_price'];
        }, 0);
        echo "РАЗОМ ДО СПЛАТИ: {$total_price}\n";

        $this->state = State::Menu;
    }
    private function settings(): void
    {
        while (true) {
            $name = readline("Ваше ім'я: ");
            if ($name !== "" && preg_match("/[a-zA-Z]+/", $name))
                break;
        }

        while (true) {
            $age = readline("Ваш вік: ");

            if (!filter_var($age, FILTER_VALIDATE_INT)) {
                echo "ПОМИЛКА! Вік користувача потрібно вказати числом\n\n";
                continue;
            }

            if ($age < 7 || $age > 150) {
                echo "ПОМИЛКА! Користувач повинен мати вік від 7 та до 150 років\n\n";
                continue;
            }

            break;
        }

        echo "\n";

        $this->db->update_user($name, $age);

        $this->state = State::Menu;
    }

    /**
     * @param array<array> $items
     * @return array<int>
     */
    private function count_columns($items): array
    {
        $columns = [];
        foreach ($items as $item) {
            foreach ($item as $field => $value) {
                if (!key_exists($field, $columns))
                    $columns[$field] = mb_strlen($value);
                else
                    $columns[$field] = max(mb_strlen($value), $columns[$field]);
            }
        }

        return $columns;
    }
    /**
     * @param array $element
     * @param array<int> $columns
     */
    private function pad_row($element, $columns): string
    {
        $result = [];
        foreach ($element as $field => $value)
            $result[] = mb_str_pad($value, $columns[$field], ' ', STR_PAD_RIGHT);

        return implode("  ", $result);
    }
    /**
     * @param array<array> $items
     * @param array<int> $columns
     */
    private function print_lits($items, $columns): void
    {
        foreach ($items as $item)
            echo $this->pad_row($item, $columns) . "\n";
    }
}

try {
    $app = new App("db.sqlite");
} catch (AppException $e) {
    echo $e;
    exit(1);
}

$app->poll();
```
