diff --git a/src/dutylog/application/__main__.py b/src/dutylog/application/__main__.py index dbbc312..606b0dd 100644 --- a/src/dutylog/application/__main__.py +++ b/src/dutylog/application/__main__.py @@ -10,7 +10,7 @@ from dishka.integrations.aiogram import setup_dishka from dutylog.application.bot.user_handlers import router as user_router from dutylog.application.bot.user_dialogs import main_menu_dialog -from dutylog.application.bot.user_dialogs.admin_dialogs import admin_menu_dialog +from dutylog.application.bot.admin_dialogs import admin_menu_dialog from dutylog.application.bot.user_dialogs.registration_dialog import registration_dialog from dutylog.infrastructure.ioc import ( ConfigProvider, diff --git a/src/dutylog/application/bot/admin_dialogs/__init__.py b/src/dutylog/application/bot/admin_dialogs/__init__.py new file mode 100644 index 0000000..7dc8fb0 --- /dev/null +++ b/src/dutylog/application/bot/admin_dialogs/__init__.py @@ -0,0 +1,5 @@ +from dutylog.application.bot.admin_dialogs.admin_menu_dialog import ( + admin_menu_dialog, +) + +__all__ = ["admin_menu_dialog"] diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py b/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py similarity index 78% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py rename to src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py index e8d15a6..50f119d 100644 --- a/src/dutylog/application/bot/user_dialogs/admin_dialogs/admin_menu_dialog.py +++ b/src/dutylog/application/bot/admin_dialogs/admin_menu_dialog.py @@ -1,10 +1,10 @@ from aiogram_dialog import Dialog -from dutylog.application.bot.user_dialogs.admin_dialogs.main_menu import ( +from dutylog.application.bot.admin_dialogs.main_menu import ( main_menu_window, statistics_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.residents_management import ( +from dutylog.application.bot.admin_dialogs.residents_management import ( residents_list_window, resident_info_window, resident_logout_confirm_window, @@ -16,18 +16,18 @@ from dutylog.application.bot.user_dialogs.admin_dialogs.residents_management imp search_input_window, search_results_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.residents_filter import ( +from dutylog.application.bot.admin_dialogs.residents_filter import ( filter_select_window, filter_hours_input_window, filtered_results_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.floors_management import ( +from dutylog.application.bot.admin_dialogs.floors_management import ( floors_list_window, floor_delete_confirm_window, create_floor_input_window, create_floor_confirm_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.rooms_management import ( +from dutylog.application.bot.admin_dialogs.rooms_management import ( rooms_select_floor_window, rooms_list_window, room_delete_confirm_window, @@ -35,13 +35,13 @@ from dutylog.application.bot.user_dialogs.admin_dialogs.rooms_management import create_room_input_window, create_room_confirm_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.reporting_period_management import ( +from dutylog.application.bot.admin_dialogs.reporting_period_management import ( reporting_period_window, next_period_confirm_window, generate_report_select_period_window, generate_report_confirm_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.hours_management import ( +from dutylog.application.bot.admin_dialogs.hours_management import ( add_hours_select_window, remove_hours_select_window, add_hours_custom_window, @@ -51,7 +51,7 @@ from dutylog.application.bot.user_dialogs.admin_dialogs.hours_management import add_hours_confirm_window, remove_hours_confirm_window, ) -from dutylog.application.bot.user_dialogs.admin_dialogs.broadcast import ( +from dutylog.application.bot.admin_dialogs.broadcast import ( broadcast_window, broadcast_confirm_window, ) diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/broadcast.py b/src/dutylog/application/bot/admin_dialogs/broadcast.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/broadcast.py rename to src/dutylog/application/bot/admin_dialogs/broadcast.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/floors_management.py b/src/dutylog/application/bot/admin_dialogs/floors_management.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/floors_management.py rename to src/dutylog/application/bot/admin_dialogs/floors_management.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/hours_management.py b/src/dutylog/application/bot/admin_dialogs/hours_management.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/hours_management.py rename to src/dutylog/application/bot/admin_dialogs/hours_management.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/main_menu.py b/src/dutylog/application/bot/admin_dialogs/main_menu.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/main_menu.py rename to src/dutylog/application/bot/admin_dialogs/main_menu.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/reporting_period_management.py b/src/dutylog/application/bot/admin_dialogs/reporting_period_management.py similarity index 98% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/reporting_period_management.py rename to src/dutylog/application/bot/admin_dialogs/reporting_period_management.py index f943275..e287736 100644 --- a/src/dutylog/application/bot/user_dialogs/admin_dialogs/reporting_period_management.py +++ b/src/dutylog/application/bot/admin_dialogs/reporting_period_management.py @@ -35,7 +35,7 @@ async def on_reporting_period_click( callback: CallbackQuery, button: Button, dialog_manager: DialogManager, -): +) -> None: await dialog_manager.switch_to(AdminMenuSG.reporting_period) @@ -43,7 +43,7 @@ async def on_reporting_period_click( async def get_reporting_period_data( reporting_periods_repository: FromDishka[ReportingPeriodsRepository], **kwargs, -): +) -> dict[str, bool | str]: active_period = await reporting_periods_repository.get_active_period() if active_period: @@ -90,7 +90,7 @@ async def on_next_period_click( callback: CallbackQuery, button: Button, dialog_manager: DialogManager, -): +) -> None: await dialog_manager.switch_to(AdminMenuSG.next_period_confirm) @@ -98,7 +98,7 @@ async def on_make_report_click( callback: CallbackQuery, button: Button, dialog_manager: DialogManager, -): +) -> None: await dialog_manager.switch_to(AdminMenuSG.generate_report_select_period) @@ -106,7 +106,7 @@ async def on_make_report_click( async def get_next_period_confirm_data( reporting_periods_repository: FromDishka[ReportingPeriodsRepository], **kwargs, -): +) -> dict[str, str]: active_period = await reporting_periods_repository.get_active_period() if active_period: @@ -144,7 +144,7 @@ async def on_next_period_confirm( button: Button, dialog_manager: DialogManager, reporting_periods_repository: FromDishka[ReportingPeriodsRepository], -): +) -> None: active_period = await reporting_periods_repository.get_active_period() current_date = datetime.now().date() @@ -161,7 +161,7 @@ async def on_next_period_cancel( callback: CallbackQuery, button: Button, dialog_manager: DialogManager, -): +) -> None: await dialog_manager.switch_to(AdminMenuSG.reporting_period) @@ -209,7 +209,7 @@ next_period_confirm_window = Window( async def get_generate_report_data( reporting_periods_repository: FromDishka[ReportingPeriodsRepository], **kwargs, -): +) -> dict[str, bool | str | list[tuple[str, int]]]: all_periods = await reporting_periods_repository.get_all() completed_periods = [p for p in all_periods if p.end_date is not None] active_period = await reporting_periods_repository.get_active_period() @@ -268,7 +268,7 @@ async def on_period_selected( widget, dialog_manager: DialogManager, item_id: str, -): +) -> None: dialog_manager.dialog_data["selected_period_id"] = int(item_id) await dialog_manager.switch_to(AdminMenuSG.generate_report_confirm) @@ -278,7 +278,7 @@ async def get_generate_report_confirm_data( reporting_periods_repository: FromDishka[ReportingPeriodsRepository], dialog_manager: DialogManager, **kwargs, -): +) -> dict[str, str]: period_id = dialog_manager.dialog_data.get("selected_period_id") if not period_id: return {"content": "⚠️ Период не выбран"} @@ -321,7 +321,7 @@ async def on_generate_report_confirm( dialog_manager: DialogManager, reporting_periods_repository: FromDishka[ReportingPeriodsRepository], report_service: FromDishka[ReportService], -): +) -> None: period_id = dialog_manager.dialog_data.get("selected_period_id") if not period_id: await callback.answer("⚠️ Период не выбран", show_alert=True) @@ -335,8 +335,7 @@ async def on_generate_report_confirm( await callback.answer("⏳ Генерирую отчёт...") - end_date = period.end_date if period.end_date else msk_now() - print(end_date) + end_date = period.end_date if period.end_date else msk_now().date() report_file = await report_service.generate_period_report( period.start_date, end_date @@ -362,7 +361,7 @@ async def on_generate_report_cancel( callback: CallbackQuery, button: Button, dialog_manager: DialogManager, -): +) -> None: await dialog_manager.switch_to(AdminMenuSG.generate_report_select_period) diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/residents_filter.py b/src/dutylog/application/bot/admin_dialogs/residents_filter.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/residents_filter.py rename to src/dutylog/application/bot/admin_dialogs/residents_filter.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/residents_management.py b/src/dutylog/application/bot/admin_dialogs/residents_management.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/residents_management.py rename to src/dutylog/application/bot/admin_dialogs/residents_management.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/rooms_management.py b/src/dutylog/application/bot/admin_dialogs/rooms_management.py similarity index 100% rename from src/dutylog/application/bot/user_dialogs/admin_dialogs/rooms_management.py rename to src/dutylog/application/bot/admin_dialogs/rooms_management.py diff --git a/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py b/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py deleted file mode 100644 index 73f79e8..0000000 --- a/src/dutylog/application/bot/user_dialogs/admin_dialogs/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from dutylog.application.bot.user_dialogs.admin_dialogs.admin_menu_dialog import ( - admin_menu_dialog, -) - -__all__ = ["admin_menu_dialog"] diff --git a/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py b/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py index 2a88e22..e95fec1 100644 --- a/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py +++ b/src/dutylog/infrastructure/database/dao/hours_transactions_dao.py @@ -39,27 +39,6 @@ class HoursTransactionsDAO: delete(HoursTransaction).where(HoursTransaction.id == transaction_id) ) await self.session.commit() - async def delete(self, transaction_id: int) -> None: - await self.session.execute( - delete(HoursTransaction).where(HoursTransaction.id == transaction_id) - ) - await self.session.commit() - - async def get_by_period(self, start_date, end_date) -> list[HoursTransaction]: - """Получает транзакции за период""" - from datetime import datetime, time - - start_datetime = datetime.combine(start_date, time.min) - end_datetime = datetime.combine(end_date, time.max) - - result = await self.session.execute( - select(HoursTransaction) - .where(HoursTransaction.created_at >= start_datetime) - .where(HoursTransaction.created_at <= end_datetime) - .order_by(HoursTransaction.created_at.asc()) - ) - return list(result.scalars().all()) - async def get_by_period(self, start_date, end_date) -> list[HoursTransaction]: from datetime import datetime, time diff --git a/src/dutylog/infrastructure/ioc.py b/src/dutylog/infrastructure/ioc.py index c9129d0..902d888 100644 --- a/src/dutylog/infrastructure/ioc.py +++ b/src/dutylog/infrastructure/ioc.py @@ -129,10 +129,12 @@ class ServiceProvider(Provider): residents_repository: ResidentsRepository, rooms_repository: RoomsRepository, floors_repository: FloorsRepository, + users_repository: UsersRepository, ) -> ReportService: return ReportService( hours_transactions_repository, residents_repository, rooms_repository, floors_repository, + users_repository, ) diff --git a/src/dutylog/services/report_service.py b/src/dutylog/services/report_service.py index bee67fe..1440939 100644 --- a/src/dutylog/services/report_service.py +++ b/src/dutylog/services/report_service.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, timedelta, timezone from io import BytesIO from openpyxl import Workbook @@ -16,6 +16,11 @@ from dutylog.infrastructure.database.repositories.residents_repository import ( from dutylog.infrastructure.database.repositories.rooms_repository import ( RoomsRepository, ) +from dutylog.infrastructure.database.repositories.users_repository import ( + UsersRepository, +) + +MSK_TZ = timezone(timedelta(hours=3)) class ReportService: @@ -25,11 +30,13 @@ class ReportService: residents_repository: ResidentsRepository, rooms_repository: RoomsRepository, floors_repository: FloorsRepository, + users_repository: UsersRepository, ): self.hours_transactions_repository = hours_transactions_repository self.residents_repository = residents_repository self.rooms_repository = rooms_repository self.floors_repository = floors_repository + self.users_repository = users_repository async def generate_period_report(self, start_date: date, end_date: date) -> BytesIO: transactions = await self.hours_transactions_repository.get_by_period( @@ -69,7 +76,7 @@ class ReportService: title_text = f"Отчет по начислениям часов за период {start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}" ws.cell(row=1, column=1, value=title_text) - ws.merge_cells("A1:E1") + ws.merge_cells("A1:F1") title_cell = ws.cell(row=1, column=1) title_cell.font = Font(bold=True, size=14) title_cell.alignment = Alignment(horizontal="center", vertical="center") @@ -79,10 +86,10 @@ class ReportService: ws.cell(row=row_num, column=1, value="НАЧИСЛЕНИЯ") ws.cell(row=row_num, column=1).font = Font(bold=True, size=12) - ws.merge_cells(f"A{row_num}:E{row_num}") + ws.merge_cells(f"A{row_num}:F{row_num}") row_num += 1 - headers = ["Дата", "Резидент", "Комната", "Часы", "Примечание"] + headers = ["Дата", "Резидент", "Комната", "Часы", "Админ", "Примечание"] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=row_num, column=col_num) cell.value = header @@ -105,9 +112,17 @@ class ReportService: room = await self.rooms_repository.get_by_id(resident.room) room_number = room.number if room else "—" + + admin_username = "—" + if transaction.admin_id: + admin = await self.users_repository.get_user_by_id(transaction.admin_id) + if admin and admin.username: + admin_username = f"@{admin.username}" + + msk_time = transaction.created_at.astimezone(MSK_TZ).replace(tzinfo=None) ws.cell( - row=row_num, column=1, value=transaction.created_at.replace(tzinfo=None) + row=row_num, column=1, value=msk_time ).border = border ws.cell(row=row_num, column=1).number_format = "DD.MM.YYYY HH:MM" ws.cell( @@ -115,8 +130,9 @@ class ReportService: ).border = border ws.cell(row=row_num, column=3, value=room_number).border = border ws.cell(row=row_num, column=4, value=transaction.amount).border = border + ws.cell(row=row_num, column=5, value=admin_username).border = border ws.cell( - row=row_num, column=5, value=transaction.remark or "—" + row=row_num, column=6, value=transaction.remark or "—" ).border = border ws.cell(row=row_num, column=1).alignment = Alignment(horizontal="center") @@ -142,12 +158,15 @@ class ReportService: ws.cell(row=summary_row, column=5, value="").fill = summary_fill ws.cell(row=summary_row, column=5).border = border + + ws.cell(row=summary_row, column=6, value="").fill = summary_fill + ws.cell(row=summary_row, column=6).border = border row_num += 3 ws.cell(row=row_num, column=1, value="СПИСАНИЯ") ws.cell(row=row_num, column=1).font = Font(bold=True, size=12) - ws.merge_cells(f"A{row_num}:E{row_num}") + ws.merge_cells(f"A{row_num}:F{row_num}") row_num += 1 for col_num, header in enumerate(headers, 1): @@ -172,9 +191,17 @@ class ReportService: room = await self.rooms_repository.get_by_id(resident.room) room_number = room.number if room else "—" + + admin_username = "—" + if transaction.admin_id: + admin = await self.users_repository.get_user_by_id(transaction.admin_id) + if admin and admin.username: + admin_username = f"@{admin.username}" + + msk_time = transaction.created_at.astimezone(MSK_TZ).replace(tzinfo=None) ws.cell( - row=row_num, column=1, value=transaction.created_at.replace(tzinfo=None) + row=row_num, column=1, value=msk_time ).border = border ws.cell(row=row_num, column=1).number_format = "DD.MM.YYYY HH:MM" ws.cell( @@ -182,8 +209,9 @@ class ReportService: ).border = border ws.cell(row=row_num, column=3, value=room_number).border = border ws.cell(row=row_num, column=4, value=transaction.amount).border = border + ws.cell(row=row_num, column=5, value=admin_username).border = border ws.cell( - row=row_num, column=5, value=transaction.remark or "—" + row=row_num, column=6, value=transaction.remark or "—" ).border = border ws.cell(row=row_num, column=1).alignment = Alignment(horizontal="center") @@ -209,12 +237,16 @@ class ReportService: ws.cell(row=summary_row, column=5, value="").fill = summary_fill ws.cell(row=summary_row, column=5).border = border + + ws.cell(row=summary_row, column=6, value="").fill = summary_fill + ws.cell(row=summary_row, column=6).border = border ws.column_dimensions["A"].width = 18 ws.column_dimensions["B"].width = 25 ws.column_dimensions["C"].width = 10 ws.column_dimensions["D"].width = 10 - ws.column_dimensions["E"].width = 30 + ws.column_dimensions["E"].width = 15 + ws.column_dimensions["F"].width = 30 output = BytesIO() wb.save(output) diff --git a/test_report_2026-03-01_2026-03-01.xlsx b/test_report_2026-03-01_2026-03-01.xlsx new file mode 100644 index 0000000..47a9a97 Binary files /dev/null and b/test_report_2026-03-01_2026-03-01.xlsx differ diff --git a/test_report_generation.py b/test_report_generation.py new file mode 100644 index 0000000..fbd57c8 --- /dev/null +++ b/test_report_generation.py @@ -0,0 +1,79 @@ +import asyncio +from datetime import date + +from dutylog.infrastructure.database.config import create_engine, create_session_maker +from dutylog.infrastructure.database.dao.hours_transactions_dao import HoursTransactionsDAO +from dutylog.infrastructure.database.dao.residents_dao import ResidentsDAO +from dutylog.infrastructure.database.dao.rooms_dao import RoomsDAO +from dutylog.infrastructure.database.dao.floors_dao import FloorsDAO +from dutylog.infrastructure.database.dao.reporting_periods_dao import ReportingPeriodsDAO +from dutylog.infrastructure.database.dao.users_dao import UsersDAO +from dutylog.infrastructure.database.repositories.hours_transactions_repository import ( + HoursTransactionsRepository, +) +from dutylog.infrastructure.database.repositories.residents_repository import ResidentsRepository +from dutylog.infrastructure.database.repositories.rooms_repository import RoomsRepository +from dutylog.infrastructure.database.repositories.floors_repository import FloorsRepository +from dutylog.infrastructure.database.repositories.reporting_periods_repository import ( + ReportingPeriodsRepository, +) +from dutylog.infrastructure.database.repositories.users_repository import UsersRepository +from dutylog.infrastructure.utils.config import load_config +from dutylog.services.report_service import ReportService + + +async def test_report_generation() -> None: + config = load_config() + engine = create_engine(config.database.url) + session_maker = create_session_maker(engine) + + async with session_maker() as session: + hours_transactions_dao = HoursTransactionsDAO(session) + residents_dao = ResidentsDAO(session) + rooms_dao = RoomsDAO(session) + floors_dao = FloorsDAO(session) + reporting_periods_dao = ReportingPeriodsDAO(session) + users_dao = UsersDAO(session) + + hours_transactions_repository = HoursTransactionsRepository( + hours_transactions_dao, residents_dao + ) + residents_repository = ResidentsRepository(residents_dao) + rooms_repository = RoomsRepository(rooms_dao) + floors_repository = FloorsRepository(floors_dao) + reporting_periods_repository = ReportingPeriodsRepository(reporting_periods_dao) + users_repository = UsersRepository(users_dao) + + report_service = ReportService( + hours_transactions_repository, + residents_repository, + rooms_repository, + floors_repository, + users_repository, + ) + + active_period = await reporting_periods_repository.get_active_period() + + if not active_period: + print("⚠️ Нет активного периода") + return + + print(f"📅 Активный период: с {active_period.start_date}") + + end_date = active_period.end_date if active_period.end_date else date.today() + + print(f"📊 Генерирую отчёт за период {active_period.start_date} - {end_date}") + + report_file = await report_service.generate_period_report( + active_period.start_date, end_date + ) + + filename = f"test_report_{active_period.start_date}_{end_date}.xlsx" + with open(filename, "wb") as f: + f.write(report_file.read()) + + print(f"✅ Отчёт сохранён в файл: {filename}") + + +if __name__ == "__main__": + asyncio.run(test_report_generation())