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 %}
+
+
- | {{ service.service.name }} |
- {{ '{:,}'.format(product.quantity) }} Р |
- {{ '{:,}'.format(service.price * product.quantity).replace(',', ' ') }} Р |
+ Наименование услуги |
+ Цена |
+ Сумма |
- {% endfor %}
-
-
-
- Итого: {{ 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 %}
+
+
+
+ {% 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 }} |
+ |
+
+ {% endfor %}
+
+
+
+ {% 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 %}
| {{ service.service.name }} |
{{ '{:,}'.format(service.quantity) }} шт. |
@@ -53,7 +53,7 @@
- Итого: {{ 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 %}