diff --git a/generators/services_excel_generator/__init__.py b/generators/services_excel_generator/__init__.py new file mode 100644 index 0000000..d840b3a --- /dev/null +++ b/generators/services_excel_generator/__init__.py @@ -0,0 +1 @@ +from .core import ServiceExcelExporter diff --git a/generators/services_excel_generator/core.py b/generators/services_excel_generator/core.py new file mode 100644 index 0000000..5f79ea9 --- /dev/null +++ b/generators/services_excel_generator/core.py @@ -0,0 +1,183 @@ +import time +from collections import defaultdict +from pathlib import Path + +import openpyxl +import openpyxl.styles +from six import BytesIO +from sqlalchemy import select +from sqlalchemy.orm import joinedload + +from constants import APP_PATH +from enums.service import ServiceType +from models import Service, ServiceCategory + + +class ServiceExcelExporter: + SERVICE_TYPE_FONT = openpyxl.styles.Font(bold=True, size=14, name='Calibri') + SERVICE_TYPE_FILL = openpyxl.styles.PatternFill(start_color='0000FF', end_color='0000FF', fill_type='solid') + SERVICE_TYPE_ALIGNMENT = openpyxl.styles.Alignment(horizontal='center') + SERVICE_TYPE_BORDER = openpyxl.styles.Border( + left=openpyxl.styles.Side(style='medium'), + right=openpyxl.styles.Side(style='medium'), + top=openpyxl.styles.Side(style='medium'), + bottom=openpyxl.styles.Side(style='medium') + ) + + CATEGORY_FONT = openpyxl.styles.Font(bold=True, size=12, name='Calibri') + CATEGORY_FILL = openpyxl.styles.PatternFill(start_color='DBEEF4', end_color='DBEEF4', fill_type='solid') + CATEGORY_ALIGNMENT = openpyxl.styles.Alignment(horizontal='center') + CATEGORY_BORDER = SERVICE_TYPE_BORDER # Same as service type border + + EVEN_ROW_FILL = openpyxl.styles.PatternFill(start_color='EBF1DE', end_color='EBF1DE', fill_type='solid') + + def __init__(self, session): + self.session = session + self.template_path = Path(APP_PATH) / 'static' / 'excel' / 'services.xlsx' + self.workbook = None + self.worksheet = None + self.start_row = 12 + self.name_column = 'A' + self.price_column = 'B' + self.categories_dict = {} + self.service_type_dict = { + ServiceType.DEAL_SERVICE: 'Общие услуги', + ServiceType.PRODUCT_SERVICE: 'Услуги фулфилмента' + } + + async def get_services(self): + """Fetch and organize services from the database.""" + services = (await self.session.scalars( + select(Service) + .options(joinedload(Service.category)) + .filter(Service.is_deleted == False) + .order_by(Service.rank) + )).all() + + intermediate_result = defaultdict(lambda: defaultdict(list)) + for service in services: + intermediate_result[service.service_type][service.category_id].append(service) + + final_result = defaultdict(dict) + for service_type, categories_dict in intermediate_result.items(): + categories = {service.category for services in categories_dict.values() for service in services} + + def category_sort_key(category): + return category.deal_service_rank if service_type == ServiceType.DEAL_SERVICE else category.product_service_rank + + sorted_categories = sorted(categories, key=category_sort_key) + sorted_categories_dict = {category.id: categories_dict[category.id] for category in sorted_categories} + final_result[service_type] = sorted_categories_dict + final_final_result = {} + for service_type in [ServiceType.DEAL_SERVICE, ServiceType.PRODUCT_SERVICE]: + final_final_result[service_type] = final_result[service_type] + return dict(final_final_result) + + async def get_categories(self): + """Fetch categories from the database.""" + categories = (await self.session.scalars( + select(ServiceCategory) + .filter(ServiceCategory.is_deleted == False) + )).all() + self.categories_dict = {category.id: category for category in categories} + + @staticmethod + def format_ruble_number(number: int): + """Format a number with spaces as thousand separators.""" + return f'{number:,}'.replace(',', ' ') + + def get_price_value(self, service: Service): + """Get the price value string for a service.""" + if service.price_ranges: + price_ranges_length = len(service.price_ranges) + if price_ranges_length == 1: + price = self.format_ruble_number(int(service.price_ranges[0].price)) + return f'{price} ₽' + + result = [] + for idx, price_range in enumerate(service.price_ranges): + price = self.format_ruble_number(int(price_range.price)) + if idx == price_ranges_length - 1: + result.append(f'от {price_range.from_quantity} шт: {price} ₽') + else: + result.append(f'{price_range.from_quantity} шт - {price_range.to_quantity} шт: {price} ₽') + return '\n'.join(result) + else: + price = self.format_ruble_number(int(service.price)) + return f'{price} ₽' + + def load_template(self): + """Load the Excel template.""" + self.workbook = openpyxl.load_workbook(self.template_path) + self.worksheet = self.workbook.active + + def format_service_type_cell(self, cell): + """Apply formatting to a service type cell.""" + cell.font = self.SERVICE_TYPE_FONT + cell.fill = self.SERVICE_TYPE_FILL + cell.alignment = self.SERVICE_TYPE_ALIGNMENT + cell.border = self.SERVICE_TYPE_BORDER + + def format_category_cell(self, cell): + """Apply formatting to a category cell.""" + cell.font = self.CATEGORY_FONT + cell.fill = self.CATEGORY_FILL + cell.alignment = self.CATEGORY_ALIGNMENT + cell.border = self.CATEGORY_BORDER + + def format_service_row(self, name_cell, price_cell, is_even_row): + """Apply formatting to a service row.""" + name_cell.alignment = openpyxl.styles.Alignment(wrap_text=True, vertical='center') + price_cell.alignment = openpyxl.styles.Alignment(wrap_text=True, horizontal='right', vertical="center") + if is_even_row: + name_cell.fill = self.EVEN_ROW_FILL + price_cell.fill = self.EVEN_ROW_FILL + + def write_service_type_row(self, service_type): + """Write a service type row to the worksheet.""" + row = self.start_row + cell = self.worksheet[f'{self.name_column}{row}'] + cell.value = self.service_type_dict[service_type] + self.worksheet.merge_cells(f'{self.name_column}{row}:{self.price_column}{row}') + self.format_service_type_cell(cell) + self.start_row += 1 + + def write_category_row(self, category_name): + """Write a category row to the worksheet.""" + row = self.start_row + cell = self.worksheet[f'{self.name_column}{row}'] + cell.value = category_name + self.worksheet.merge_cells(f'{self.name_column}{row}:{self.price_column}{row}') + self.format_category_cell(cell) + self.start_row += 1 + + def write_service_row(self, service, counter): + """Write a service row to the worksheet.""" + row = self.start_row + name_cell = self.worksheet[f'{self.name_column}{row}'] + price_cell = self.worksheet[f'{self.price_column}{row}'] + name_cell.value = service.name + price_cell.value = self.get_price_value(service) + self.format_service_row(name_cell, price_cell, counter % 2 == 0) + self.start_row += 1 + + async def generate_excel(self): + """Generate the Excel file with services and categories.""" + start = time.time() + await self.get_categories() + services = await self.get_services() + self.load_template() + + for service_type, categories in services.items(): + self.write_service_type_row(service_type) + for category_id, services_list in categories.items(): + category_name = self.categories_dict[category_id].name + self.write_category_row(category_name) + for idx, service in enumerate(services_list): + self.write_service_row(service, idx) + + result = BytesIO() + self.workbook.save(result) + result.seek(0) + print('Elapsed time:', time.time() - start) + return result diff --git a/static/excel/services.xlsx b/static/excel/services.xlsx new file mode 100644 index 0000000..1898763 Binary files /dev/null and b/static/excel/services.xlsx differ