feat: barcode printing

This commit is contained in:
2025-10-10 23:00:39 +04:00
parent 6b0f8a1aa5
commit 4c871e1e1b
16 changed files with 413 additions and 5 deletions

Binary file not shown.

BIN
assets/fonts/DejaVuSans.ttf Normal file

Binary file not shown.

View File

@ -0,0 +1,2 @@
from .barcode_pdf_generator import BarcodePdfGenerator as BarcodePdfGenerator
from .types import BarcodeData as BarcodeData

View File

@ -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 = "<br/>".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

View File

@ -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

View File

@ -32,6 +32,12 @@ class BarcodeTemplateRepository(
.order_by(BarcodeTemplate.id) .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]: async def _get_size_by_id(self, size_id: int) -> Optional[BarcodeTemplateSize]:
stmt = select(BarcodeTemplateSize).where(BarcodeTemplateSize.id == size_id) stmt = select(BarcodeTemplateSize).where(BarcodeTemplateSize.id == size_id)
result = await self.session.scalars(stmt) result = await self.session.scalars(stmt)

View File

@ -1,7 +1,7 @@
from sqlalchemy import or_, delete 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 ( from modules.fulfillment_base.schemas.product import (
CreateProductSchema, CreateProductSchema,
UpdateProductSchema, UpdateProductSchema,
@ -55,7 +55,13 @@ class ProductRepository(
return list(result.scalars().all()), total_items return list(result.scalars().all()), total_items
def _process_get_by_id_stmt(self, stmt: Select) -> Select: 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: async def _after_create(self, product: Product, data: CreateProductSchema) -> None:
new_barcodes = [ new_barcodes = [

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Path, Query
from backend.dependecies import SessionDependency, PaginationDependency from backend.dependecies import SessionDependency, PaginationDependency
from modules.fulfillment_base.schemas.product import * 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"]) router = APIRouter(tags=["product"])
@ -56,3 +56,18 @@ async def delete_product(
pk: int = Path(), pk: int = Path(),
): ):
return await ProductService(session).delete(pk) 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"
)

View File

@ -4,7 +4,7 @@ from pydantic import field_validator
from modules.fulfillment_base.models import ProductBarcode from modules.fulfillment_base.models import ProductBarcode
from modules.fulfillment_base.schemas.barcode_template import BarcodeTemplateSchema 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 # region Entity
@ -69,6 +69,12 @@ class UpdateProductRequest(BaseSchema):
entity: UpdateProductSchema entity: UpdateProductSchema
class GetProductBarcodePdfRequest(BaseSchema):
quantity: int
product_id: int
barcode: str
# endregion # endregion
# region Response # region Response
@ -91,4 +97,8 @@ class DeleteProductResponse(BaseResponse):
pass pass
class GetProductBarcodePdfResponse(BasePdfResponse):
pass
# endregion # endregion

View File

@ -6,3 +6,4 @@ from .service import ServiceModelService as ServiceModelService
from .services_kit import ServicesKitService as ServicesKitService from .services_kit import ServicesKitService as ServicesKitService
from .service_category import ServiceCategoryService as ServiceCategoryService from .service_category import ServiceCategoryService as ServiceCategoryService
from .barcode_template import BarcodeTemplateService as BarcodeTemplateService from .barcode_template import BarcodeTemplateService as BarcodeTemplateService
from .barcode_printer_service import BarcodePrinterService as BarcodePrinterService

View File

@ -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

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
PyMuPDF
pdfrw
fpdf
reportlab
fastapi
SQLAlchemy
pathlib
python-dotenv
starlette
pydantic
alembic

View File

@ -61,3 +61,9 @@ class BaseEnumSchema(BaseSchema):
class BaseEnumListSchema(BaseSchema): class BaseEnumListSchema(BaseSchema):
items: list[BaseEnumSchema] items: list[BaseEnumSchema]
class BasePdfResponse(BaseSchema):
base64_string: str
filename: str
mime_type: str

2
utils/pdf/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .pdf_maker import PdfMaker as PdfMaker
from .pdf_generator import PDFGenerator as PDFGenerator

View File

@ -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,
)

81
utils/pdf/pdf_maker.py Normal file
View File

@ -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