This commit is contained in:
2026-03-01 15:06:22 +03:00
parent aff088723a
commit 8b05a2d512
15 changed files with 540 additions and 5 deletions
+2
View File
@@ -17,6 +17,7 @@ from dutylog.infrastructure.ioc import (
DatabaseProvider,
DAOProvider,
RepositoryProvider,
ServiceProvider,
)
from dutylog.infrastructure.utils.config import load_config
@@ -39,6 +40,7 @@ async def main():
DatabaseProvider(),
DAOProvider(),
RepositoryProvider(),
ServiceProvider(),
)
dp.include_router(user_router)
@@ -38,6 +38,8 @@ from dutylog.application.bot.user_dialogs.admin_dialogs.rooms_management import
from dutylog.application.bot.user_dialogs.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 (
add_hours_select_window,
@@ -85,6 +87,8 @@ admin_menu_dialog = Dialog(
create_room_confirm_window,
reporting_period_window,
next_period_confirm_window,
generate_report_select_period_window,
generate_report_confirm_window,
statistics_window,
broadcast_window,
broadcast_confirm_window,
@@ -1,15 +1,18 @@
from aiogram.types import CallbackQuery
from datetime import datetime, timedelta
from aiogram.types import CallbackQuery, BufferedInputFile
from aiogram_dialog import Window, DialogManager
from aiogram_dialog.widgets.text import Format, Const
from aiogram_dialog.widgets.kbd import SwitchTo, Button, Row
from aiogram_dialog.widgets.kbd import SwitchTo, Button, Row, Select, ScrollingGroup
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from datetime import datetime, timedelta
from dutylog.application.bot.user_dialogs.states import AdminMenuSG
from dutylog.infrastructure.database.repositories.reporting_periods_repository import (
ReportingPeriodsRepository,
)
from dutylog.infrastructure.utils.datetime import msk_now
from dutylog.services.report_service import ReportService
MONTH_NAMES = {
@@ -96,7 +99,7 @@ async def on_make_report_click(
button: Button,
dialog_manager: DialogManager,
):
await callback.answer("⚠️ Функционал в разработке", show_alert=True)
await dialog_manager.switch_to(AdminMenuSG.generate_report_select_period)
@inject
@@ -173,7 +176,6 @@ reporting_period_window = Window(
Const("📊 Сделать отчёт"),
id="make_report_btn",
on_click=on_make_report_click,
when="has_active",
),
SwitchTo(
Const("◀️ Назад"),
@@ -201,3 +203,207 @@ next_period_confirm_window = Window(
state=AdminMenuSG.next_period_confirm,
getter=get_next_period_confirm_data,
)
@inject
async def get_generate_report_data(
reporting_periods_repository: FromDishka[ReportingPeriodsRepository],
**kwargs,
):
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()
if not completed_periods and not active_period:
content = """
<blockquote>📊 <b>Генерация отчёта</b></blockquote>
⚠️ Нет отчётных периодов.
Создайте отчётный период, чтобы сгенерировать отчёт.
"""
has_periods = False
periods_list = []
elif not completed_periods and active_period:
content = """
<blockquote>📊 <b>Генерация отчёта</b></blockquote>
Будет сгенерирован отчёт по активному периоду.
"""
has_periods = True
next_day = active_period.start_date + timedelta(days=1)
month_name = MONTH_NAMES[next_day.month]
year = next_day.year
periods_list = [(
f"{month_name} {year} (активный, с {active_period.start_date.strftime('%d.%m.%Y')})",
active_period.id
)]
else:
content = """
<blockquote>📊 <b>Генерация отчёта</b></blockquote>
Выберите период для генерации отчёта:
"""
has_periods = True
periods_list = []
for period in sorted(completed_periods, key=lambda p: p.start_date, reverse=True):
if period.end_date:
next_day = period.start_date + timedelta(days=1)
month_name = MONTH_NAMES[next_day.month]
year = next_day.year
periods_list.append((
f"{month_name} {year} ({period.start_date.strftime('%d.%m.%Y')} - {period.end_date.strftime('%d.%m.%Y')})",
period.id
))
return {
"content": content,
"has_periods": has_periods,
"periods": periods_list,
}
async def on_period_selected(
callback: CallbackQuery,
widget,
dialog_manager: DialogManager,
item_id: str,
):
dialog_manager.dialog_data["selected_period_id"] = int(item_id)
await dialog_manager.switch_to(AdminMenuSG.generate_report_confirm)
@inject
async def get_generate_report_confirm_data(
reporting_periods_repository: FromDishka[ReportingPeriodsRepository],
dialog_manager: DialogManager,
**kwargs,
):
period_id = dialog_manager.dialog_data.get("selected_period_id")
if not period_id:
return {"content": "⚠️ Период не выбран"}
period = await reporting_periods_repository.get_by_id(int(period_id))
if period:
next_day = period.start_date + timedelta(days=1)
month_name = MONTH_NAMES[next_day.month]
year = next_day.year
if period.end_date:
content = f"""
<blockquote>📊 <b>Подтверждение генерации</b></blockquote>
<b>Период:</b> <code>{month_name} {year}</code>
<b>Даты:</b> <code>{period.start_date.strftime('%d.%m.%Y')} - {period.end_date.strftime('%d.%m.%Y')}</code>
Сгенерировать отчёт по начислениям часов за этот период?
"""
else:
content = f"""
<blockquote>📊 <b>Подтверждение генерации</b></blockquote>
<b>Период:</b> <code>{month_name} {year} (активный)</code>
<b>Начало:</b> <code>{period.start_date.strftime('%d.%m.%Y')}</code>
Сгенерировать отчёт по начислениям часов за активный период (до текущей даты)?
"""
else:
content = "⚠️ Период не найден"
return {"content": content}
@inject
async def on_generate_report_confirm(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
reporting_periods_repository: FromDishka[ReportingPeriodsRepository],
report_service: FromDishka[ReportService],
):
period_id = dialog_manager.dialog_data.get("selected_period_id")
if not period_id:
await callback.answer("⚠️ Период не выбран", show_alert=True)
return
period = await reporting_periods_repository.get_by_id(int(period_id))
if not period:
await callback.answer("⚠️ Период не найден", show_alert=True)
return
await callback.answer("⏳ Генерирую отчёт...")
end_date = period.end_date if period.end_date else msk_now()
print(end_date)
report_file = await report_service.generate_period_report(
period.start_date, end_date
)
next_day = period.start_date + timedelta(days=1)
month_name = MONTH_NAMES[next_day.month]
year = next_day.year
filename = f"Отчёт_{month_name}_{year}.xlsx"
document = BufferedInputFile(report_file.read(), filename=filename)
if callback.message:
await callback.message.answer_document(
document=document,
caption=f"📊 Отчёт по начислениям часов за {month_name} {year}"
)
await callback.answer("✅ Отчёт сгенерирован!")
await dialog_manager.switch_to(AdminMenuSG.reporting_period)
async def on_generate_report_cancel(
callback: CallbackQuery,
button: Button,
dialog_manager: DialogManager,
):
await dialog_manager.switch_to(AdminMenuSG.generate_report_select_period)
generate_report_select_period_window = Window(
Format("{content}"),
ScrollingGroup(
Select(
Format("{item[0]}"),
id="period_select",
item_id_getter=lambda x: x[1],
items="periods",
on_click=on_period_selected,
),
id="periods_scroll",
width=1,
height=5,
when="has_periods",
),
SwitchTo(
Const("◀️ Назад"),
id="back_to_reporting_period",
state=AdminMenuSG.reporting_period,
),
state=AdminMenuSG.generate_report_select_period,
getter=get_generate_report_data,
)
generate_report_confirm_window = Window(
Format("{content}"),
Row(
Button(
Const("✅ Да"),
id="confirm_generate_report",
on_click=on_generate_report_confirm,
),
Button(
Const("❌ Нет"),
id="cancel_generate_report",
on_click=on_generate_report_cancel,
),
),
state=AdminMenuSG.generate_report_confirm,
getter=get_generate_report_confirm_data,
)
@@ -42,6 +42,8 @@ class AdminMenuSG(StatesGroup):
create_room_confirm = State()
reporting_period = State()
next_period_confirm = State()
generate_report_select_period = State()
generate_report_confirm = State()
statistics = State()
broadcast = State()
broadcast_confirm = State()
@@ -39,3 +39,38 @@ 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
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())
@@ -28,3 +28,6 @@ class FloorsRepository:
async def delete_floor(self, floor_id: int) -> None:
await self.floors_dao.delete(floor_id)
async def get_by_id(self, floor_id: int) -> Floor | None:
return await self.get_floor_by_id(floor_id)
@@ -121,3 +121,6 @@ class HoursTransactionsRepository:
self, transaction_id: int
) -> HoursTransaction | None:
return await self.transactions_dao.get_by_id(transaction_id)
async def get_by_period(self, start_date, end_date) -> list[HoursTransaction]:
return await self.transactions_dao.get_by_period(start_date, end_date)
@@ -26,3 +26,9 @@ class ReportingPeriodsRepository:
async def delete_period(self, period_id: int) -> None:
await self.reporting_periods_dao.delete(period_id)
async def get_by_id(self, period_id: int) -> ReportingPeriod | None:
return await self.get_period_by_id(period_id)
async def get_all(self) -> list[ReportingPeriod]:
return await self.get_all_periods()
@@ -103,3 +103,6 @@ class ResidentsRepository:
async def delete_resident(self, resident_id: int) -> None:
await self.residents_dao.delete(resident_id)
async def get_by_id(self, resident_id: int) -> Resident | None:
return await self.get_resident_by_id(resident_id)
@@ -24,3 +24,6 @@ class RoomsRepository:
async def delete_room(self, room_id: int) -> None:
await self.rooms_dao.delete(room_id)
async def get_by_id(self, room_id: int) -> Room | None:
return await self.get_room_by_id(room_id)
+18
View File
@@ -33,6 +33,7 @@ from dutylog.infrastructure.database.repositories.reporting_periods_repository i
ReportingPeriodsRepository,
)
from dutylog.infrastructure.utils.config import Config, load_config
from dutylog.services.report_service import ReportService
class ConfigProvider(Provider):
@@ -118,3 +119,20 @@ class RepositoryProvider(Provider):
self, reporting_periods_dao: ReportingPeriodsDAO
) -> ReportingPeriodsRepository:
return ReportingPeriodsRepository(reporting_periods_dao)
class ServiceProvider(Provider):
@provide(scope=Scope.REQUEST)
def get_report_service(
self,
hours_transactions_repository: HoursTransactionsRepository,
residents_repository: ResidentsRepository,
rooms_repository: RoomsRepository,
floors_repository: FloorsRepository,
) -> ReportService:
return ReportService(
hours_transactions_repository,
residents_repository,
rooms_repository,
floors_repository,
)
+3
View File
@@ -0,0 +1,3 @@
from dutylog.services.report_service import ReportService
__all__ = ["ReportService"]
+223
View File
@@ -0,0 +1,223 @@
from datetime import date
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from dutylog.infrastructure.database.repositories.floors_repository import (
FloorsRepository,
)
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,
)
class ReportService:
def __init__(
self,
hours_transactions_repository: HoursTransactionsRepository,
residents_repository: ResidentsRepository,
rooms_repository: RoomsRepository,
floors_repository: FloorsRepository,
):
self.hours_transactions_repository = hours_transactions_repository
self.residents_repository = residents_repository
self.rooms_repository = rooms_repository
self.floors_repository = floors_repository
async def generate_period_report(self, start_date: date, end_date: date) -> BytesIO:
transactions = await self.hours_transactions_repository.get_by_period(
start_date, end_date
)
increase_transactions = [
t for t in transactions if t.transaction_type == "increase"
]
decrease_transactions = [
t for t in transactions if t.transaction_type == "decrease"
]
wb = Workbook()
ws = wb.active
if ws is None:
raise ValueError("Failed to create worksheet")
ws.title = "Отчет по начислениям"
header_font = Font(bold=True, size=12, color="FFFFFF")
header_fill = PatternFill(
start_color="4472C4", end_color="4472C4", fill_type="solid"
)
header_alignment = Alignment(horizontal="center", vertical="center")
summary_font = Font(bold=True, size=11)
summary_fill = PatternFill(
start_color="E7E6E6", end_color="E7E6E6", fill_type="solid"
)
border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
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")
title_cell = ws.cell(row=1, column=1)
title_cell.font = Font(bold=True, size=14)
title_cell.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 25
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}")
row_num += 1
headers = ["Дата", "Резидент", "Комната", "Часы", "Примечание"]
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=row_num, column=col_num)
cell.value = header
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
ws.row_dimensions[row_num].height = 20
row_num += 1
total_increase = 0
for transaction in increase_transactions:
resident = await self.residents_repository.get_by_id(
transaction.resident_id
)
if not resident:
continue
room = await self.rooms_repository.get_by_id(resident.room)
room_number = room.number if room else ""
ws.cell(
row=row_num, column=1, value=transaction.created_at.replace(tzinfo=None)
).border = border
ws.cell(row=row_num, column=1).number_format = "DD.MM.YYYY HH:MM"
ws.cell(
row=row_num, column=2, value=resident.real_name or "Без имени"
).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=transaction.remark or ""
).border = border
ws.cell(row=row_num, column=1).alignment = Alignment(horizontal="center")
ws.cell(row=row_num, column=3).alignment = Alignment(horizontal="center")
ws.cell(row=row_num, column=4).alignment = Alignment(horizontal="center")
total_increase += transaction.amount
row_num += 1
summary_row = row_num
ws.merge_cells(f"A{summary_row}:C{summary_row}")
summary_cell = ws[f"A{summary_row}"]
summary_cell.value = "Начислено часов:"
summary_cell.font = summary_font
summary_cell.fill = summary_fill
summary_cell.alignment = Alignment(horizontal="right", vertical="center")
summary_cell.border = border
ws.cell(row=summary_row, column=4, value=total_increase).font = summary_font
ws.cell(row=summary_row, column=4).fill = summary_fill
ws.cell(row=summary_row, column=4).alignment = Alignment(horizontal="center")
ws.cell(row=summary_row, column=4).border = border
ws.cell(row=summary_row, column=5, value="").fill = summary_fill
ws.cell(row=summary_row, column=5).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}")
row_num += 1
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=row_num, column=col_num)
cell.value = header
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
ws.row_dimensions[row_num].height = 20
row_num += 1
total_decrease = 0
for transaction in decrease_transactions:
resident = await self.residents_repository.get_by_id(
transaction.resident_id
)
if not resident:
continue
room = await self.rooms_repository.get_by_id(resident.room)
room_number = room.number if room else ""
ws.cell(
row=row_num, column=1, value=transaction.created_at.replace(tzinfo=None)
).border = border
ws.cell(row=row_num, column=1).number_format = "DD.MM.YYYY HH:MM"
ws.cell(
row=row_num, column=2, value=resident.real_name or "Без имени"
).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=transaction.remark or ""
).border = border
ws.cell(row=row_num, column=1).alignment = Alignment(horizontal="center")
ws.cell(row=row_num, column=3).alignment = Alignment(horizontal="center")
ws.cell(row=row_num, column=4).alignment = Alignment(horizontal="center")
total_decrease += transaction.amount
row_num += 1
summary_row = row_num
ws.merge_cells(f"A{summary_row}:C{summary_row}")
summary_cell = ws[f"A{summary_row}"]
summary_cell.value = "Списано часов:"
summary_cell.font = summary_font
summary_cell.fill = summary_fill
summary_cell.alignment = Alignment(horizontal="right", vertical="center")
summary_cell.border = border
ws.cell(row=summary_row, column=4, value=total_decrease).font = summary_font
ws.cell(row=summary_row, column=4).fill = summary_fill
ws.cell(row=summary_row, column=4).alignment = Alignment(horizontal="center")
ws.cell(row=summary_row, column=4).border = border
ws.cell(row=summary_row, column=5, value="").fill = summary_fill
ws.cell(row=summary_row, column=5).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
output = BytesIO()
wb.save(output)
output.seek(0)
return output