Law/app/services/invoice_pdf.py
2026-03-18 20:11:07 +03:00

568 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import io
import os
import unicodedata
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
REPORTLAB_AVAILABLE = True
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader, simpleSplit
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle
except Exception:
REPORTLAB_AVAILABLE = False
_DEFAULT_ISSUER = 'ООО "Аудиторы корпоративной безопасности"'
_DEFAULT_ISSUER_ADDRESS = "г. Ярославль, ул. Богдановича, 6А"
_DEFAULT_ISSUER_PHONE = "+7 (977) 268-94-06"
_DEFAULT_ISSUER_INN = "7604226740"
_DEFAULT_ISSUER_KPP = "760401001"
_DEFAULT_ISSUER_OGRN = "1127604008806"
_DEFAULT_BANK_NAME = 'АО "АЛЬФА-БАНК"'
_DEFAULT_BANK_BIK = "044525593"
_DEFAULT_BANK_ACCOUNT = "40702810501860000582"
_DEFAULT_BANK_CORR_ACCOUNT = "30101810200000000593"
_DEFAULT_SIGNATURE_STAMP_IMAGE = "invoice_signature_stamp.png"
_DEFAULT_DIRECTOR_NAME = "Андрианова С.С."
_RU_MONTHS = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]
_FONT_CANDIDATES: list[tuple[str, str | None]] = [
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf"),
("/usr/share/fonts/truetype/freefont/FreeSans.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf"),
("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
("/System/Library/Fonts/Supplemental/Arial Unicode.ttf", None),
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
("/Library/Fonts/Arial Unicode.ttf", None),
]
_FONT_CACHE: tuple[str, str] | None = None
_UNITS_MALE = ("", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять")
_UNITS_FEMALE = ("", "одна", "две", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять")
_TEENS = (
"десять",
"одиннадцать",
"двенадцать",
"тринадцать",
"четырнадцать",
"пятнадцать",
"шестнадцать",
"семнадцать",
"восемнадцать",
"девятнадцать",
)
_TENS = ("", "", "двадцать", "тридцать", "сорок", "пятьдесят", "шестьдесят", "семьдесят", "восемьдесят", "девяносто")
_HUNDREDS = ("", "сто", "двести", "триста", "четыреста", "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот")
_SCALES = [
("", "", "", False),
("тысяча", "тысячи", "тысяч", True),
("миллион", "миллиона", "миллионов", False),
("миллиард", "миллиарда", "миллиардов", False),
]
def _ascii_text(value: Any) -> str:
text = str(value or "")
normalized = unicodedata.normalize("NFKD", text)
return normalized.encode("ascii", "ignore").decode("ascii")
def _escape_pdf_text(value: str) -> str:
return value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def _build_content_stream(lines: list[str]) -> bytes:
safe_lines = [_escape_pdf_text(_ascii_text(line)) for line in lines]
if not safe_lines:
safe_lines = ["Invoice"]
parts = ["BT", "/F1 11 Tf", "50 800 Td"]
for index, line in enumerate(safe_lines):
if index == 0:
parts.append(f"({line}) Tj")
else:
parts.append("T*")
parts.append(f"({line}) Tj")
parts.append("ET")
return "\n".join(parts).encode("latin-1", errors="ignore")
def _build_legacy_invoice_pdf_bytes(lines: list[str]) -> bytes:
stream = _build_content_stream(lines)
objects = [
b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj\n",
b"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n",
f"5 0 obj << /Length {len(stream)} >> stream\n".encode("latin-1") + stream + b"\nendstream endobj\n",
]
body = b"%PDF-1.4\n"
offsets = [0]
for obj in objects:
offsets.append(len(body))
body += obj
xref_offset = len(body)
body += f"xref\n0 {len(objects)+1}\n".encode("latin-1")
body += b"0000000000 65535 f \n"
for offset in offsets[1:]:
body += f"{offset:010d} 00000 n \n".encode("latin-1")
body += f"trailer << /Size {len(objects)+1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("latin-1")
return body
def _first_non_empty(source: dict[str, Any], *keys: str, default: str = "") -> str:
for key in keys:
value = source.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return default
def _format_amount(value: float) -> str:
amount = Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{amount:.2f}"
def _format_amount_ru(value: float) -> str:
amount = Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
integer_part = int(amount)
fraction = int((amount - Decimal(integer_part)) * 100)
grouped = f"{integer_part:,}".replace(",", " ")
if fraction == 0:
return grouped
return f"{grouped},{fraction:02d}"
def _plural_ru(value: int, forms: tuple[str, str, str]) -> str:
n = abs(int(value)) % 100
if 11 <= n <= 19:
return forms[2]
n = n % 10
if n == 1:
return forms[0]
if 2 <= n <= 4:
return forms[1]
return forms[2]
def _triplet_to_words(value: int, *, female: bool) -> list[str]:
n = int(value) % 1000
if n == 0:
return []
words: list[str] = []
words.append(_HUNDREDS[n // 100])
n = n % 100
if 10 <= n <= 19:
words.append(_TEENS[n - 10])
else:
words.append(_TENS[n // 10])
unit_map = _UNITS_FEMALE if female else _UNITS_MALE
words.append(unit_map[n % 10])
return [word for word in words if word]
def _integer_to_words_ru(value: int) -> str:
number = int(value)
if number == 0:
return "ноль"
parts: list[str] = []
scale_index = 0
while number > 0:
triplet = number % 1000
if triplet:
one, two, five, female = _SCALES[min(scale_index, len(_SCALES) - 1)]
segment = _triplet_to_words(triplet, female=female)
if scale_index > 0:
segment.append(_plural_ru(triplet, (one, two, five)))
parts.append(" ".join(segment))
number //= 1000
scale_index += 1
return " ".join(reversed(parts)).strip()
def _amount_words_ru(amount: float) -> str:
dec = Decimal(str(amount or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
rub = int(dec)
kop = int((dec - Decimal(rub)) * 100)
words = _integer_to_words_ru(rub)
rub_label = _plural_ru(rub, ("рубль", "рубля", "рублей"))
kop_label = _plural_ru(kop, ("копейка", "копейки", "копеек"))
return f"{words} {rub_label} {kop:02d} {kop_label}".strip()
def _capitalize_first(text: str) -> str:
value = str(text or "").strip()
if not value:
return ""
return value[0].upper() + value[1:]
def _format_invoice_date(value: datetime | None) -> str:
dt = value or datetime.now()
month = _RU_MONTHS[max(0, min(11, dt.month - 1))]
return f"{dt.day:02d} {month} {dt.year} г."
def _resolve_reportlab_fonts() -> tuple[str, str]:
global _FONT_CACHE
if _FONT_CACHE is not None:
return _FONT_CACHE
regular_name = "Helvetica"
bold_name = "Helvetica-Bold"
for regular_path, bold_path in _FONT_CANDIDATES:
if not os.path.exists(regular_path):
continue
try:
regular_name = "InvoiceSans"
pdfmetrics.registerFont(TTFont(regular_name, regular_path))
if bold_path and os.path.exists(bold_path):
bold_name = "InvoiceSansBold"
pdfmetrics.registerFont(TTFont(bold_name, bold_path))
else:
bold_name = regular_name
_FONT_CACHE = (regular_name, bold_name)
return _FONT_CACHE
except Exception:
regular_name = "Helvetica"
bold_name = "Helvetica-Bold"
_FONT_CACHE = (regular_name, bold_name)
return _FONT_CACHE
def _resolve_signature_stamp_image_path(req: dict[str, Any]) -> str:
provided = _first_non_empty(
req,
"signature_stamp_image_path",
"signature_stamp_path",
"signature_image_path",
default="",
)
local_default = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", _DEFAULT_SIGNATURE_STAMP_IMAGE)
candidates = [provided, local_default, f"/app/app/assets/{_DEFAULT_SIGNATURE_STAMP_IMAGE}"]
for path in candidates:
candidate = str(path or "").strip()
if candidate and os.path.exists(candidate):
return candidate
return ""
def _display_invoice_number(raw_number: str, issued_at: datetime | None) -> str:
value = str(raw_number or "").strip()
if not value:
return (issued_at or datetime.now()).strftime("%Y%m%d")
upper = value.upper()
if upper.startswith("INV-"):
tail = value[4:]
if len(tail) >= 8 and tail[:8].isdigit():
date_part = tail[:8]
remainder = tail[8:]
if not remainder:
return date_part
if remainder.startswith("-"):
suffix = remainder[1:]
if suffix.isdigit():
return f"{date_part}-{suffix}"
return date_part
return date_part
return value
def _draw_wrapped_line(pdf: Any, *, text: str, x: float, y: float, width: float, font: str, size: int, leading: float) -> float:
lines = simpleSplit(str(text or ""), font, size, width) or [""]
pdf.setFont(font, size)
cursor = y
for line in lines:
pdf.drawString(x, cursor, line)
cursor -= leading
return cursor
def _build_reportlab_invoice_pdf_bytes(
*,
invoice_number: str,
amount: float,
currency: str,
status: str,
issued_at: datetime | None,
paid_at: datetime | None,
payer_display_name: str,
request_track_number: str,
issued_by_name: str | None,
requisites: dict[str, Any] | None,
) -> bytes:
regular_font, bold_font = _resolve_reportlab_fonts()
req = dict(requisites or {})
issuer_name = _first_non_empty(req, "issuer_name", "beneficiary_name", "recipient_name", default=_DEFAULT_ISSUER)
issuer_address = _first_non_empty(req, "issuer_address", "address", default=_DEFAULT_ISSUER_ADDRESS)
issuer_phone = _first_non_empty(req, "issuer_phone", "phone", default=_DEFAULT_ISSUER_PHONE)
issuer_inn = _first_non_empty(req, "issuer_inn", "inn", default=_DEFAULT_ISSUER_INN)
issuer_kpp = _first_non_empty(req, "issuer_kpp", "kpp", default=_DEFAULT_ISSUER_KPP)
issuer_ogrn = _first_non_empty(req, "issuer_ogrn", "ogrn", default=_DEFAULT_ISSUER_OGRN)
bank_name = _first_non_empty(req, "bank_name", "bank", default=_DEFAULT_BANK_NAME)
bank_bik = _first_non_empty(req, "bank_bik", "bik", default=_DEFAULT_BANK_BIK)
bank_account = _first_non_empty(req, "bank_account", "account", default=_DEFAULT_BANK_ACCOUNT)
bank_corr_account = _first_non_empty(req, "bank_corr_account", "corr_account", default=_DEFAULT_BANK_CORR_ACCOUNT)
service_description = _first_non_empty(req, "service_description", "service", "template_rendered", default="Юридические услуги")
vat_note = _first_non_empty(req, "vat_note", default="без НДС")
director_name = _DEFAULT_DIRECTOR_NAME
signature_stamp_image_path = _resolve_signature_stamp_image_path(req)
amount_text = _format_amount_ru(amount)
amount_words = _capitalize_first(_amount_words_ru(amount))
issue_date = issued_at or datetime.now()
invoice_number_display = _display_invoice_number(invoice_number, issue_date)
issue_date_compact = issue_date.strftime("%d.%m.%Y")
buffer = io.BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
page_width, page_height = A4
left = 15 * mm
content_width = page_width - 30 * mm
cursor_y = page_height - 13 * mm
# Header block close to the supplied invoice sample.
pdf.setFillColorRGB(0.17, 0.35, 0.40)
pdf.setFont(bold_font, 18)
pdf.drawCentredString(page_width / 2, cursor_y, "АУДИТОРЫ КОРПОРАТИВНОЙ БЕЗОПАСНОСТИ")
cursor_y -= 6.5 * mm
pdf.setFillColorRGB(0, 0, 0)
pdf.setFont(bold_font, 7)
pdf.drawCentredString(page_width / 2, cursor_y, "О Б Щ Е С Т В О С О Г Р А Н И Ч Е Н Н О Й О Т В Е Т С Т В Е Н Н О С Т Ь Ю")
cursor_y -= 4.6 * mm
pdf.setFont(regular_font, 8)
pdf.drawCentredString(page_width / 2, cursor_y, "Россия, 150014, Ярославль, ул. Богдановича, 6А")
cursor_y -= 2.2 * mm
pdf.line(left, cursor_y, page_width - left, cursor_y)
cursor_y -= 6.2 * mm
pdf.setFont(bold_font, 10)
pdf.drawString(left + 1 * mm, cursor_y, "Образец заполнения платежного поручения")
cursor_y -= 2.2 * mm
bank_table = Table(
[
[f"ИНН {issuer_inn}", f"КПП {issuer_kpp}", "", "Сч. №", bank_account],
[f"Получатель\n{issuer_name}", "", "", "", ""],
[f"Банк получателя\n{bank_name}", "", "", "БИК", bank_bik],
["", "", "", "Сч. №", bank_corr_account],
],
colWidths=[37 * mm, 34 * mm, 39 * mm, 25 * mm, 50 * mm],
)
bank_table.setStyle(
TableStyle(
[
("FONT", (0, 0), (-1, -1), regular_font, 9),
("FONT", (0, 0), (2, 0), bold_font, 8),
("GRID", (0, 0), (-1, -1), 0.7, colors.black),
("SPAN", (1, 0), (2, 0)),
("SPAN", (0, 1), (2, 1)),
("SPAN", (0, 2), (2, 3)),
("SPAN", (3, 0), (3, 1)),
("SPAN", (4, 0), (4, 1)),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (3, 0), (3, -1), "CENTER"),
("ALIGN", (4, 0), (4, -1), "LEFT"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
_, bank_table_height = bank_table.wrap(content_width, cursor_y)
bank_table.drawOn(pdf, left, cursor_y - bank_table_height)
cursor_y -= bank_table_height + 5.5 * mm
pdf.setFont(bold_font, 13)
pdf.drawCentredString(page_width / 2, cursor_y, f"СЧЕТ № {invoice_number_display} от {issue_date_compact} года")
cursor_y -= 6.2 * mm
details_table = Table(
[
["Исполнитель", issuer_name],
["Адрес", issuer_address],
["Телефон", issuer_phone],
["Расчетный счет", bank_account],
["Банк", bank_name],
["БИК", bank_bik],
["Корр. счет", bank_corr_account],
["ИНН", issuer_inn],
["КПП", issuer_kpp],
["ОГРН", issuer_ogrn],
],
colWidths=[30 * mm, content_width - 30 * mm],
)
details_table.setStyle(
TableStyle(
[
("FONT", (0, 0), (-1, -1), regular_font, 9),
("GRID", (0, 0), (-1, -1), 0.7, colors.black),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]
)
)
_, details_table_height = details_table.wrap(content_width, cursor_y)
details_table.drawOn(pdf, left, cursor_y - details_table_height)
cursor_y -= details_table_height + 5 * mm
pdf.line(left, cursor_y, page_width - left, cursor_y)
cursor_y -= 2.4 * mm
item_name_width = 95 * mm - 8
wrapped_service = "\n".join(simpleSplit(service_description, regular_font, 9, item_name_width) or [service_description])
item_table = Table(
[
["\nПП", "Наименование", "Кол-во", "Цена\n(за единицу)", "ВСЕГО"],
["1", wrapped_service, "1", amount_text, amount_text],
["ВСЕГО", "", "", "", amount_text],
],
colWidths=[13 * mm, 95 * mm, 18 * mm, 27 * mm, 28 * mm],
)
item_table.setStyle(
TableStyle(
[
("FONT", (0, 0), (-1, -1), regular_font, 9),
("FONT", (0, 0), (-1, 0), bold_font, 9),
("FONT", (0, 2), (4, 2), bold_font, 9),
("GRID", (0, 0), (-1, -1), 0.7, colors.black),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (2, 0), (4, -1), "CENTER"),
("ALIGN", (3, 1), (4, -1), "RIGHT"),
("SPAN", (0, 2), (3, 2)),
("ALIGN", (0, 2), (3, 2), "LEFT"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
_, item_table_height = item_table.wrap(content_width, cursor_y)
item_table.drawOn(pdf, left, cursor_y - item_table_height)
cursor_y -= item_table_height + 5.5 * mm
pdf.setFont(regular_font, 9)
prefix = "Сумма прописью: "
pdf.drawString(left, cursor_y, prefix)
prefix_width = pdfmetrics.stringWidth(prefix, regular_font, 9)
pdf.setFont(bold_font, 10)
pdf.drawString(left + prefix_width, cursor_y, f"{amount_words} ({vat_note}).")
cursor_y -= 10 * mm
block_width = min(155 * mm, content_width)
block_left = left + (content_width - block_width) / 2
block_center_x = block_left + block_width / 2
block_top = cursor_y
signature_name = director_name or _DEFAULT_DIRECTOR_NAME
pdf.setFont(regular_font, 11)
pdf.drawString(block_left + 2 * mm, block_top, "С уважением,")
pdf.drawString(block_left + 2 * mm, block_top - 13 * mm, "Генеральный директор")
pdf.drawString(block_left + 2 * mm, block_top - 19 * mm, "ООО «АКБ»")
pdf.drawString(block_left + block_width - 35 * mm, block_top - 19 * mm, signature_name)
if signature_stamp_image_path:
try:
stamp_image = ImageReader(signature_stamp_image_path)
img_w, img_h = stamp_image.getSize()
target_h = 40 * mm
target_w = target_h * (float(img_w) / max(float(img_h), 1.0))
x = block_center_x - target_w / 2
y = max(12 * mm, block_top - 43 * mm)
pdf.drawImage(stamp_image, x, y, width=target_w, height=target_h, mask="auto")
pdf.setFont(regular_font, 11)
pdf.drawString(x + target_w + 3 * mm, y + 6 * mm, "МП")
except Exception:
pdf.drawString(block_center_x + 28 * mm, block_top - 19 * mm, "МП")
else:
pdf.drawString(block_center_x + 28 * mm, block_top - 19 * mm, "МП")
pdf.showPage()
pdf.save()
return buffer.getvalue()
def build_invoice_pdf_bytes(
*,
invoice_number: str,
amount: float,
currency: str,
status: str,
issued_at: datetime | None,
paid_at: datetime | None,
payer_display_name: str,
request_track_number: str,
issued_by_name: str | None,
requisites: dict[str, Any] | None,
) -> bytes:
if REPORTLAB_AVAILABLE:
try:
return _build_reportlab_invoice_pdf_bytes(
invoice_number=invoice_number,
amount=amount,
currency=currency,
status=status,
issued_at=issued_at,
paid_at=paid_at,
payer_display_name=payer_display_name,
request_track_number=request_track_number,
issued_by_name=issued_by_name,
requisites=requisites,
)
except Exception:
# Safety fallback for environments without fonts/reportlab internals.
pass
lines = [
f"Invoice: {invoice_number}",
f"Request: {request_track_number}",
f"Payer: {payer_display_name}",
f"Amount: {amount:.2f} {currency}",
f"Status: {status}",
f"Issued at: {issued_at.isoformat() if issued_at else '-'}",
f"Paid at: {paid_at.isoformat() if paid_at else '-'}",
f"Issued by: {issued_by_name or '-'}",
"Requisites:",
]
req = dict(requisites or {})
if req:
for key in sorted(req.keys()):
lines.append(f"{key}: {req.get(key)}")
else:
lines.append("-")
return _build_legacy_invoice_pdf_bytes(lines)