From ca6e7d5b37c381b170a3799b27d7ed09e9fff28a Mon Sep 17 00:00:00 2001 From: AlexSserb Date: Wed, 2 Oct 2024 02:40:54 +0400 Subject: [PATCH] feat: executors and grouping by article in deal document --- constants.py | 1 + generators/__init__.py | 0 generators/deal_pdf_generator/__init__.py | 0 generators/deal_pdf_generator/deal_data.py | 20 ++ generators/deal_pdf_generator/generator.py | 102 ++++++++ routers/deal.py | 3 +- schemas/deal.py | 5 +- services/deal.py | 70 +----- static/css/deal.css | 20 +- .../documents/deal/deal-product-services.html | 226 ++++++++++++------ templates/documents/deal/deal-services.html | 26 +- templates/documents/deal/deal.html | 4 +- 12 files changed, 308 insertions(+), 169 deletions(-) create mode 100644 generators/__init__.py create mode 100644 generators/deal_pdf_generator/__init__.py create mode 100644 generators/deal_pdf_generator/deal_data.py create mode 100644 generators/deal_pdf_generator/generator.py diff --git a/constants.py b/constants.py index 16cde0a..8b69779 100644 --- a/constants.py +++ b/constants.py @@ -10,6 +10,7 @@ from utils.code128 import encode128 ENV = Environment(loader=FileSystemLoader(Path("templates") / Path("documents"))) ENV.globals['now'] = datetime.now ENV.globals['encode128'] = encode128 +ENV.globals['format_number'] = lambda x: '{:,}'.format(x).replace(',', ' ') APP_PATH = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(__file__) diff --git a/generators/__init__.py b/generators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/generators/deal_pdf_generator/__init__.py b/generators/deal_pdf_generator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/generators/deal_pdf_generator/deal_data.py b/generators/deal_pdf_generator/deal_data.py new file mode 100644 index 0000000..c87e8ad --- /dev/null +++ b/generators/deal_pdf_generator/deal_data.py @@ -0,0 +1,20 @@ +from typing import TypedDict, List, Dict, Tuple, Optional + +from models import DealProduct, Deal, DealStatusHistory + + +class DocumentDealProductData(TypedDict): + deal_products: List[DealProduct] + total_one_product: int + quantity: int + additional_info: Optional[str] + + +class DocumentDealData(TypedDict): + deal: Deal + general_services_total: int + products: Dict[str, DocumentDealProductData] + current_status_str: str + last_status: DealStatusHistory + product_images: Tuple[str] + diff --git a/generators/deal_pdf_generator/generator.py b/generators/deal_pdf_generator/generator.py new file mode 100644 index 0000000..518493f --- /dev/null +++ b/generators/deal_pdf_generator/generator.py @@ -0,0 +1,102 @@ +from io import BytesIO +from typing import List, Dict, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload, joinedload +from weasyprint import HTML, CSS + +from constants import DEAL_STATUS_STR, ENV, APP_PATH +from generators.deal_pdf_generator.deal_data import DocumentDealProductData +from models import Deal, DealProduct, DealService as DealServiceModel, Product +from utils.images_fetcher import fetch_images + + +class DealPdfGenerator: + def __init__(self, session: AsyncSession): + self._session = session + + @staticmethod + async def _get_product_services_totals(deal: Deal) -> List[Dict[str, int]]: + totals: List[Dict[str, int]] = [] + + for product in deal.products: + total_one_product = sum((service.price for service in product.services)) + total = total_one_product * product.quantity + totals.append({"total_one_product": total_one_product, "total": total}) + + return totals + + @staticmethod + async def _group_deal_products_by_products(deal_products: List[DealProduct]) -> Dict[str, DocumentDealProductData]: + products: Dict[str, DocumentDealProductData] = {} + additional_info: Optional[str] + + for deal_product in deal_products: + # Для группировки по артикулу и услугам + key = f"{deal_product.product.article} - " + ",".join( + str(service.service_id) for service in deal_product.services + ) + + if key not in products: + total_one_product = sum(service.price for service in deal_product.services) + products[key] = { + "deal_products": [deal_product], + "total_one_product": total_one_product, + "quantity": deal_product.quantity, + "additional_info": deal_product.product.additional_info, + } + else: + products[key]["deal_products"].append(deal_product) + products[key]["quantity"] += deal_product.quantity + if not products[key]["additional_info"]: + products[key]["additional_info"] = deal_product.product.additional_info + + return products + + async def _create_detailed_deal_document_html(self, deal_id: int): + deal: Deal | None = await self._session.scalar( + select(Deal) + .where(Deal.id == deal_id) + .options( + selectinload(Deal.products).selectinload(DealProduct.services), + selectinload(Deal.products).selectinload(DealProduct.product).selectinload(Product.barcodes), + selectinload(Deal.services).selectinload(DealServiceModel.service), + selectinload(Deal.status_history), + joinedload(Deal.client), + joinedload(Deal.shipping_warehouse), + ) + ) + + if not deal: + return "" + + products = await self._group_deal_products_by_products(deal.products) + + product_urls: List[Optional[str]] = [] + for product in products.values(): + if len(product["deal_products"][0].product.images) > 0: + product_urls.append(product["deal_products"][0].product.images[0].image_url) + else: + product_urls.append(None) + product_images = await fetch_images(product_urls) + + document_deal_data = { + "deal": deal, + "general_services_total": sum((service.price * service.quantity for service in deal.services)), + "products": products, + "current_status_str": DEAL_STATUS_STR[deal.current_status], + "last_status": max(deal.status_history, key=lambda status: status.changed_at), + "product_images": product_images, + } + + template = ENV.get_template("deal/deal.html") + + result = template.render({"data": document_deal_data, "sign_place_text": "_" * 22}) + return result + + async def create_detailed_deal_document_pdf(self, deal_id) -> BytesIO: + doc = await self._create_detailed_deal_document_html(deal_id) + pdf_file = BytesIO() + HTML(string=doc).write_pdf(pdf_file, stylesheets=[CSS(APP_PATH + '/static/css/deal.css')]) + return pdf_file diff --git a/routers/deal.py b/routers/deal.py index 9de0c0d..a4df919 100644 --- a/routers/deal.py +++ b/routers/deal.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.dependecies import SessionDependency, CurrentUserDependency from backend.session import get_session +from generators.deal_pdf_generator.generator import DealPdfGenerator from models import User from schemas.barcode import GetDealProductsBarcodesPdfRequest, GetDealProductsBarcodesPdfResponse from schemas.deal import * @@ -202,7 +203,7 @@ async def get_detailed_deal_document( deal_id: int, session: Annotated[AsyncSession, Depends(get_session)], ): - pdf_file: BytesIO = await DealService(session).create_detailed_deal_document_pdf(deal_id) + pdf_file: BytesIO = await DealPdfGenerator(session).create_detailed_deal_document_pdf(deal_id) return Response(pdf_file.getvalue(), media_type='application/pdf') diff --git a/schemas/deal.py b/schemas/deal.py index cbe706c..df6e7c3 100644 --- a/schemas/deal.py +++ b/schemas/deal.py @@ -1,9 +1,10 @@ import datetime -from typing import List, Optional, Union +from collections import defaultdict +from typing import List, Optional, Union, Dict, Tuple, TypedDict from pydantic import constr, field_validator -from models import ServiceCategoryPrice, ServicePriceCategory +from models import ServiceCategoryPrice, ServicePriceCategory, Deal, Product, DealProduct, DealStatusHistory from schemas.base import BaseSchema, OkMessageSchema from schemas.billing import DealBillRequestSchema from schemas.client import ClientSchema diff --git a/services/deal.py b/services/deal.py index b398ed1..0867ace 100644 --- a/services/deal.py +++ b/services/deal.py @@ -1,18 +1,12 @@ -from io import BytesIO -from typing import Dict - import lexorank from fastapi import HTTPException from sqlalchemy import select, func, update, delete, insert from sqlalchemy.orm import joinedload, selectinload from starlette import status -from weasyprint import HTML, CSS -import constants import models.deal import models.secondary -from constants import ENV, DEAL_STATUS_STR -from models import User, Service, Client, DealProduct, DealService as DealServiceModel, Product +from models import User, Service, Client from models.deal import * from schemas.client import ClientDetailsSchema from schemas.deal import * @@ -21,7 +15,6 @@ from services.base import BaseService from services.client import ClientService from services.service import ServiceService from services.shipping_warehouse import ShippingWarehouseService -from utils.images_fetcher import fetch_images class DealService(BaseService): @@ -1008,64 +1001,3 @@ class DealService(BaseService): except Exception as e: await self.session.rollback() return DealCompleteResponse(ok=False, message=str(e)) - - # region Deal documents - - @staticmethod - async def _get_product_services_totals(deal: Deal) -> List[Dict[str, int]]: - totals: List[Dict[str, int]] = [] - - for product in deal.products: - total_one_product = sum((service.price for service in product.services)) - total = total_one_product * product.quantity - totals.append({"total_one_product": total_one_product, "total": total}) - - return totals - - async def _create_detailed_deal_document_html(self, deal_id: int): - deal: Deal | None = await self.session.scalar( - select(Deal) - .where(Deal.id == deal_id) - .options( - selectinload(Deal.products).selectinload(DealProduct.services), - selectinload(Deal.products).selectinload(DealProduct.product).selectinload(Product.barcodes), - selectinload(Deal.services).selectinload(DealServiceModel.service), - selectinload(Deal.status_history), - joinedload(Deal.client), - joinedload(Deal.shipping_warehouse), - ) - ) - - if not deal: - return "" - - last_status: DealStatusHistory = max(deal.status_history, key=lambda status: status.changed_at) - general_services_total = sum((service.price * service.quantity for service in deal.services)) - product_services_totals: List[Dict[str, int]] = await self._get_product_services_totals(deal) - template = ENV.get_template("deal/deal.html") - - product_urls: List[Optional[str]] = [] - for product in deal.products: - if len(product.product.images) > 0: - product_urls.append(product.product.images[0].image_url) - else: - product_urls.append(None) - product_images = await fetch_images(product_urls) - - result = template.render({ - "general_services_total": general_services_total, - "product_services_totals": product_services_totals, - "deal": deal, - "current_status_str": DEAL_STATUS_STR[deal.current_status], - "last_status": last_status, - "product_images": product_images, - }) - return result - - async def create_detailed_deal_document_pdf(self, deal_id) -> BytesIO: - doc = await self._create_detailed_deal_document_html(deal_id) - pdf_file = BytesIO() - HTML(string=doc).write_pdf(pdf_file, stylesheets=[CSS(constants.APP_PATH + '/static/css/deal.css')]) - return pdf_file - - # endregion diff --git a/static/css/deal.css b/static/css/deal.css index 687681b..265b129 100644 --- a/static/css/deal.css +++ b/static/css/deal.css @@ -18,6 +18,10 @@ hr { margin-right: -100px; } +.sign-place { + margin-top: 25px; +} + .doc-container { margin: -30px 0; padding: 0 15px; @@ -42,7 +46,7 @@ hr { .small-text { font-size: 12px; - margin-bottom: 4px; + margin-bottom: 3px; } .medium-text { @@ -67,7 +71,7 @@ hr { } .product-data > * { - width: 200px; + width: 170px; padding-right: 170px; } @@ -152,8 +156,16 @@ table tfoot { border-radius: 5px; } +.barcode-container-table { + margin: 10px 0 -15px -25px; + display: flex; + white-space: nowrap; + flex-direction: column; + align-items: center; +} + .barcode-container { - margin: 15px 0 -5px 355px; + margin: 15px 0 -5px 375px; display: flex; white-space: nowrap; flex-direction: column; @@ -162,7 +174,7 @@ table tfoot { .barcode { font-family: "Libre Barcode 128", cursive; - font-size: 30pt; + font-size: 25pt; transform: scale(1, 1.5); } diff --git a/templates/documents/deal/deal-product-services.html b/templates/documents/deal/deal-product-services.html index 3b47dd8..e595f09 100644 --- a/templates/documents/deal/deal-product-services.html +++ b/templates/documents/deal/deal-product-services.html @@ -1,86 +1,156 @@ -
-
-
-
-
- {% if product.product.images|length > 0 %} - Фото товара - {% else %} - Фото товара - {% endif %} -
-
-
- {{ index }}. {{ product.product.name }} +{% with common_deal_product=product_data.deal_products[0], common_product=product_data.deal_products[0].product %} +
+ +
+
+
+
+ {% if common_product.images|length > 0 %} + Фото товара + {% else %} + Фото товара + {% endif %}
-
-
Артикул: {{ product.product.article }}
- {% if product.product.brand %} -
Бренд: {{ product.product.brand }}
- {% endif %} - {% if product.product.color %} -
Цвет: {{ product.product.color }}
- {% endif %} - {% if product.product.size %} -
Размер: {{ product.product.size }}
- {% endif %} - {% if product.quantity %} -
Количество: {{ product.quantity }}
- {% endif %} - {% if product.product.additional_info %} -
- Доп. информация: {{ product.product.additional_info }} +
+
+ {{ index }}. {{ common_product.name }} +
+
+
+
Артикул: {{ common_product.article }}
+ {% if common_product.brand %} +
Бренд: {{ common_product.brand }}
+ {% endif %} + {% if common_product.color %} +
Цвет: {{ common_product.color }}
+ {% endif %} + {% if product_data.deal_products|length > 1 %} + {% if common_deal_product.quantity %} +
Суммарное количество: {{ product_data.quantity }}
+ {% endif %} + {% if product_data.additional_info %} +
+ {{ product_data.additional_info }} +
+ {% endif %} + {% else %} + {% if common_product.size %} +
Размер: {{ common_product.size }}
+ {% endif %} + {% if common_deal_product.quantity %} +
Количество: {{ common_deal_product.quantity }}
+ {% endif %} + {% endif %}
- {% endif %} +
+
Исполнители:
+
{{ sign_place_text }}
+
{{ sign_place_text }}
+
{{ sign_place_text }}
+
+
-
-
-
-
- {% if product.product.barcodes|length > 0 %} -
- {{ encode128(product.product.barcodes[0].barcode, "A") }} +
+ {% if product_data.deal_products|length > 1 %} +
+ {% else %} +
+
+ {% if product_data.deal_products[0].product.barcodes|length > 0 %} +
+ {{ encode128(product_data.deal_products[0].product.barcodes[0].barcode, "A") }} +
+
+ {{ product_data.deal_products[0].product.barcodes[0].barcode }} +
+ {% else %} +
+ {% endif %}
-
- {{ product.product.barcodes[0].barcode }} -
- {% else %} -
- {% endif %} -
+
+ {% endif %}
-
- {% if product.services|length > 0 %} - - - - - - - - - - - - {% for service in product.services %} + + + {% if common_deal_product.services|length > 0 %} +
Наименование услугиЦенаСумма
+ - - - + + + - {% endfor %} - -
{{ service.service.name }}{{ '{:,}'.format(product.quantity) }} Р{{ '{:,}'.format(service.price * product.quantity).replace(',', ' ') }} РНаименование услугиЦенаСумма
-
- Итого: {{ product_services_totals[loop.index0].total }} Р, за - единицу: {{ product_services_totals[loop.index0].total_one_product }} Р -
- {% else %} -
- {% endif %} -
+ + + + + {% for service in common_deal_product.services %} + + {{ service.service.name }} + {{ format_number(service.price) }} Р + {{ format_number(service.price * product_data.quantity) }} Р + + {% endfor %} + + +
+ Итого: {{ product_data.total_one_product * product_data.quantity }} Р, за + единицу: {{ product_data.total_one_product }} Р +
+ {% else %} +
+ {% endif %} + + + {% if product_data.deal_products|length > 1 %} + + + + + + + + + + + + + {% for deal_product in product_data.deal_products %} + + + + + + + {% endfor %} + +
ШтрихкодРазмерКол-воКороба
+
+ {% if deal_product.product.barcodes|length > 0 %} +
+ {{ encode128(deal_product.product.barcodes[0].barcode, "A") }} +
+
+ {{ deal_product.product.barcodes[0].barcode }} +
+ {% else %} +
+ {% endif %} +
+
+ {% if deal_product.product.size %} + {{ deal_product.product.size }} + {% else %} + - + {% endif %} + {{ deal_product.quantity }}
+
+ {% endif %} + +
+{% endwith %} diff --git a/templates/documents/deal/deal-services.html b/templates/documents/deal/deal-services.html index 78c3ab8..d0e83ca 100644 --- a/templates/documents/deal/deal-services.html +++ b/templates/documents/deal/deal-services.html @@ -1,7 +1,7 @@
- Название сделки: {{ deal.name }} + Название сделки: {{ data.deal.name }}

@@ -9,29 +9,29 @@ Дата создания: {{ now().strftime("%d.%m.%Y, %H:%M") }}
- Текущий статус: {{ current_status_str }} + Текущий статус: {{ data.current_status_str }}
- {% if deal.comment %} - Комментарий к сделке: {{ deal.comment }} + {% if data.deal.comment %} + Комментарий к сделке: {{ data.deal.comment }} {% endif %}

- Клиент: {{ deal.client.name }} + Клиент: {{ data.deal.client.name }}
- Дата отгрузки: {{ last_status.next_status_deadline.strftime("%d.%m.%Y") }} + Дата отгрузки: {{ data.last_status.next_status_deadline.strftime("%d.%m.%Y") }}
-{% if deal.base_marketplace.name %} -
Маркетплейс: {{ deal.base_marketplace.name }}
+{% if data.deal.base_marketplace.name %} +
Маркетплейс: {{ data.deal.base_marketplace.name }}
{% endif %} -{% if deal.shipping_warehouse.name %} -
Склад отгрузки: {{ deal.shipping_warehouse.name }}
+{% if data.deal.shipping_warehouse.name %} +
Склад отгрузки: {{ data.deal.shipping_warehouse.name }}
{% endif %} -{% if deal.services|length > 0 %} +{% if data.deal.services|length > 0 %} @@ -43,7 +43,7 @@ - {% for service in deal.services %} + {% for service in data.deal.services %} @@ -53,7 +53,7 @@
{{ service.service.name }} {{ '{:,}'.format(service.quantity) }} шт.
- Итого: {{ general_services_total }} Р + Итого: {{ data.general_services_total }} Р
{% else %}
diff --git a/templates/documents/deal/deal.html b/templates/documents/deal/deal.html index ed57687..795221e 100644 --- a/templates/documents/deal/deal.html +++ b/templates/documents/deal/deal.html @@ -29,7 +29,7 @@ font-size: 18px; } @top-right { - content: "ID:{{ deal.id }}"; + content: "ID:{{ data.deal.id }}"; border: solid black 1px; border-radius: 6px; padding: 12px 2px; @@ -45,7 +45,7 @@
{% include "deal/deal-services.html" %} - {% for product in deal.products %} + {% for product_data in data.products.values() %} {% with index = loop.index %} {% include "deal/deal-product-services.html" %} {% endwith %}