mirror of
https://github.com/koloideal/DutyLog.git
synced 2026-06-10 02:15:30 +03:00
update
This commit is contained in:
@@ -12,6 +12,7 @@ dependencies = [
|
|||||||
"aiogram-dialog>=2.5.0",
|
"aiogram-dialog>=2.5.0",
|
||||||
"asyncpg>=0.31.0",
|
"asyncpg>=0.31.0",
|
||||||
"dishka>=1.8.0",
|
"dishka>=1.8.0",
|
||||||
|
"openpyxl>=3.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from dutylog.infrastructure.ioc import (
|
|||||||
DatabaseProvider,
|
DatabaseProvider,
|
||||||
DAOProvider,
|
DAOProvider,
|
||||||
RepositoryProvider,
|
RepositoryProvider,
|
||||||
|
ServiceProvider,
|
||||||
)
|
)
|
||||||
from dutylog.infrastructure.utils.config import load_config
|
from dutylog.infrastructure.utils.config import load_config
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ async def main():
|
|||||||
DatabaseProvider(),
|
DatabaseProvider(),
|
||||||
DAOProvider(),
|
DAOProvider(),
|
||||||
RepositoryProvider(),
|
RepositoryProvider(),
|
||||||
|
ServiceProvider(),
|
||||||
)
|
)
|
||||||
|
|
||||||
dp.include_router(user_router)
|
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 (
|
from dutylog.application.bot.user_dialogs.admin_dialogs.reporting_period_management import (
|
||||||
reporting_period_window,
|
reporting_period_window,
|
||||||
next_period_confirm_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.user_dialogs.admin_dialogs.hours_management import (
|
||||||
add_hours_select_window,
|
add_hours_select_window,
|
||||||
@@ -85,6 +87,8 @@ admin_menu_dialog = Dialog(
|
|||||||
create_room_confirm_window,
|
create_room_confirm_window,
|
||||||
reporting_period_window,
|
reporting_period_window,
|
||||||
next_period_confirm_window,
|
next_period_confirm_window,
|
||||||
|
generate_report_select_period_window,
|
||||||
|
generate_report_confirm_window,
|
||||||
statistics_window,
|
statistics_window,
|
||||||
broadcast_window,
|
broadcast_window,
|
||||||
broadcast_confirm_window,
|
broadcast_confirm_window,
|
||||||
|
|||||||
+211
-5
@@ -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 import Window, DialogManager
|
||||||
from aiogram_dialog.widgets.text import Format, Const
|
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 import FromDishka
|
||||||
from dishka.integrations.aiogram_dialog import inject
|
from dishka.integrations.aiogram_dialog import inject
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from dutylog.application.bot.user_dialogs.states import AdminMenuSG
|
from dutylog.application.bot.user_dialogs.states import AdminMenuSG
|
||||||
from dutylog.infrastructure.database.repositories.reporting_periods_repository import (
|
from dutylog.infrastructure.database.repositories.reporting_periods_repository import (
|
||||||
ReportingPeriodsRepository,
|
ReportingPeriodsRepository,
|
||||||
)
|
)
|
||||||
|
from dutylog.infrastructure.utils.datetime import msk_now
|
||||||
|
from dutylog.services.report_service import ReportService
|
||||||
|
|
||||||
|
|
||||||
MONTH_NAMES = {
|
MONTH_NAMES = {
|
||||||
@@ -96,7 +99,7 @@ async def on_make_report_click(
|
|||||||
button: Button,
|
button: Button,
|
||||||
dialog_manager: DialogManager,
|
dialog_manager: DialogManager,
|
||||||
):
|
):
|
||||||
await callback.answer("⚠️ Функционал в разработке", show_alert=True)
|
await dialog_manager.switch_to(AdminMenuSG.generate_report_select_period)
|
||||||
|
|
||||||
|
|
||||||
@inject
|
@inject
|
||||||
@@ -173,7 +176,6 @@ reporting_period_window = Window(
|
|||||||
Const("📊 Сделать отчёт"),
|
Const("📊 Сделать отчёт"),
|
||||||
id="make_report_btn",
|
id="make_report_btn",
|
||||||
on_click=on_make_report_click,
|
on_click=on_make_report_click,
|
||||||
when="has_active",
|
|
||||||
),
|
),
|
||||||
SwitchTo(
|
SwitchTo(
|
||||||
Const("◀️ Назад"),
|
Const("◀️ Назад"),
|
||||||
@@ -201,3 +203,207 @@ next_period_confirm_window = Window(
|
|||||||
state=AdminMenuSG.next_period_confirm,
|
state=AdminMenuSG.next_period_confirm,
|
||||||
getter=get_next_period_confirm_data,
|
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()
|
create_room_confirm = State()
|
||||||
reporting_period = State()
|
reporting_period = State()
|
||||||
next_period_confirm = State()
|
next_period_confirm = State()
|
||||||
|
generate_report_select_period = State()
|
||||||
|
generate_report_confirm = State()
|
||||||
statistics = State()
|
statistics = State()
|
||||||
broadcast = State()
|
broadcast = State()
|
||||||
broadcast_confirm = State()
|
broadcast_confirm = State()
|
||||||
|
|||||||
@@ -39,3 +39,38 @@ class HoursTransactionsDAO:
|
|||||||
delete(HoursTransaction).where(HoursTransaction.id == transaction_id)
|
delete(HoursTransaction).where(HoursTransaction.id == transaction_id)
|
||||||
)
|
)
|
||||||
await self.session.commit()
|
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:
|
async def delete_floor(self, floor_id: int) -> None:
|
||||||
await self.floors_dao.delete(floor_id)
|
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
|
self, transaction_id: int
|
||||||
) -> HoursTransaction | None:
|
) -> HoursTransaction | None:
|
||||||
return await self.transactions_dao.get_by_id(transaction_id)
|
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:
|
async def delete_period(self, period_id: int) -> None:
|
||||||
await self.reporting_periods_dao.delete(period_id)
|
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:
|
async def delete_resident(self, resident_id: int) -> None:
|
||||||
await self.residents_dao.delete(resident_id)
|
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:
|
async def delete_room(self, room_id: int) -> None:
|
||||||
await self.rooms_dao.delete(room_id)
|
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)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from dutylog.infrastructure.database.repositories.reporting_periods_repository i
|
|||||||
ReportingPeriodsRepository,
|
ReportingPeriodsRepository,
|
||||||
)
|
)
|
||||||
from dutylog.infrastructure.utils.config import Config, load_config
|
from dutylog.infrastructure.utils.config import Config, load_config
|
||||||
|
from dutylog.services.report_service import ReportService
|
||||||
|
|
||||||
|
|
||||||
class ConfigProvider(Provider):
|
class ConfigProvider(Provider):
|
||||||
@@ -118,3 +119,20 @@ class RepositoryProvider(Provider):
|
|||||||
self, reporting_periods_dao: ReportingPeriodsDAO
|
self, reporting_periods_dao: ReportingPeriodsDAO
|
||||||
) -> ReportingPeriodsRepository:
|
) -> ReportingPeriodsRepository:
|
||||||
return ReportingPeriodsRepository(reporting_periods_dao)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from dutylog.services.report_service import ReportService
|
||||||
|
|
||||||
|
__all__ = ["ReportService"]
|
||||||
@@ -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
|
||||||
@@ -249,6 +249,7 @@ dependencies = [
|
|||||||
{ name = "aiogram-dialog" },
|
{ name = "aiogram-dialog" },
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
{ name = "dishka" },
|
{ name = "dishka" },
|
||||||
|
{ name = "openpyxl" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -265,6 +266,7 @@ requires-dist = [
|
|||||||
{ name = "aiogram-dialog", specifier = ">=2.5.0" },
|
{ name = "aiogram-dialog", specifier = ">=2.5.0" },
|
||||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||||
{ name = "dishka", specifier = ">=1.8.0" },
|
{ name = "dishka", specifier = ">=1.8.0" },
|
||||||
|
{ name = "openpyxl", specifier = ">=3.1.5" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -275,6 +277,15 @@ dev = [
|
|||||||
{ name = "watchfiles", specifier = ">=1.1.1" },
|
{ name = "watchfiles", specifier = ">=1.1.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "et-xmlfile"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
@@ -483,6 +494,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openpyxl"
|
||||||
|
version = "3.1.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "et-xmlfile" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ovld"
|
name = "ovld"
|
||||||
version = "0.5.14"
|
version = "0.5.14"
|
||||||
|
|||||||
Reference in New Issue
Block a user