diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/database/__init__.py differ diff --git a/database/db.py b/database/db.py new file mode 100644 index 0000000..13f29ab --- /dev/null +++ b/database/db.py @@ -0,0 +1,527 @@ +import pg8000 +import os +from typing import Optional, Dict, List, Any +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger(__name__) + + +class Database: + def __init__(self): + self.conn = None + self.cursor = None + + async def connect(self): + try: + self.conn = pg8000.connect( + host=os.getenv('DB_HOST', 'localhost'), + port=int(os.getenv('DB_PORT', '5432')), + database=os.getenv('DB_NAME', 'max_bot'), + user=os.getenv('DB_USER', 'max_user'), + password=os.getenv('DB_PASSWORD', 'max_password') + ) + self.cursor = self.conn.cursor() + logger.info("✅ Подключение к PostgreSQL установлено (pg8000)") + await self.init_tables() + await self.init_schools() + except Exception as e: + logger.error(f"❌ Ошибка подключения к БД: {e}") + raise + + async def disconnect(self): + if self.cursor: + self.cursor.close() + if self.conn: + self.conn.close() + logger.info("Соединение с PostgreSQL закрыто") + + async def init_tables(self): + try: + sql_path = os.path.join(os.path.dirname(__file__), 'init.sql') + with open(sql_path, 'r') as f: + sql = f.read() + self.cursor.execute(sql) + self.conn.commit() + logger.info("✅ Таблицы созданы/проверены") + except Exception as e: + logger.error(f"❌ Ошибка инициализации таблиц: {e}") + raise + + async def init_schools(self): + try: + self.cursor.execute("SELECT COUNT(*) FROM schools") + count = self.cursor.fetchone()[0] + if count == 0: + schools = [ + ('101', 'Средняя общеобразовательная школа №101', 'ул. Ленина, 15', 'Москва'), + ('102', 'Средняя общеобразовательная школа №102', 'ул. Гагарина, 10', 'Москва'), + ('103', 'Средняя общеобразовательная школа №103', 'ул. Мира, 25', 'Москва'), + ('104', 'Гимназия №104', 'ул. Садовая, 8', 'Москва'), + ('105', 'Лицей №105', 'ул. Тверская, 12', 'Москва'), + ('201', 'Гимназия №201', 'ул. Пушкина, 5', 'Санкт-Петербург'), + ('202', 'Средняя школа №202', 'ул. Невский проспект, 20', 'Санкт-Петербург'), + ('203', 'Средняя школа №203', 'ул. Московская, 15', 'Санкт-Петербург'), + ('301', 'Лицей №301', 'ул. Советская, 8', 'Казань'), + ('302', 'Средняя школа №302', 'ул. Ленина, 42', 'Казань'), + ('401', 'Средняя школа №401', 'ул. Мира, 12', 'Новосибирск'), + ('402', 'Гимназия №402', 'ул. Кирова, 7', 'Новосибирск'), + ('501', 'Школа №501 с углубленным изучением математики', 'ул. Лермонтова, 3', 'Екатеринбург'), + ('502', 'Гимназия №502', 'ул. Свердлова, 10', 'Екатеринбург'), + ('601', 'Средняя общеобразовательная школа №601', 'ул. Садовая, 25', 'Нижний Новгород'), + ('602', 'Лицей №602', 'ул. Горького, 8', 'Нижний Новгород'), + ('701', 'Гимназия №701', 'ул. Чехова, 10', 'Ростов-на-Дону'), + ('702', 'Средняя школа №702', 'ул. Пушкинская, 15', 'Ростов-на-Дону'), + ] + for school in schools: + self.cursor.execute(""" + INSERT INTO schools (school_number, school_name, address, city) + VALUES (%s, %s, %s, %s) + """, school) + self.conn.commit() + logger.info(f"✅ Добавлено {len(schools)} школ в справочник") + except Exception as e: + logger.error(f"❌ Ошибка инициализации школ: {e}") + + async def get_parent(self, user_id: int) -> Optional[Dict]: + self.cursor.execute("SELECT * FROM parents WHERE user_id = %s", (user_id,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def create_parent(self, user_id: int, username: str) -> Dict: + self.cursor.execute(""" + INSERT INTO parents (user_id, username) + VALUES (%s, %s) + ON CONFLICT (user_id) DO UPDATE + SET username = %s, updated_at = CURRENT_TIMESTAMP + RETURNING * + """, (user_id, username, username)) + self.conn.commit() + row = self.cursor.fetchone() + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + + async def get_all_schools(self) -> List[Dict]: + self.cursor.execute("SELECT * FROM schools ORDER BY school_number") + rows = self.cursor.fetchall() + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + + async def get_school_by_number(self, school_number: str) -> Optional[Dict]: + self.cursor.execute("SELECT * FROM schools WHERE school_number = %s", (school_number,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def get_school_by_id(self, school_id: int) -> Optional[Dict]: + self.cursor.execute("SELECT * FROM schools WHERE id = %s", (school_id,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def search_schools(self, query: str) -> List[Dict]: + self.cursor.execute(""" + SELECT * FROM schools + WHERE school_number ILIKE %s OR school_name ILIKE %s + ORDER BY school_number + """, (f'%{query}%', f'%{query}%')) + rows = self.cursor.fetchall() + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + + async def get_children(self, parent_id: int) -> List[Dict]: + self.cursor.execute(""" + SELECT + c.id, c.parent_id, c.card_number, c.student_name, c.school_id, + s.school_number, s.school_name, s.address, s.city, + c.created_at, c.updated_at + FROM children c + JOIN schools s ON c.school_id = s.id + WHERE c.parent_id = %s + ORDER BY c.id + """, (parent_id,)) + rows = self.cursor.fetchall() + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + + async def get_child_by_card_number(self, card_number: str) -> Optional[Dict]: + self.cursor.execute(""" + SELECT + c.id, c.parent_id, c.card_number, c.student_name, c.school_id, + s.school_number, s.school_name + FROM children c + JOIN schools s ON c.school_id = s.id + WHERE c.card_number = %s + """, (card_number,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def add_child(self, parent_id: int, card_number: str, student_name: str, school_number: str) -> Dict: + parent = await self.get_parent(parent_id) + if not parent: + raise ValueError("Родитель не найден. Сначала отправьте /start") + school = await self.get_school_by_number(school_number) + if not school: + raise ValueError(f"Школа с номером '{school_number}' не найдена в справочнике") + self.cursor.execute(""" + INSERT INTO children (parent_id, card_number, student_name, school_id) + VALUES (%s, %s, %s, %s) + ON CONFLICT (parent_id, card_number) DO UPDATE + SET student_name = %s, school_id = %s, updated_at = CURRENT_TIMESTAMP + RETURNING * + """, (parent_id, card_number, student_name, school['id'], student_name, school['id'])) + self.conn.commit() + row = self.cursor.fetchone() + columns = [desc[0] for desc in self.cursor.description] + child = dict(zip(columns, row)) + child['school_number'] = school['school_number'] + child['school_name'] = school['school_name'] + return child + + async def delete_child(self, parent_id: int, child_id: int) -> bool: + self.cursor.execute("DELETE FROM children WHERE parent_id = %s AND id = %s", (parent_id, child_id)) + self.conn.commit() + return self.cursor.rowcount > 0 + + async def get_access_event_by_id(self, event_id: str) -> Optional[Dict]: + self.cursor.execute("SELECT * FROM access_events WHERE event_id = %s", (event_id,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def save_access_event(self, event_id: str, user_id: str, resource_number: int, + event_time: datetime, device_id: str = None, + access_zone_id1: str = None, access_zone_id2: str = None) -> Dict: + self.cursor.execute(""" + INSERT INTO access_events + (event_id, user_id, device_id, resource_number, access_zone_id1, access_zone_id2, event_time) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (event_id) DO NOTHING + RETURNING * + """, (event_id, user_id, device_id, resource_number, access_zone_id1, access_zone_id2, event_time)) + self.conn.commit() + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + existing = await self.get_access_event_by_id(event_id) + return existing if existing else {} + + async def mark_event_processed(self, event_id: int) -> bool: + self.cursor.execute("UPDATE access_events SET processed = TRUE WHERE id = %s", (event_id,)) + self.conn.commit() + return self.cursor.rowcount > 0 + + async def save_notification(self, parent_id: int, child_id: int, message: str, event_type: str) -> Dict: + self.cursor.execute(""" + INSERT INTO notifications (parent_id, child_id, message, event_type) + VALUES (%s, %s, %s, %s) + RETURNING * + """, (parent_id, child_id, message, event_type)) + self.conn.commit() + row = self.cursor.fetchone() + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + + async def get_unread_notifications(self, parent_id: int) -> List[Dict]: + self.cursor.execute(""" + SELECT * FROM notifications + WHERE parent_id = %s AND is_read = FALSE + ORDER BY created_at DESC + """, (parent_id,)) + rows = self.cursor.fetchall() + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + + async def mark_notification_as_read(self, notification_id: int) -> bool: + self.cursor.execute("UPDATE notifications SET is_read = TRUE WHERE id = %s", (notification_id,)) + self.conn.commit() + return self.cursor.rowcount > 0 + + # --- ПОДПИСОЧНАЯ СИСТЕМА --- + async def get_subscription(self, parent_id: int) -> Optional[Dict]: + self.cursor.execute("SELECT * FROM subscriptions WHERE parent_id = %s", (parent_id,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def create_subscription(self, parent_id: int) -> Dict: + trial_end = datetime.now() + timedelta(days=7) + self.cursor.execute(""" + INSERT INTO subscriptions (parent_id, status, trial_start, trial_end) + VALUES (%s, %s, %s, %s) + ON CONFLICT (parent_id) DO UPDATE + SET status = 'trial', trial_start = CURRENT_TIMESTAMP, trial_end = %s, updated_at = CURRENT_TIMESTAMP + RETURNING * + """, (parent_id, 'trial', datetime.now(), trial_end, trial_end)) + self.conn.commit() + row = self.cursor.fetchone() + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + + async def check_subscription_status(self, parent_id: int) -> Dict: + subscription = await self.get_subscription(parent_id) + if not subscription: + subscription = await self.create_subscription(parent_id) + now = datetime.now() + status = subscription.get('status') + if status == 'trial': + trial_end = subscription.get('trial_end') + if trial_end and trial_end < now: + await self.update_subscription_status(parent_id, 'expired') + subscription['status'] = 'expired' + elif status == 'active': + paid_until = subscription.get('paid_until') + if paid_until and paid_until < now: + await self.update_subscription_status(parent_id, 'expired') + subscription['status'] = 'expired' + return subscription + + async def update_subscription_status(self, parent_id: int, status: str) -> bool: + self.cursor.execute(""" + UPDATE subscriptions + SET status = %s, updated_at = CURRENT_TIMESTAMP + WHERE parent_id = %s + """, (status, parent_id)) + self.conn.commit() + return self.cursor.rowcount > 0 + + async def extend_subscription(self, parent_id: int, months: int = 1) -> Dict: + try: + subscription = await self.get_subscription(parent_id) + if not subscription: + subscription = await self.create_subscription(parent_id) + current_end = subscription.get('paid_until') + if current_end and current_end > datetime.now(): + paid_until = current_end + timedelta(days=30 * months) + else: + paid_until = datetime.now() + timedelta(days=30 * months) + self.cursor.execute(""" + UPDATE subscriptions + SET status = 'active', paid_until = %s, last_payment_date = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE parent_id = %s + RETURNING * + """, (paid_until, parent_id)) + self.conn.commit() + row = self.cursor.fetchone() + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + except Exception as e: + self.conn.rollback() + logger.error(f"❌ Ошибка продления подписки: {e}") + raise + + # --- ПЛАТЕЖИ --- + async def record_payment(self, parent_id: int, amount: float, transaction_id: str, status: str = 'pending') -> Dict: + """Запись платежа с проверкой на дубликат""" + try: + # Сначала проверяем, существует ли платеж + existing = await self.get_payment_by_transaction_id(transaction_id) + if existing: + if existing.get('status') != status: + await self.update_payment_status(transaction_id, status) + return await self.get_payment_by_transaction_id(transaction_id) + return existing + + self.cursor.execute(""" + INSERT INTO payments (parent_id, amount, transaction_id, status) + VALUES (%s, %s, %s, %s) + RETURNING * + """, (parent_id, amount, transaction_id, status)) + self.conn.commit() + row = self.cursor.fetchone() + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + except Exception as e: + # Если ошибка уникальности, пытаемся получить существующий платеж + if 'duplicate key' in str(e).lower(): + self.conn.rollback() + existing = await self.get_payment_by_transaction_id(transaction_id) + if existing: + return existing + self.conn.rollback() + logger.error(f"❌ Ошибка записи платежа: {e}") + raise + + async def get_payment_by_transaction_id(self, transaction_id: str) -> Optional[Dict]: + self.cursor.execute("SELECT * FROM payments WHERE transaction_id = %s", (transaction_id,)) + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return None + + async def update_payment_status(self, transaction_id: str, status: str) -> bool: + try: + self.cursor.execute(""" + UPDATE payments + SET status = %s + WHERE transaction_id = %s + """, (status, transaction_id)) + self.conn.commit() + return self.cursor.rowcount > 0 + except Exception as e: + self.conn.rollback() + logger.error(f"❌ Ошибка обновления статуса платежа: {e}") + return False + + async def get_expiring_subscriptions(self, days_threshold: int = 3) -> List[Dict]: + """ + Получение подписок, у которых до окончания осталось days_threshold дней или меньше. + """ + now = datetime.now() + threshold_date = now + timedelta(days=days_threshold) + + self.cursor.execute(""" + SELECT + s.*, + p.username, + p.user_id + FROM subscriptions s + JOIN parents p ON s.parent_id = p.user_id + WHERE s.status IN ('trial', 'active') + AND s.trial_end <= %s + AND s.trial_end > %s + UNION + SELECT + s.*, + p.username, + p.user_id + FROM subscriptions s + JOIN parents p ON s.parent_id = p.user_id + WHERE s.status = 'active' + AND s.paid_until <= %s + AND s.paid_until > %s + """, (threshold_date, now, threshold_date, now)) + + rows = self.cursor.fetchall() + if rows: + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + return [] + + async def get_subscription_notification_sent(self, parent_id: int, days_left: int) -> bool: + """Проверка, отправлялось ли уже уведомление об окончании подписки для данного количества дней""" + self.cursor.execute(""" + SELECT 1 FROM subscription_notifications + WHERE parent_id = %s AND days_left = %s + """, (parent_id, days_left)) + return self.cursor.fetchone() is not None + + async def get_expiring_subscriptions(self, days_threshold: int = 3) -> List[Dict]: + """ + Получение подписок, у которых до окончания осталось days_threshold дней или меньше. + """ + now = datetime.now() + threshold_date = now + timedelta(days=days_threshold) + + self.cursor.execute(""" + SELECT + s.*, + p.username, + p.user_id + FROM subscriptions s + JOIN parents p ON s.parent_id = p.user_id + WHERE s.status IN ('trial', 'active') + AND s.trial_end <= %s + AND s.trial_end > %s + UNION + SELECT + s.*, + p.username, + p.user_id + FROM subscriptions s + JOIN parents p ON s.parent_id = p.user_id + WHERE s.status = 'active' + AND s.paid_until <= %s + AND s.paid_until > %s + """, (threshold_date, now, threshold_date, now)) + + rows = self.cursor.fetchall() + if rows: + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + return [] + + async def get_subscription_notification_sent(self, parent_id: int, days_left: int) -> bool: + """Проверка, отправлялось ли уже уведомление об окончании подписки для данного количества дней""" + self.cursor.execute(""" + SELECT 1 FROM subscription_notifications + WHERE parent_id = %s AND days_left = %s + """, (parent_id, days_left)) + return self.cursor.fetchone() is not None + + async def mark_subscription_notification_sent(self, parent_id: int, days_left: int) -> Dict: + """Отметить, что уведомление об окончании подписки отправлено""" + self.cursor.execute(""" + INSERT INTO subscription_notifications (parent_id, days_left) + VALUES (%s, %s) + ON CONFLICT (parent_id, days_left) DO NOTHING + RETURNING * + """, (parent_id, days_left)) + self.conn.commit() + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return {} + + async def clean_old_notifications(self, days: int = 30): + """Очистка старых записей уведомлений (старше days дней)""" + self.cursor.execute(""" + DELETE FROM subscription_notifications + WHERE notified_at < CURRENT_DATE - INTERVAL '%s days' + """, (days,)) + self.conn.commit() + return self.cursor.rowcount + + async def mark_subscription_notification_sent(self, parent_id: int, days_left: int) -> Dict: + """Отметить, что уведомление об окончании подписки отправлено""" + self.cursor.execute(""" + INSERT INTO subscription_notifications (parent_id, days_left) + VALUES (%s, %s) + ON CONFLICT (parent_id, days_left) DO NOTHING + RETURNING * + """, (parent_id, days_left)) + self.conn.commit() + row = self.cursor.fetchone() + if row: + columns = [desc[0] for desc in self.cursor.description] + return dict(zip(columns, row)) + return {} + + async def clean_old_notifications(self, days: int = 30): + """Очистка старых записей уведомлений (старше days дней)""" + self.cursor.execute(""" + DELETE FROM subscription_notifications + WHERE notified_at < CURRENT_DATE - INTERVAL '%s days' + """, (days,)) + self.conn.commit() + return self.cursor.rowcount + async def get_payment_history(self, parent_id: int, limit: int = 10) -> List[Dict]: + self.cursor.execute(""" + SELECT * FROM payments + WHERE parent_id = %s + ORDER BY payment_date DESC + LIMIT %s + """, (parent_id, limit)) + rows = self.cursor.fetchall() + columns = [desc[0] for desc in self.cursor.description] + return [dict(zip(columns, row)) for row in rows] + + +db = Database() \ No newline at end of file diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..a044021 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,213 @@ +-- Создаем таблицу для отслеживания отправленных уведомлений об окончании подписки +CREATE TABLE IF NOT EXISTS subscription_notifications ( + id SERIAL PRIMARY KEY, + parent_id BIGINT NOT NULL REFERENCES parents(user_id) ON DELETE CASCADE, + notified_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + days_left INTEGER NOT NULL, + UNIQUE(parent_id, days_left) -- Чтобы не дублировать уведомления для одного количества дней +); +-- Создаем таблицу справочника школ +CREATE TABLE IF NOT EXISTS schools ( + id SERIAL PRIMARY KEY, + school_number VARCHAR(50) NOT NULL UNIQUE, + school_name VARCHAR(255) NOT NULL, + address TEXT, + city VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Создаем таблицу родителей (пользователей) +CREATE TABLE IF NOT EXISTS parents ( + user_id BIGINT PRIMARY KEY, + username VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Создаем таблицу детей +CREATE TABLE IF NOT EXISTS children ( + id SERIAL PRIMARY KEY, + parent_id BIGINT NOT NULL REFERENCES parents(user_id) ON DELETE CASCADE, + card_number VARCHAR(16) NOT NULL, + student_name VARCHAR(255) NOT NULL, + school_id INTEGER NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(parent_id, card_number) +); + +-- Индексы для детей +CREATE INDEX IF NOT EXISTS idx_children_parent_id ON children(parent_id); +CREATE INDEX IF NOT EXISTS idx_children_card_number ON children(card_number); +CREATE INDEX IF NOT EXISTS idx_children_school_id ON children(school_id); +CREATE INDEX IF NOT EXISTS idx_subscription_notifications_parent_id ON subscription_notifications(parent_id); + +-- Создаем таблицу для истории проходов через турникет +CREATE TABLE IF NOT EXISTS access_events ( + id SERIAL PRIMARY KEY, + event_id VARCHAR(100) UNIQUE, + user_id VARCHAR(100) NOT NULL, + device_id VARCHAR(100), + resource_number INTEGER NOT NULL, + access_zone_id1 VARCHAR(100), + access_zone_id2 VARCHAR(100), + school_id INTEGER REFERENCES schools(id) ON DELETE CASCADE, + event_time TIMESTAMP NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для access_events +CREATE INDEX IF NOT EXISTS idx_access_events_user_id ON access_events(user_id); +CREATE INDEX IF NOT EXISTS idx_access_events_time ON access_events(event_time); +CREATE INDEX IF NOT EXISTS idx_access_events_processed ON access_events(processed); + +-- Создаем таблицу уведомлений +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + parent_id BIGINT NOT NULL REFERENCES parents(user_id) ON DELETE CASCADE, + child_id INTEGER REFERENCES children(id) ON DELETE CASCADE, + message TEXT NOT NULL, + event_type VARCHAR(50), + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для уведомлений +CREATE INDEX IF NOT EXISTS idx_notifications_parent_id ON notifications(parent_id); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); + +-- ============================================ +-- ПОДПИСОЧНАЯ СИСТЕМА +-- ============================================ + +-- Создаем таблицу подписок +CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + parent_id BIGINT NOT NULL UNIQUE REFERENCES parents(user_id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'trial', -- 'trial', 'active', 'expired', 'cancelled' + trial_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + trial_end TIMESTAMP, + paid_until TIMESTAMP, + last_payment_date TIMESTAMP, + payment_amount DECIMAL(10, 2) DEFAULT 50.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для подписок +CREATE INDEX IF NOT EXISTS idx_subscriptions_parent_id ON subscriptions(parent_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); + +-- Создаем таблицу истории платежей +CREATE TABLE IF NOT EXISTS payments ( + id SERIAL PRIMARY KEY, + parent_id BIGINT NOT NULL REFERENCES parents(user_id) ON DELETE CASCADE, + amount DECIMAL(10, 2) NOT NULL, + payment_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + payment_method VARCHAR(50), + transaction_id VARCHAR(255) UNIQUE, + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'success', 'failed' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для платежей +CREATE INDEX IF NOT EXISTS idx_payments_parent_id ON payments(parent_id); +CREATE INDEX IF NOT EXISTS idx_payments_transaction_id ON payments(transaction_id); +CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); + +-- ============================================ +-- ФУНКЦИИ И ТРИГГЕРЫ +-- ============================================ + +-- Функция для автоматического обновления updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Триггер для обновления updated_at в parents +DROP TRIGGER IF EXISTS update_parents_updated_at ON parents; +CREATE TRIGGER update_parents_updated_at + BEFORE UPDATE ON parents + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Триггер для обновления updated_at в children +DROP TRIGGER IF EXISTS update_children_updated_at ON children; +CREATE TRIGGER update_children_updated_at + BEFORE UPDATE ON children + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Триггер для обновления updated_at в subscriptions +DROP TRIGGER IF EXISTS update_subscriptions_updated_at ON subscriptions; +CREATE TRIGGER update_subscriptions_updated_at + BEFORE UPDATE ON subscriptions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- НАЧАЛЬНЫЕ ДАННЫЕ +-- ============================================ + +-- Добавляем школы +INSERT INTO schools (school_number, school_name, address, city) VALUES +('101', 'Средняя общеобразовательная школа №101', 'ул. Ленина, 15', 'Москва'), +('102', 'Средняя общеобразовательная школа №102', 'ул. Гагарина, 10', 'Москва'), +('103', 'Средняя общеобразовательная школа №103', 'ул. Мира, 25', 'Москва'), +('104', 'Гимназия №104', 'ул. Садовая, 8', 'Москва'), +('105', 'Лицей №105', 'ул. Тверская, 12', 'Москва'), +('201', 'Гимназия №201', 'ул. Пушкина, 5', 'Санкт-Петербург'), +('202', 'Средняя школа №202', 'ул. Невский проспект, 20', 'Санкт-Петербург'), +('203', 'Средняя школа №203', 'ул. Московская, 15', 'Санкт-Петербург'), +('301', 'Лицей №301', 'ул. Советская, 8', 'Казань'), +('302', 'Средняя школа №302', 'ул. Ленина, 42', 'Казань'), +('401', 'Средняя школа №401', 'ул. Мира, 12', 'Новосибирск'), +('402', 'Гимназия №402', 'ул. Кирова, 7', 'Новосибирск'), +('501', 'Школа №501 с углубленным изучением математики', 'ул. Лермонтова, 3', 'Екатеринбург'), +('502', 'Гимназия №502', 'ул. Свердлова, 10', 'Екатеринбург'), +('601', 'Средняя общеобразовательная школа №601', 'ул. Садовая, 25', 'Нижний Новгород'), +('602', 'Лицей №602', 'ул. Горького, 8', 'Нижний Новгород'), +('701', 'Гимназия №701', 'ул. Чехова, 10', 'Ростов-на-Дону'), +('702', 'Средняя школа №702', 'ул. Пушкинская, 15', 'Ростов-на-Дону') +ON CONFLICT (school_number) DO NOTHING; + +-- Добавляем тестового пользователя (для разработки) +-- INSERT INTO parents (user_id, username) VALUES (15649081, 'Алексей Халецкий') +-- ON CONFLICT (user_id) DO NOTHING; + +-- Добавляем тестовую подписку для разработчика (активная на год) +-- INSERT INTO subscriptions (parent_id, status, paid_until, payment_amount) +-- VALUES (15649081, 'active', CURRENT_TIMESTAMP + INTERVAL '365 days', 50.00) +-- ON CONFLICT (parent_id) DO NOTHING; + +-- ============================================ +-- ПРОВЕРОЧНЫЕ ЗАПРОСЫ +-- ============================================ + +-- Проверка количества школ +-- SELECT COUNT(*) as total_schools FROM schools; + +-- Проверка подписок +-- SELECT +-- p.username, +-- s.status, +-- s.trial_end, +-- s.paid_until, +-- s.payment_amount +-- FROM subscriptions s +-- JOIN parents p ON s.parent_id = p.user_id; + +-- Проверка детей +-- SELECT +-- p.username, +-- c.student_name, +-- c.card_number, +-- sc.school_name +-- FROM children c +-- JOIN parents p ON c.parent_id = p.user_id +-- JOIN schools sc ON c.school_id = sc.id; \ No newline at end of file