diff --git a/assets/fonts/Arial Nova Cond.ttf b/assets/fonts/Arial Nova Cond.ttf new file mode 100644 index 0000000..589b2aa Binary files /dev/null and b/assets/fonts/Arial Nova Cond.ttf differ diff --git a/assets/fonts/DejaVuSans.ttf b/assets/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..e5f7eec Binary files /dev/null and b/assets/fonts/DejaVuSans.ttf differ diff --git a/modules/fulfillment_base/barcodes_pdf_gen/__init__.py b/modules/fulfillment_base/barcodes_pdf_gen/__init__.py new file mode 100644 index 0000000..870ea38 --- /dev/null +++ b/modules/fulfillment_base/barcodes_pdf_gen/__init__.py @@ -0,0 +1,2 @@ +from .barcode_pdf_generator import BarcodePdfGenerator as BarcodePdfGenerator +from .types import BarcodeData as BarcodeData diff --git a/modules/fulfillment_base/barcodes_pdf_gen/barcode_pdf_generator.py b/modules/fulfillment_base/barcodes_pdf_gen/barcode_pdf_generator.py new file mode 100644 index 0000000..e56a150 --- /dev/null +++ b/modules/fulfillment_base/barcodes_pdf_gen/barcode_pdf_generator.py @@ -0,0 +1,161 @@ +from io import BytesIO +from typing import Any, Optional + +from reportlab.graphics.barcode import code128 +from reportlab.lib.units import mm +from reportlab.platypus import Spacer, PageBreak, Paragraph + +from modules.fulfillment_base.barcodes_pdf_gen.types import * +from utils.pdf import PdfMaker, PDFGenerator + + +class BarcodePdfGenerator(PDFGenerator): + def _get_attr_by_path( + self, value: Any, path: str + ) -> Optional[str | int | float | bool]: + keys = path.split(".") + for key in keys: + try: + if isinstance(value, dict): + value = value[key] + else: + value = getattr(value, key) + except (KeyError, AttributeError, TypeError): + return None + return value + + def generate( + self, barcodes_data: list[BarcodeData | PdfBarcodeImageGenData] + ) -> BytesIO: + pdf_barcodes_gen_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData] = [] + + for barcode_data in barcodes_data: + if "barcode" not in barcode_data: + pdf_barcodes_gen_data.append(barcode_data) + continue + + attributes = {} + for attribute in barcode_data["template"].attributes: + value = self._get_attr_by_path(barcode_data["product"], attribute.key) + if not value or not value.strip(): + continue + attributes[attribute.name] = value + barcode_text = "
".join( + [f"{key}: {value}" for key, value in attributes.items()] + ) + + pdf_barcodes_gen_data.append( + { + "barcode_value": barcode_data["barcode"], + "text": barcode_text, + "num_duplicates": barcode_data["num_duplicates"], + } + ) + + return self._generate(pdf_barcodes_gen_data) + + def _generate( + self, barcodes_data: list[PdfBarcodeGenData | PdfBarcodeImageGenData] + ) -> BytesIO: + pdf_maker = PdfMaker((self.page_width, self.page_height)) + + pdf_files: list[BytesIO] = [] + + for barcode_data in barcodes_data: + if "barcode_value" in barcode_data: + pdf_files.append(self._generate_for_one_product(barcode_data)) + else: + pdf_files.append(self._generate_for_one_product_using_img(barcode_data)) + pdf_files.append(self._generate_spacers()) + + for file in pdf_files[:-1]: + pdf_maker.add_pdfs(file) + + return pdf_maker.get_bytes() + + def _generate_for_one_product(self, barcode_data: PdfBarcodeGenData) -> BytesIO: + buffer = BytesIO() + doc = self._create_doc(buffer) + + # Создаем абзац с новым стилем + paragraph = Paragraph(barcode_data["text"], self.small_style) + + # Получаем ширину и высоту абзаца + paragraph_width, paragraph_height = paragraph.wrap( + self.page_width - 2 * mm, self.page_height + ) + + # Рассчитываем доступное пространство для штрихкода + human_readable_height = 6 * mm # Высота human-readable текста + space_between_text_and_barcode = 4 * mm # Отступ между текстом и штрихкодом + barcode_height = ( + self.page_height + - paragraph_height + - human_readable_height + - space_between_text_and_barcode + - 4 * mm + ) # Учитываем поля и отступы + + # Создаем штрихкод + available_width = self.page_width - 4 * mm # Учитываем поля + + # Приблизительное количество элементов в штрихкоде Code 128 для средней длины + num_elements = 11 * len( + barcode_data["barcode_value"] + ) # Примерная оценка: 11 элементов на символ + + # Рассчитываем ширину штриха + bar_width = available_width / num_elements + barcode = code128.Code128( + barcode_data["barcode_value"], + barWidth=bar_width, + barHeight=barcode_height, + humanReadable=True, + ) + + # Добавление штрихкодов в список элементов документа + elements = [] + for _ in range(barcode_data["num_duplicates"]): + elements.append(paragraph) + elements.append( + Spacer(1, space_between_text_and_barcode) + ) # Отступ между текстом и штрихкодом + elements.append(PageBreak()) + + # Функция для отрисовки штрихкода на canvas + def add_barcode(canvas, doc): + barcode_width = barcode.width + barcode_x = (self.page_width - barcode_width) / 2 # Центрируем штрихкод + # Размещаем штрихкод снизу с учетом отступа + barcode_y = human_readable_height + 2 * mm + barcode.drawOn(canvas, barcode_x, barcode_y) + + # Создаем документ + doc.build( + elements, onFirstPage=add_barcode, onLaterPages=add_barcode + ) # Убираем последний PageBreak + + buffer.seek(0) + return buffer + + def _generate_for_one_product_using_img( + self, barcode_data: PdfBarcodeImageGenData + ) -> BytesIO: + with open(barcode_data["barcode_image_url"], "rb") as pdf_file: + pdf_bytes = pdf_file.read() + + pdf_maker = PdfMaker((self.page_width, self.page_height)) + for _ in range(barcode_data["num_duplicates"]): + pdf_maker.add_pdfs(BytesIO(pdf_bytes)) + + return pdf_maker.get_bytes() + + def _generate_spacers(self) -> BytesIO: + buffer = BytesIO() + doc = self._create_doc(buffer) + elements = [] + for _ in range(self.number_of_spacing_pages): + elements.append(PageBreak()) + doc.build(elements) + buffer.seek(0) + return buffer diff --git a/modules/fulfillment_base/barcodes_pdf_gen/types.py b/modules/fulfillment_base/barcodes_pdf_gen/types.py new file mode 100644 index 0000000..b3b3c77 --- /dev/null +++ b/modules/fulfillment_base/barcodes_pdf_gen/types.py @@ -0,0 +1,21 @@ +from typing import TypedDict + +from modules.fulfillment_base.models import BarcodeTemplate, Product + + +class BarcodeData(TypedDict): + barcode: str + template: BarcodeTemplate + product: Product + num_duplicates: int + + +class PdfBarcodeGenData(TypedDict): + barcode_value: str + text: str + num_duplicates: int + + +class PdfBarcodeImageGenData(TypedDict): + barcode_image_url: str + num_duplicates: int diff --git a/modules/fulfillment_base/repositories/barcode_template.py b/modules/fulfillment_base/repositories/barcode_template.py index 47a888f..64f7017 100644 --- a/modules/fulfillment_base/repositories/barcode_template.py +++ b/modules/fulfillment_base/repositories/barcode_template.py @@ -32,6 +32,12 @@ class BarcodeTemplateRepository( .order_by(BarcodeTemplate.id) ) + def _process_get_by_id_stmt(self, stmt: Select) -> Select: + return stmt.options( + selectinload(BarcodeTemplate.attributes), + joinedload(BarcodeTemplate.size), + ) + async def _get_size_by_id(self, size_id: int) -> Optional[BarcodeTemplateSize]: stmt = select(BarcodeTemplateSize).where(BarcodeTemplateSize.id == size_id) result = await self.session.scalars(stmt) diff --git a/modules/fulfillment_base/repositories/product.py b/modules/fulfillment_base/repositories/product.py index 1b18b2b..cf88162 100644 --- a/modules/fulfillment_base/repositories/product.py +++ b/modules/fulfillment_base/repositories/product.py @@ -1,7 +1,7 @@ from sqlalchemy import or_, delete -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import selectinload, joinedload -from modules.fulfillment_base.models import Product, ProductBarcode +from modules.fulfillment_base.models import Product, ProductBarcode, BarcodeTemplate from modules.fulfillment_base.schemas.product import ( CreateProductSchema, UpdateProductSchema, @@ -55,7 +55,13 @@ class ProductRepository( return list(result.scalars().all()), total_items def _process_get_by_id_stmt(self, stmt: Select) -> Select: - return stmt.options(selectinload(Product.barcodes)) + return stmt.options( + selectinload(Product.barcodes), + joinedload(Product.client), + joinedload(Product.barcode_template).selectinload( + BarcodeTemplate.attributes + ), + ) async def _after_create(self, product: Product, data: CreateProductSchema) -> None: new_barcodes = [ diff --git a/modules/fulfillment_base/routers/product.py b/modules/fulfillment_base/routers/product.py index 4830ed7..b60ea89 100644 --- a/modules/fulfillment_base/routers/product.py +++ b/modules/fulfillment_base/routers/product.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Path, Query from backend.dependecies import SessionDependency, PaginationDependency from modules.fulfillment_base.schemas.product import * -from modules.fulfillment_base.services import ProductService +from modules.fulfillment_base.services import ProductService, BarcodePrinterService router = APIRouter(tags=["product"]) @@ -56,3 +56,18 @@ async def delete_product( pk: int = Path(), ): return await ProductService(session).delete(pk) + + +@router.post( + "/barcode/get-pdf", + operation_id="get_product_barcode_pdf", + response_model=GetProductBarcodePdfResponse, +) +async def get_product_barcode_pdf( + request: GetProductBarcodePdfRequest, session: SessionDependency +): + service = BarcodePrinterService(session) + filename, base64_string = await service.generate_base64(request) + return GetProductBarcodePdfResponse( + base64_string=base64_string, filename=filename, mime_type="application/pdf" + ) diff --git a/modules/fulfillment_base/schemas/product.py b/modules/fulfillment_base/schemas/product.py index 476fdef..18f12da 100644 --- a/modules/fulfillment_base/schemas/product.py +++ b/modules/fulfillment_base/schemas/product.py @@ -4,7 +4,7 @@ from pydantic import field_validator from modules.fulfillment_base.models import ProductBarcode from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema -from schemas.base import BaseSchema, BaseResponse, PaginationInfoSchema +from schemas.base import BaseSchema, BaseResponse, PaginationInfoSchema, BasePdfResponse # region Entity @@ -69,6 +69,12 @@ class UpdateProductRequest(BaseSchema): entity: UpdateProductSchema +class GetProductBarcodePdfRequest(BaseSchema): + quantity: int + product_id: int + barcode: str + + # endregion # region Response @@ -91,4 +97,8 @@ class DeleteProductResponse(BaseResponse): pass +class GetProductBarcodePdfResponse(BasePdfResponse): + pass + + # endregion diff --git a/modules/fulfillment_base/services/__init__.py b/modules/fulfillment_base/services/__init__.py index 1df9aa0..e4d4210 100644 --- a/modules/fulfillment_base/services/__init__.py +++ b/modules/fulfillment_base/services/__init__.py @@ -6,3 +6,4 @@ from .service import ServiceModelService as ServiceModelService from .services_kit import ServicesKitService as ServicesKitService from .service_category import ServiceCategoryService as ServiceCategoryService from .barcode_template import BarcodeTemplateService as BarcodeTemplateService +from .barcode_printer_service import BarcodePrinterService as BarcodePrinterService diff --git a/modules/fulfillment_base/services/barcode_printer_service.py b/modules/fulfillment_base/services/barcode_printer_service.py new file mode 100644 index 0000000..5568cf3 --- /dev/null +++ b/modules/fulfillment_base/services/barcode_printer_service.py @@ -0,0 +1,41 @@ +import base64 +from io import BytesIO + +from sqlalchemy.ext.asyncio import AsyncSession + +from modules.fulfillment_base.barcodes_pdf_gen import BarcodePdfGenerator, BarcodeData +from modules.fulfillment_base.models import Product +from modules.fulfillment_base.repositories import ProductRepository +from modules.fulfillment_base.schemas.product import GetProductBarcodePdfRequest + + +class BarcodePrinterService: + session: AsyncSession + + def __init__(self, session: AsyncSession): + self.session = session + + async def generate_pdf( + self, request: GetProductBarcodePdfRequest + ) -> tuple[str, BytesIO]: + product: Product = await ProductRepository(self.session).get_by_id( + request.product_id + ) + barcode_data: BarcodeData = { + "barcode": request.barcode, + "template": product.barcode_template, + "product": product, + "num_duplicates": request.quantity, + } + filename = f"{product.id}_barcode.pdf" + + size = product.barcode_template.size + generator = BarcodePdfGenerator(size.width, size.height) + return filename, generator.generate([barcode_data]) + + async def generate_base64( + self, request: GetProductBarcodePdfRequest + ) -> tuple[str, str]: + filename, pdf_buffer = await self.generate_pdf(request) + base64_string = base64.b64encode(pdf_buffer.read()).decode("utf-8") + return filename, base64_string diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e803c8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +PyMuPDF +pdfrw +fpdf +reportlab +fastapi +SQLAlchemy +pathlib +python-dotenv +starlette +pydantic +alembic diff --git a/schemas/base.py b/schemas/base.py index 84af798..463b9bd 100644 --- a/schemas/base.py +++ b/schemas/base.py @@ -61,3 +61,9 @@ class BaseEnumSchema(BaseSchema): class BaseEnumListSchema(BaseSchema): items: list[BaseEnumSchema] + + +class BasePdfResponse(BaseSchema): + base64_string: str + filename: str + mime_type: str diff --git a/utils/pdf/__init__.py b/utils/pdf/__init__.py new file mode 100644 index 0000000..12919f7 --- /dev/null +++ b/utils/pdf/__init__.py @@ -0,0 +1,2 @@ +from .pdf_maker import PdfMaker as PdfMaker +from .pdf_generator import PDFGenerator as PDFGenerator diff --git a/utils/pdf/pdf_generator.py b/utils/pdf/pdf_generator.py new file mode 100644 index 0000000..f439cd2 --- /dev/null +++ b/utils/pdf/pdf_generator.py @@ -0,0 +1,45 @@ +import os + +from reportlab.lib.pagesizes import mm +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import SimpleDocTemplate + +from constants import APP_PATH + + +class PDFGenerator: + def __init__(self, page_width: int, page_height: int): + ASSETS_FOLDER = os.path.join(APP_PATH, "assets") + FONTS_FOLDER = os.path.join(ASSETS_FOLDER, "fonts") + FONT_FILE_PATH = os.path.join(FONTS_FOLDER, "DejaVuSans.ttf") + self.page_width = page_width * mm + self.page_height = page_height * mm + self.number_of_spacing_pages = 1 + + pdfmetrics.registerFont(TTFont("DejaVuSans", FONT_FILE_PATH)) + + # Get standard styles and create a new style with a smaller font size + styles = getSampleStyleSheet() + self.small_style = ParagraphStyle( + "Small", + parent=styles["Normal"], + fontName="DejaVuSans", # Specify the new font + fontSize=6, + leading=7, + spaceAfter=2, + leftIndent=2, + rightIndent=2, + ) + + # Создание документа с указанным размером страницы + def _create_doc(self, buffer): + return SimpleDocTemplate( + buffer, + pagesize=(self.page_width, self.page_height), + rightMargin=1 * mm, + leftMargin=1 * mm, + topMargin=1 * mm, + bottomMargin=1 * mm, + ) diff --git a/utils/pdf/pdf_maker.py b/utils/pdf/pdf_maker.py new file mode 100644 index 0000000..c0538b5 --- /dev/null +++ b/utils/pdf/pdf_maker.py @@ -0,0 +1,81 @@ +from io import BytesIO + +import fitz +import pdfrw +from fpdf import FPDF + + +class PdfMaker: + def __init__(self, size: tuple): + self.size = size + + self.writer = pdfrw.PdfWriter() + + def clear(self): + del self.writer + self.writer = pdfrw.PdfWriter() + + def add_image(self, image_data): + fpdf = FPDF(format=self.size, unit="pt") + width, height = self.size + fpdf.add_page() + fpdf.image(image_data, 0, 0, width, height) + fpdf_reader: pdfrw.PdfReader = pdfrw.PdfReader(fdata=bytes(fpdf.output())) + self.writer.addpage(fpdf_reader.getPage(0)) + + def add_pdf(self, pdf_data: BytesIO): + pdf_reader = pdfrw.PdfReader(fdata=bytes(pdf_data.read())) + self.writer.addpage(pdf_reader.getPage(0)) + + def add_pdfs(self, pdf_data: BytesIO): + pdf_reader = pdfrw.PdfReader(fdata=bytes(pdf_data.read())) + self.writer.addpages(pdf_reader.readpages(pdf_reader.Root)) + + def get_bytes(self): + result_io = BytesIO() + self.writer.write(result_io) + result_io.seek(0) + return result_io + + @staticmethod + def _get_target_rect(page: fitz.Page, target_ratio: float) -> fitz.Rect: + original_width, original_height = page.rect.width, page.rect.height + + if original_width / original_height > target_ratio: + # Image is wider than target aspect ratio + new_width = original_width + new_height = int(original_width / target_ratio) + else: + # Image is taller than target aspect ratio + new_height = original_height + new_width = int(new_height * target_ratio) + + return fitz.Rect(0, 0, new_width, new_height) + + @staticmethod + def resize_pdf_with_reportlab(input_pdf_bytesio: BytesIO) -> BytesIO: + output_pdf = BytesIO() + + pdf_document = fitz.open(stream=input_pdf_bytesio.getvalue(), filetype="pdf") + + if len(pdf_document) != 1: + raise Exception("Ошибка. В документе должна быть одна страница.") + + page = pdf_document[0] + target_ratio = 29 / 20 + actual_ratio = page.rect.width / page.rect.height + + if abs(actual_ratio - target_ratio) < 0.1: + return input_pdf_bytesio + + rect = PdfMaker._get_target_rect(page, target_ratio) + page.set_mediabox(rect) + page.set_cropbox(rect) + page.set_bleedbox(rect) + page.set_trimbox(rect) + + pdf_document.save(output_pdf) + pdf_document.close() + + output_pdf.seek(0) + return output_pdf