diff --git a/import_hours.py b/import_hours.py deleted file mode 100644 index 87bbd94..0000000 --- a/import_hours.py +++ /dev/null @@ -1,251 +0,0 @@ -import argparse -import asyncio -import pandas as pd -import asyncpg -import re -import os - - -def clean_name(name: str) -> str: - if not isinstance(name, str): - return "" - name = re.sub(r'\s+[СC]$', '', name.strip()) - name = re.sub(r'\s*\d+\s*$', '', name) - return name.strip() - - -def parse_excel_raw(file_path: str): - excel_file = pd.ExcelFile(file_path) - resident_hours = {} - room_hours = {} - - for sheet_name in excel_file.sheet_names: - df = pd.read_excel(file_path, sheet_name=sheet_name) - - # --- Парсинг жильцов --- - if 'ФИО' in df.columns and 'Часы' in df.columns: - for _, row in df.iterrows(): - fio = clean_name(str(row['ФИО'])) - if not fio or str(fio).lower() == 'nan': - continue - try: - hours_raw = str(row['Часы']).split('(')[0].strip() - amount = int(float(hours_raw)) - except ValueError: - continue - - if fio in resident_hours: - resident_hours[fio] += amount - else: - resident_hours[fio] = amount - - # --- Парсинг комнат --- - elif df.iloc[0].astype(str).str.contains('Комната', case=False, na=False).any(): - header_idx = df[df.apply(lambda r: r.astype(str).str.contains('Комната', case=False).any(), axis=1)].index[0] - df.columns = df.iloc[header_idx] - df = df.iloc[header_idx + 1:].reset_index(drop=True) - - for _, row in df.iterrows(): - room_str = str(row['Комната']) - if not room_str or room_str.lower() == 'nan': - continue - - room_match = re.search(r'\b(\d{3,4})\b', room_str) - if not room_match: - continue - - room_number = int(room_match.group(1)) - - try: - punishment_raw = str(row['Наказание']).split('(')[0].replace('часов', '').replace(',', ';').strip() - amounts = [int(x.strip()) for x in re.findall(r'\d+', punishment_raw)] - amount = sum(amounts) if amounts else 0 - except (ValueError, AttributeError): - continue - - if amount <= 0: - continue - - if room_number in room_hours: - room_hours[room_number] += amount - else: - room_hours[room_number] = amount - - return resident_hours, room_hours - - -async def run_parse(database_url: str, file_path: str): - raw_resident_hours, raw_room_hours = parse_excel_raw(file_path) - - async with asyncpg.create_pool(database_url) as pool: - async with pool.acquire() as conn: - resident_rows = await conn.fetch("SELECT real_name FROM residents WHERE real_name IS NOT NULL;") - db_names = [row['real_name'] for row in resident_rows] - - room_rows = await conn.fetch("SELECT number FROM rooms;") - db_rooms = {row['number'] for row in room_rows} - - # --- Сопоставление жильцов --- - db_names_lower = {name.lower(): name for name in db_names} - matched_residents = {} - unmatched_residents = {} - - for raw_fio, amount in raw_resident_hours.items(): - fio_lower = raw_fio.lower() - matched_name = None - - if fio_lower in db_names_lower: - matched_name = db_names_lower[fio_lower] - else: - parts = fio_lower.split() - if len(parts) >= 2: - search_key = f"{parts[0]} {parts[1]}" - if search_key in db_names_lower: - matched_name = db_names_lower[search_key] - - if not matched_name: - for db_lower, db_orig in db_names_lower.items(): - if fio_lower in db_lower or db_lower in fio_lower: - matched_name = db_orig - break - - if matched_name: - matched_residents[matched_name] = matched_residents.get(matched_name, 0) + amount - else: - unmatched_residents[raw_fio] = unmatched_residents.get(raw_fio, 0) + amount - - # --- Сопоставление комнат --- - matched_rooms = {} - unmatched_rooms = {} - - for room_num, amount in raw_room_hours.items(): - if room_num in db_rooms: - matched_rooms[room_num] = matched_rooms.get(room_num, 0) + amount - else: - unmatched_rooms[room_num] = unmatched_rooms.get(room_num, 0) + amount - - # --- Запись в файлы --- - '''with open("parsed_residents.txt", "w", encoding="utf-8") as f: - for name, amount in sorted(matched_residents.items()): - f.write(f"{name} - {amount}\n")''' - - with open("parsed_rooms.txt", "w", encoding="utf-8") as f: - for room, amount in sorted(matched_rooms.items()): - f.write(f"{room} - {amount}\n") - - '''if unmatched_residents: - with open("unmatched_residents.txt", "w", encoding="utf-8") as f: - for name, amount in sorted(unmatched_residents.items()): - f.write(f"{name} - {amount}\n")''' - - if unmatched_rooms: - with open("unmatched_rooms.txt", "w", encoding="utf-8") as f: - for room, amount in sorted(unmatched_rooms.items()): - f.write(f"{room} - {amount}\n") - - -async def run_load(database_url: str, admin_id: int): - if not os.path.exists("parsed_residents.txt") or not os.path.exists("parsed_rooms.txt"): - raise FileNotFoundError("Файлы parsed_residents.txt или parsed_rooms.txt не найдены. Сначала запустите parse.") - - resident_transactions = [] - with open("parsed_residents.txt", "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - parts = line.split(" - ") - if len(parts) == 2: - resident_transactions.append((parts[0].strip(), int(parts[1].strip()))) - - room_transactions = [] - with open("parsed_rooms.txt", "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - parts = line.split(" - ") - if len(parts) == 2: - room_transactions.append((int(parts[0].strip()), int(parts[1].strip()))) - - async with asyncpg.create_pool(database_url) as pool: - async with pool.acquire() as conn: - # Маппинги - res_rows = await conn.fetch("SELECT id, real_name FROM residents WHERE real_name IS NOT NULL;") - name_to_id = {row['real_name']: row['id'] for row in res_rows} - - rm_rows = await conn.fetch("SELECT id, number FROM rooms;") - num_to_id = {row['number']: row['id'] for row in rm_rows} - - res_tx_params = [] - res_update_params = [] - for name, amount in resident_transactions: - res_id = name_to_id.get(name) - if res_id: - res_tx_params.append((res_id, "increase", amount, admin_id, "Инициализация существующих часов")) - res_update_params.append((amount, res_id)) - - rm_tx_params = [] - rm_update_params = [] - for room_num, amount in room_transactions: - rm_id = num_to_id.get(room_num) - if rm_id: - rm_tx_params.append((rm_id, "increase", amount, admin_id, "Инициализация существующих часов")) - rm_update_params.append((amount, rm_id)) - - async with conn.transaction(): - # Транзакции резидентов - if res_tx_params: - await conn.executemany( - """ - INSERT INTO hours_transactions - (resident_id, transaction_type, amount, admin_id, remark, created_at) - VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP); - """, res_tx_params - ) - # Обновление баланса резидентов - await conn.executemany( - """ - UPDATE residents - SET active_hours = active_hours + $1, updated_at = CURRENT_TIMESTAMP - WHERE id = $2; - """, res_update_params - ) - - # Транзакции комнат - if rm_tx_params: - await conn.executemany( - """ - INSERT INTO room_hours_transactions - (room_id, transaction_type, amount, admin_id, remark, created_at) - VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP); - """, rm_tx_params - ) - # Обновление баланса комнат - await conn.executemany( - """ - UPDATE rooms - SET active_hours = active_hours + $1 - WHERE id = $2; - """, rm_update_params - ) - - -async def main(): - parser = argparse.ArgumentParser() - parser.add_argument("command", choices=["parse", "load"]) - parser.add_argument("--db-url", required=True) - parser.add_argument("--file", required=False) - parser.add_argument("--admin-id", type=int, default=2047958833) - args = parser.parse_args() - - if args.command == "parse": - if not args.file: - parser.error("Для команды parse требуется указать --file") - await run_parse(args.db_url, args.file) - elif args.command == "load": - await run_load(args.db_url, args.admin_id) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/import_residents.py b/import_residents.py deleted file mode 100644 index 2d3c74d..0000000 --- a/import_residents.py +++ /dev/null @@ -1,102 +0,0 @@ -import argparse -import asyncio -import re -from docx import Document -import asyncpg - - -def parse_residents(docx_path: str) -> list[dict]: - doc = Document(docx_path) - results = [] - seen = set() - - for table in doc.tables: - current_room = None - for row in table.rows: - cells = row.cells - if len(cells) < 3: - continue - - cell0_text = cells[0].text.strip() - room_match = re.search(r'\b(\d{3,4})\b', cell0_text) - if room_match: - current_room = int(room_match.group(1)) - - name_text = cells[2].text.strip() - skip_keywords = ('Дата', 'Фамилия', 'Осталось', 'Совершеннолетних') - - if name_text and current_room and not any(kw in name_text for kw in skip_keywords): - clean_name = re.sub(r'\s+[СC]$', '', name_text).strip() - if clean_name and len(clean_name) > 2: - key = (current_room, clean_name) - if key not in seen: - seen.add(key) - results.append({"room_number": current_room, "real_name": clean_name}) - - return results - - -async def insert_data(database_url: str, residents: list[dict]) -> None: - async with asyncpg.create_pool(database_url) as pool: - async with pool.acquire() as conn: - async with conn.transaction(): - floor_rows = await conn.fetch("SELECT id, number FROM floors;") - floor_map = {row['number']: row['id'] for row in floor_rows} - - if not floor_map: - raise RuntimeError("Таблица floors пуста, невозможно привязать комнаты.") - - unique_rooms = set(r["room_number"] for r in residents) - room_insert_params = [] - - for room_num in unique_rooms: - floor_num = room_num // 100 - if floor_num not in floor_map: - raise ValueError(f"Для комнаты {room_num} не найден этаж {floor_num} в БД.") - room_insert_params.append((room_num, floor_map[floor_num])) - - await conn.executemany( - """ - INSERT INTO rooms (number, on_floor) - VALUES ($1, $2) - ON CONFLICT (number) DO NOTHING; - """, - room_insert_params - ) - - room_rows = await conn.fetch( - """ - SELECT id, number - FROM rooms - WHERE number = ANY($1::int[]); - """, - list(unique_rooms) - ) - room_map = {row['number']: row['id'] for row in room_rows} - - resident_params = [ - (r["real_name"], room_map[r["room_number"]]) - for r in residents - ] - - await conn.executemany( - """ - INSERT INTO residents (real_name, room, created_at, updated_at) - VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); - """, - resident_params - ) - - -async def main() -> None: - parser = argparse.ArgumentParser(description="Импорт жильцов из docx в PostgreSQL") - parser.add_argument("--db-url", required=True, help="URL базы данных (напр. postgresql://user:pass@localhost/db)") - parser.add_argument("--file", required=True, help="Путь к docx файлу") - args = parser.parse_args() - - residents = parse_residents(args.file) - await insert_data(args.db_url, residents) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/parsed_residents.txt b/parsed_residents.txt deleted file mode 100644 index 3561c0d..0000000 --- a/parsed_residents.txt +++ /dev/null @@ -1,98 +0,0 @@ -Абакунчик Анастасия - 55 -Адамович Анастасия - 30 -Алиева Ангелина - 5 -Анищенок Алена - 5 -Апацкая Александра - 25 -Басаревский Олег - 50 -Бобкова Софья - 60 -Бойко Виктория - 55 -Буйвол Екатерина - 45 -Вишневская Вирсавия - 20 -Володько Виктория - 10 -Гвоздович Иван - 45 -Гинцева Ева - 5 -Гумовский Тимур - 20 -Гутырчик Арина - 5 -Гутырчик Никита - 5 -Давлетшина Алеся - 35 -Доморацкий Ярослав - 105 -Дубинец Ксения - 20 -Дудинская Полина - 15 -Демова Маргарита - 10 -Дауд Фэрдос - 25 -Жилевич Кира - 15 -Зимницкая Валерия - 10 -Игнатенко Маргарита - 10 -Илюкевич Анна - 10 -Казак Милана - 10 -Канорин Егор - 75 -Каранько Дарья - 30 -Климбовский Иван - 55 -Костюкевич Лиза - 5 -Клименко Дарья - 15 -Климович Дмитрий - 30 -Ковалева Анна - 45 -Крукович Маргарита - 10 -Кулага Софья - 65 -Левизанова Алина - 10 -Лемеза Надежда - 55 -Липницкая Ксения - 45 -Лисица Наталья - 45 -Лобановская Кристина - 10 -Логинова Мария - 10 -Лось Артем - 85 -Лукьянов Павел - 15 -Ляхнович Яна - 20 -Максимова Юлия - 60 -Мальков Артем - 35 -Мамонько Анна - 80 -Мананкова Дарья - 90 -Мануйлов Артем - 65 -Мысливец Екатерина - 5 -Марьянский Дмитрий - 10 -Мельник Кира - 5 -Маковский Даниил - 35 -Мирошниченко Максим - 5 -Михаленок Полина - 15 -Мусаева Дильшад - 50 -Нагорная Марина - 10 -Неверовская Майя - 90 -Негода Никита - 40 -Немченко Виктория - 10 -Новикова Ксения - 80 -Островская Валерия - 10 -Павлусенко Богдан - 10 -Понамарева Алина - 10 -Пашкевич Николай - 60 -Пожиган Анастасия - 20 -Пусторжевцева Виктория - 10 -Рабцевич Дмитрий - 10 -Саланович Анастасия - 15 -Сергей Ангелина - 15 -Сечко Алина - 15 -Сивакова Татьяна - 5 -Скринник Маргарита - 35 -Сманцер Алесь - 75 -Скурьят Юлиана - 5 -Семенов Даниил - 5 -Спиченок Кира - 15 -Салоха Полина - 5 -Сушкевич Артем - 30 -Сырокваш Валерия - 5 -Терехова Арина - 15 -Тозик Константин - 5 -Тризнюк Станислав - 15 -Трухан Яна - 35 -Турончик Ангелина - 35 -Тюнис Надежда - 15 -Хацкевич Дарья - 5 -Цыбулько Екатерина - 10 -Черник Дарья - 5 -Чигир Дарья - 220 -Шарапова Ксения - 20 -Шидловский Георгий - 10 -Шишкина София - 25 -Щелоков Тимофей - 25 -Юхович Дарья - 105 -Янущик Владислав - 55 -Зимницкая Эвелина - 5 diff --git a/parsed_rooms.txt b/parsed_rooms.txt deleted file mode 100644 index 90c17d3..0000000 --- a/parsed_rooms.txt +++ /dev/null @@ -1,44 +0,0 @@ -201 - 5 -202 - 20 -203 - 5 -206 - 5 -207 - 10 -208 - 5 -209 - 5 -210 - 15 -211 - 10 -212 - 10 -213 - 5 -214 - 10 -215 - 55 -216 - 10 -301 - 5 -302 - 10 -303 - 35 -304 - 25 -305 - 15 -308 - 10 -310 - 15 -311 - 10 -313 - 5 -314 - 5 -315 - 20 -402 - 5 -403 - 5 -404 - 5 -405 - 10 -408 - 10 -412 - 10 -415 - 10 -501 - 10 -506 - 40 -507 - 20 -508 - 5 -509 - 20 -510 - 50 -511 - 100 -512 - 5 -513 - 15 -514 - 45 -515 - 15 -517 - 15 diff --git a/seed_rooms.py b/seed_rooms.py deleted file mode 100644 index 2268362..0000000 --- a/seed_rooms.py +++ /dev/null @@ -1,57 +0,0 @@ -import argparse -import asyncio -import asyncpg - - -async def seed_floors_and_rooms(database_url: str): - async with asyncpg.create_pool(database_url) as pool: - async with pool.acquire() as conn: - async with conn.transaction(): - - # Создаем этажи (со 2-го по 5-й) - floors = [2, 3, 4, 5] - await conn.executemany( - """ - INSERT INTO floors (number) - VALUES ($1) - ON CONFLICT (number) DO NOTHING; - """, - [(f,) for f in floors] - ) - print(f"Добавлены или уже существуют этажи: {floors}") - - # Получаем ID созданных этажей - floor_rows = await conn.fetch("SELECT id, number FROM floors WHERE number = ANY($1::int[]);", floors) - floor_map = {row['number']: row['id'] for row in floor_rows} - - # Создаем комнаты (x01 .. x16 для каждого этажа) - rooms_to_insert = [] - for floor_num in floors: - floor_id = floor_map[floor_num] - for room_suffix in range(1, 17): - room_number = floor_num * 100 + room_suffix # 201, 202, ..., 516 - rooms_to_insert.append((room_number, floor_id)) - - # Выполняем вставку комнат - await conn.executemany( - """ - INSERT INTO rooms (number, on_floor) - VALUES ($1, $2) - ON CONFLICT (number) DO NOTHING; - """, - rooms_to_insert - ) - print(f"Добавлены или уже существуют {len(rooms_to_insert)} комнат.") - - -async def main(): - parser = argparse.ArgumentParser(description="Инициализация этажей (2-5) и комнат (x01-x16)") - parser.add_argument("--db-url", required=True, help="URL базы данных") - args = parser.parse_args() - - await seed_floors_and_rooms(args.db_url) - print("Инициализация завершена.") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py b/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py index c7872a8..e64cece 100644 --- a/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py +++ b/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py @@ -7,6 +7,7 @@ from dutylog.application.bot.admin_dialogs.main_menu import ( from dutylog.application.bot.admin_dialogs.residents_management import ( residents_list_window, resident_info_window, + resident_history_window, resident_logout_confirm_window, resident_delete_confirm_window, resident_rebind_floor_window, @@ -34,6 +35,7 @@ from dutylog.application.bot.admin_dialogs.rooms_management import ( rooms_select_floor_window, rooms_list_window, room_info_window, + room_history_window, room_delete_confirm_window, room_add_hours_select_window, room_remove_hours_select_window, @@ -76,6 +78,7 @@ admin_menu_dialog = Dialog( main_menu_window, residents_list_window, resident_info_window, + resident_history_window, resident_logout_confirm_window, resident_delete_confirm_window, resident_rebind_floor_window, @@ -100,6 +103,7 @@ admin_menu_dialog = Dialog( rooms_select_floor_window, rooms_list_window, room_info_window, + room_history_window, room_delete_confirm_window, room_add_hours_select_window, room_remove_hours_select_window, diff --git a/src/dutylog/application/bot/admin_dialogs/faq.py b/src/dutylog/application/bot/admin_dialogs/faq.py index d6a505b..00f33bb 100644 --- a/src/dutylog/application/bot/admin_dialogs/faq.py +++ b/src/dutylog/application/bot/admin_dialogs/faq.py @@ -7,73 +7,48 @@ from dutylog.application.bot.user_dialogs.states import AdminMenuSG async def get_admin_faq_data(**kwargs) -> dict[str, str]: content = """ -
❓ Гайд по админке+
❓ О боте DutyLog-Привет! Ты теперь админ, и это круто 😎 -Давай разберёмся, что тут к чему. +DutyLog — система учёта дежурных часов для общежития. -
🏠 Резиденты — твоя главная тусовка +Основные возможности: -Здесь живут все жители общаги. Можешь: -• Искать кого угодно — по имени, комнате или нику в телеге -• Фильтровать по часам (кто должник, а кто молодец) -• Начислять или снимать часы (не забывай писать за что!) -• Добавлять новых людей или удалять старых -• Отвязывать юзеров от резидентов (если кто-то съехал)+
👥 Управление резидентами +• Регистрация жителей общежития +• Привязка к комнатам и пользователям Telegram +• Учёт отработанных и неотработанных часов +• Поиск по имени, комнате или username +• Фильтрация по количеству часов-
🚪 Комнаты и 🏢 Этажи ++🏢 Структура общежития +• Управление этажами и комнатами +• Учёт часов как для резидентов, так и для комнат +• Просмотр жителей каждой комнаты-Тут всё просто — структура общежития. -Добавляй этажи, создавай комнаты, привязывай их друг к другу. -Без этого резидентов не создать!
⏰ Учёт часов +• Начисление неотработанных часов +• Списание часов (перевод в отработанные) +• История всех операций с примечаниями +• Уведомления резидентам об изменениях-
📅 Отчётный период — важная штука! +- -📅 Отчётные периоды +• Создание периодов учёта (обычно месяц) +• Автоматическое закрытие предыдущего периода +• Генерация Excel-отчётов за период +• 4 листа в отчёте: начисления/списания для резидентов и комнат-Система работает по месяцам. Один период = один месяц учёта. +📊 Статистика и отчёты +• Общая статистика по резидентам и комнатам +• Количество пользователей и админов +• Детальные отчёты в формате Excel +• История операций для каждого резидента и комнаты-Как это работает: -• Создаёшь новый период → старый автоматом закрывается -• Дата начала = когда создал -• Дата конца = когда создал следующий +📢 Дополнительно +• Рассылка сообщений всем пользователям +• Разграничение прав (админы/пользователи) +• Админы не участвуют в учёте часов-Зачем это нужно? Чтобы потом сделать красивый отчёт за месяц и показать всем, кто сколько отработал.
📊 Отчёты — твоя суперсила - -После закрытия периода можешь сгенерить Excel-файл. -В нём будет: -• Все начисления и списания за месяц -• Кто и когда начислил/снял часы -• Примечания (поэтому их важно писать!) -• Автоматический подсчёт итогов - -Отчёт можно скинуть старосте или куратору — всё наглядно.- -
📊 Статистика - -Быстрый взгляд на цифры: -• Сколько всего людей в системе -• Сколько админов (ты не один!) -• Общая сумма часов по всем резидентам- -
📢 Рассылка - -Нужно всем что-то сообщить? Жми сюда. -Сообщение улетит всем пользователям бота. -Можно использовать HTML для красоты.- -
💡 Лайфхаки: - -• Всегда пиши примечание при начислении/списании часов - Через месяц никто не вспомнит, за что было - -• Делай отчёты регулярно - Это твоя страховка и архив данных - -• Проверяй статистику перед закрытием периода - Вдруг что-то забыл начислить?- -Вопросы? Пиши создателю бота 👑 +Для вопросов и предложений обращайтесь к создателю бота. """ return {"content": content} diff --git a/src/dutylog/application/bot/admin_dialogs/residents_management.py b/src/dutylog/application/bot/admin_dialogs/residents_management.py index 8dbf773..4201b52 100644 --- a/src/dutylog/application/bot/admin_dialogs/residents_management.py +++ b/src/dutylog/application/bot/admin_dialogs/residents_management.py @@ -20,6 +20,10 @@ from dutylog.infrastructure.database.repositories.floors_repository import ( from dutylog.infrastructure.database.repositories.users_repository import ( UsersRepository, ) +from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( + HoursTransactionsRepository, +) +from dutylog.infrastructure.utils.datetime import msk_now @inject @@ -546,6 +550,58 @@ async def on_search_resident_selected( await dialog_manager.switch_to(AdminMenuSG.resident_info) +@inject +async def get_resident_history_data( + dialog_manager: DialogManager, + residents_repository: FromDishka[ResidentsRepository], + transactions_repository: FromDishka[HoursTransactionsRepository], + **kwargs, +): + resident_id = dialog_manager.dialog_data.get("selected_resident_id") + + if not resident_id: + return {"history_content": "Ошибка: резидент не выбран"} + + resident = await residents_repository.get_resident_by_id(resident_id) + + if not resident: + return {"history_content": "Ошибка: резидент не найден"} + + transactions = await transactions_repository.get_resident_history(resident_id) + transactions_sorted = sorted(transactions, key=lambda x: x.created_at) + last_10 = transactions_sorted[-10:] + + resident_name = resident.real_name if resident.real_name else "Без имени" + + if not last_10: + history_text = f""" +
📜 История операций+ +Резидент: {resident_name} + +История операций пуста +""" + else: + history_text = f""" +
📜 История операций+ +Резидент: {resident_name} + +""" + for tx in last_10: + operation = "Начислено" if tx.transaction_type == "increase" else "Списано" + emoji = "+" if tx.transaction_type == "increase" else "−" + + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + remark_text = f"\n💬 {tx.remark}" if tx.remark else "" + + history_text += f"
{operation} {emoji}{tx.amount} ч\n📅 {date_str}{remark_text}\n"
+
+ return {"history_content": history_text}
+
+
residents_list_window = Window(
Format("{content}"),
Row(
@@ -602,6 +658,12 @@ resident_info_window = Window(
when=~F["is_admin"],
),
),
+ Button(
+ Const("📜 История"),
+ id="resident_history_btn",
+ on_click=lambda c, b, m: m.switch_to(AdminMenuSG.resident_history),
+ when=~F["is_admin"],
+ ),
Button(
Const("🔄 Перепривязать к комнате"),
id="rebind_resident_btn",
@@ -850,3 +912,14 @@ resident_rebind_confirm_window = Window(
state=AdminMenuSG.resident_rebind_confirm,
getter=get_rebind_confirm_data,
)
+
+resident_history_window = Window(
+ Format("{history_content}"),
+ SwitchTo(
+ Const("◀️ Назад"),
+ id="back_to_resident_info",
+ state=AdminMenuSG.resident_info,
+ ),
+ state=AdminMenuSG.resident_history,
+ getter=get_resident_history_data,
+)
diff --git a/src/dutylog/application/bot/admin_dialogs/rooms_management.py b/src/dutylog/application/bot/admin_dialogs/rooms_management.py
index 10bbf07..9ec77eb 100644
--- a/src/dutylog/application/bot/admin_dialogs/rooms_management.py
+++ b/src/dutylog/application/bot/admin_dialogs/rooms_management.py
@@ -22,6 +22,7 @@ from dutylog.infrastructure.database.repositories.room_hours_transactions_reposi
from dutylog.infrastructure.database.repositories.users_repository import (
UsersRepository,
)
+from dutylog.infrastructure.utils.datetime import msk_now
async def on_rooms_click(
@@ -533,6 +534,56 @@ async def on_delete_room_cancel(
await dialog_manager.switch_to(AdminMenuSG.room_info)
+@inject
+async def get_room_history_data(
+ dialog_manager: DialogManager,
+ rooms_repository: FromDishka[RoomsRepository],
+ room_transactions_repository: FromDishka[RoomHoursTransactionsRepository],
+ **kwargs,
+):
+ room_id = dialog_manager.dialog_data.get("selected_room_id")
+
+ if not room_id:
+ return {"history_content": "Ошибка: комната не выбрана"}
+
+ room = await rooms_repository.get_room_by_id(room_id)
+
+ if not room:
+ return {"history_content": "Ошибка: комната не найдена"}
+
+ transactions = await room_transactions_repository.get_room_history(room_id)
+ transactions_sorted = sorted(transactions, key=lambda x: x.created_at)
+ last_10 = transactions_sorted[-10:]
+
+ if not last_10:
+ history_text = f"""
+📜 История операций+ +Комната: {room.number} + +История операций пуста +""" + else: + history_text = f""" +
📜 История операций+ +Комната: {room.number} + +""" + for tx in last_10: + operation = "Начислено" if tx.transaction_type == "increase" else "Списано" + emoji = "+" if tx.transaction_type == "increase" else "−" + + msk_time = tx.created_at.astimezone(msk_now().tzinfo).replace(tzinfo=None) + date_str = msk_time.strftime("%d.%m.%Y %H:%M") + + remark_text = f"\n💬 {tx.remark}" if tx.remark else "" + + history_text += f"
{operation} {emoji}{tx.amount} ч\n📅 {date_str}{remark_text}\n"
+
+ return {"history_content": history_text}
+
+
rooms_select_floor_window = Window(
Format("{content}"),
Group(
@@ -596,6 +647,11 @@ room_info_window = Window(
on_click=on_room_remove_hours_click,
),
),
+ Button(
+ Const("📜 История"),
+ id="room_history_btn",
+ on_click=lambda c, b, m: m.switch_to(AdminMenuSG.room_history),
+ ),
Button(
Const("🗑 Удалить комнату"),
id="delete_room_btn",
@@ -819,3 +875,14 @@ create_room_confirm_window = Window(
state=AdminMenuSG.create_room_confirm,
getter=get_create_room_confirm_data,
)
+
+room_history_window = Window(
+ Format("{history_content}"),
+ SwitchTo(
+ Const("◀️ Назад"),
+ id="back_to_room_info",
+ state=AdminMenuSG.room_info,
+ ),
+ state=AdminMenuSG.room_history,
+ getter=get_room_history_data,
+)
diff --git a/src/dutylog/application/bot/user_dialogs/states.py b/src/dutylog/application/bot/user_dialogs/states.py
index 0e60b90..6613c1e 100644
--- a/src/dutylog/application/bot/user_dialogs/states.py
+++ b/src/dutylog/application/bot/user_dialogs/states.py
@@ -17,6 +17,7 @@ class AdminMenuSG(StatesGroup):
residents_filter_hours_input = State()
residents_filtered_results = State()
resident_info = State()
+ resident_history = State()
resident_logout_confirm = State()
resident_delete_confirm = State()
resident_rebind_floor = State()
@@ -41,6 +42,7 @@ class AdminMenuSG(StatesGroup):
rooms_select_floor = State()
rooms_list = State()
room_info = State()
+ room_history = State()
room_delete_confirm = State()
room_add_hours_select = State()
room_remove_hours_select = State()