Edit supplier deliveries after the fact; downloadable Excel/PDF statements

Two gaps: an unpaid delivery had no way to later be marked paid (or
corrected at all) short of delete-and-recreate, losing history; and
there was no way to hand a supplier or an accountant a statement of
account.

- New GET+POST /suppliers/<id>/deliveries/<id>/edit
  (supplier_delivery_edit.html) — edits every field including
  paid_amount/payment_date. Deliberately a pure ledger correction: like
  the existing delete button already warns, this never touches
  current_stock/StockMovement even if quantity or the ingredient link
  changes — stock only moves through the ingredient's own actions.
- New bober_bbq/utils/supplier_reports.py generates both a per-supplier
  detailed statement and an all-suppliers summary, each in Excel
  (openpyxl, already a dependency) and PDF (new: fpdf2), optionally
  scoped to a delivery-date range. Download buttons added to the
  supplier detail page and the suppliers list.
- PDF needs a real Cyrillic TTF; added fpdf2 to requirements.txt and
  documented (and will install) fonts-dejavu-core on the server rather
  than bundling a font in the repo — DejaVu is freely embeddable,
  Windows fonts are not. A missing font produces a clear flash message,
  not a 500.

Verified with a 33-check smoke test across both features (edit
correctness + stock isolation, date-range filtering on both statement
shapes, real route responses reopened/parsed, and the missing-font
fallback path) plus a full regression rerun of every supplier/inventory
smoke suite from this session.
This commit is contained in:
byrsapty 2026-08-26 20:17:48 +03:00
parent 1481332d3f
commit a3c29095c8
7 changed files with 490 additions and 5 deletions

View file

@ -1,18 +1,28 @@
import json
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
from flask import flash, redirect, render_template, request, send_file, url_for
from bober_bbq.admin import admin_bp
from bober_bbq.admin.authz import admin_required, suppliers_access_required
from bober_bbq.admin.routes import _uk_plural
from bober_bbq.extensions import db
from bober_bbq.models import Ingredient, Supplier, SupplierDelivery
from bober_bbq.utils import supplier_import
from bober_bbq.utils import supplier_import, supplier_reports
from bober_bbq.utils.audit import log_action
from bober_bbq.utils.inventory import inventory_enabled, log_movement
def _parse_date_arg(name: str):
raw = request.args.get(name, "").strip()
if not raw:
return None
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return None
@admin_bp.route("/suppliers")
@suppliers_access_required
def suppliers_list():
@ -251,6 +261,61 @@ def supplier_deliveries_delete(supplier_id, delivery_id):
return redirect(url_for("admin.suppliers_detail", supplier_id=supplier_id))
@admin_bp.route("/suppliers/<int:supplier_id>/deliveries/<int:delivery_id>/edit", methods=["GET", "POST"])
@suppliers_access_required
def supplier_deliveries_edit(supplier_id, delivery_id):
"""Pure ledger correction — even if quantity or the ingredient link
changes here, current_stock/StockMovement are NOT touched (same
principle already stated on the delete button: this table doesn't
control stock, _apply_stock_topup did that once, at add-time)."""
delivery = SupplierDelivery.query.filter_by(id=delivery_id, supplier_id=supplier_id).first_or_404()
supplier = delivery.supplier
if request.method == "POST":
product_name = request.form.get("product_name", "").strip()
quantity = request.form.get("quantity", type=float)
if not product_name or not quantity or quantity <= 0:
flash("Вкажіть назву товару й кількість", "error")
return redirect(url_for("admin.supplier_deliveries_edit", supplier_id=supplier_id, delivery_id=delivery_id))
price = request.form.get("price_per_unit", type=float)
delivery_sum = request.form.get("delivery_sum", type=float)
if delivery_sum is None and price is not None:
delivery_sum = round(quantity * price, 2)
if delivery_sum is None:
flash("Вкажіть суму поставки або ціну за одиницю", "error")
return redirect(url_for("admin.supplier_deliveries_edit", supplier_id=supplier_id, delivery_id=delivery_id))
def parse_date_field(name):
raw = request.form.get(name, "").strip()
if not raw:
return None
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return None
delivery.product_name = product_name
delivery.unit = request.form.get("unit", "").strip()
delivery.quantity = quantity
delivery.price_per_unit = price
delivery.delivery_sum = delivery_sum
delivery.sku = request.form.get("sku", "").strip() or None
delivery.reference = request.form.get("reference", "").strip() or None
delivery.delivery_date = parse_date_field("delivery_date")
delivery.paid_amount = request.form.get("paid_amount", type=float) or 0
delivery.payment_date = parse_date_field("payment_date")
delivery.ingredient_id = request.form.get("ingredient_id", type=int) or None
delivery.note = request.form.get("note", "").strip() or None
db.session.commit()
log_action("supplier_delivery_edit", f"Оновлено запис поставки «{delivery.product_name}» (id {delivery_id})")
flash("Поставку оновлено", "success")
return redirect(url_for("admin.suppliers_detail", supplier_id=supplier_id))
ingredients = Ingredient.query.filter_by(is_active=True).order_by(Ingredient.name).all()
return render_template("admin/supplier_delivery_edit.html", delivery=delivery, supplier=supplier, ingredients=ingredients)
# ---------------------------------------------------------------------------
# Import — one route handles both a fresh upload and re-previewing an
# already-uploaded file with a different sheet/header row (via `token`),
@ -415,3 +480,59 @@ def suppliers_import_confirm():
message += "" + "; ".join(notes[:5]) + ("" if len(notes) > 5 else "")
flash(message, "success" if not notes else "warning")
return redirect(url_for("admin.suppliers_detail", supplier_id=supplier.id))
# ---------------------------------------------------------------------------
# Downloadable statements — per-supplier detail, or a summary across all of
# them, both in Excel and PDF, optionally scoped to a delivery-date range.
# ---------------------------------------------------------------------------
@admin_bp.route("/suppliers/<int:supplier_id>/statement/<fmt>")
@suppliers_access_required
def suppliers_statement(supplier_id, fmt):
if fmt not in ("xlsx", "pdf"):
return ("", 404)
supplier = Supplier.query.get_or_404(supplier_id)
date_from = _parse_date_arg("date_from")
date_to = _parse_date_arg("date_to")
rows, totals = supplier_reports.supplier_statement_rows(supplier, date_from, date_to)
slug = "".join(c if c.isalnum() else "_" for c in supplier.name).strip("_") or "postachalnyk"
filename = f"vidomist-{slug}.{fmt}"
if fmt == "xlsx":
buf = supplier_reports.render_statement_xlsx(supplier, rows, totals, date_from, date_to)
mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
try:
buf = supplier_reports.render_statement_pdf(supplier, rows, totals, date_from, date_to)
except supplier_reports.PdfFontMissing as e:
flash(str(e), "error")
return redirect(url_for("admin.suppliers_detail", supplier_id=supplier_id))
mimetype = "application/pdf"
return send_file(buf, mimetype=mimetype, as_attachment=True, download_name=filename)
@admin_bp.route("/suppliers/statements/summary/<fmt>")
@suppliers_access_required
def suppliers_statement_summary(fmt):
if fmt not in ("xlsx", "pdf"):
return ("", 404)
date_from = _parse_date_arg("date_from")
date_to = _parse_date_arg("date_to")
rows, totals = supplier_reports.all_suppliers_summary_rows(date_from, date_to)
filename = f"zvedennya-postachalnykiv.{fmt}"
if fmt == "xlsx":
buf = supplier_reports.render_summary_xlsx(rows, totals, date_from, date_to)
mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
try:
buf = supplier_reports.render_summary_pdf(rows, totals, date_from, date_to)
except supplier_reports.PdfFontMissing as e:
flash(str(e), "error")
return redirect(url_for("admin.suppliers_list"))
mimetype = "application/pdf"
return send_file(buf, mimetype=mimetype, as_attachment=True, download_name=filename)

View file

@ -0,0 +1,77 @@
{% extends "admin/base.html" %}
{% block page_title %}Редагувати поставку{% endblock %}
{% block content %}
<a class="muted" style="font-size:12.5px;" href="{{ url_for('admin.suppliers_detail', supplier_id=supplier.id) }}">← До постачальника «{{ supplier.name }}»</a>
<h1>✏️ Редагувати поставку</h1>
<p class="muted field-hint">Це чиста корекція запису — навіть якщо тут зміниться кількість чи прив'язка до інгредієнта, залишок на складі та історія рухів не перераховуються (склад міняється лише через власні дії на картці інгредієнта).</p>
<div class="card" style="max-width:640px;">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div>
<label>Назва товару</label>
<input type="text" name="product_name" value="{{ delivery.product_name }}" required>
</div>
<div>
<label>Артикул <span class="muted">(необов'язково)</span></label>
<input type="text" name="sku" value="{{ delivery.sku or '' }}">
</div>
</div>
<div class="row">
<div>
<label>Одиниця виміру</label>
<input type="text" name="unit" value="{{ delivery.unit or '' }}" placeholder="кг / грам / шт">
</div>
<div>
<label>Кількість</label>
<input type="number" step="0.01" min="0" name="quantity" value="{{ delivery.quantity }}" required>
</div>
</div>
<div class="row">
<div>
<label>Ціна за одиницю, грн</label>
<input type="number" step="0.01" min="0" name="price_per_unit" value="{{ delivery.price_per_unit if delivery.price_per_unit is not none else '' }}">
</div>
<div>
<label>Сума поставки, грн</label>
<input type="number" step="0.01" min="0" name="delivery_sum" value="{{ delivery.delivery_sum }}">
</div>
</div>
<label>№ накладної / рахунку <span class="muted">(необов'язково)</span></label>
<input type="text" name="reference" value="{{ delivery.reference or '' }}">
<div class="row">
<div>
<label>Дата поставки</label>
<input type="date" name="delivery_date" value="{{ delivery.delivery_date or '' }}">
</div>
<div>
<label>Оплачено, грн</label>
<input type="number" step="0.01" min="0" name="paid_amount" value="{{ delivery.paid_amount }}">
</div>
</div>
<label>Дата оплати <span class="muted">(необов'язково)</span></label>
<input type="date" name="payment_date" value="{{ delivery.payment_date or '' }}">
{% if ingredients %}
<label>Прив'язати до інгредієнта складу <span class="muted">(необов'язково)</span></label>
<select name="ingredient_id">
<option value="">— не прив'язувати —</option>
{% for i in ingredients %}
<option value="{{ i.id }}" {{ 'selected' if delivery.ingredient_id == i.id }}>{{ i.name }} ({{ i.unit }})</option>
{% endfor %}
</select>
{% endif %}
<label>Примітка</label>
<input type="text" name="note" value="{{ delivery.note or '' }}">
<div style="margin-top:16px;display:flex;gap:10px;">
<button class="btn" type="submit">Зберегти</button>
<a class="btn secondary" href="{{ url_for('admin.suppliers_detail', supplier_id=supplier.id) }}">Скасувати</a>
</div>
</form>
</div>
{% endblock %}

View file

@ -32,6 +32,26 @@
</div>
</div>
<div class="card" style="max-width:520px;">
<h3 style="margin-top:0;">📥 Скачати відомість</h3>
<form method="get" action="{{ url_for('admin.suppliers_statement', supplier_id=supplier.id, fmt='xlsx') }}">
<div class="row">
<div>
<label>З дати <span class="muted">(необов'язково)</span></label>
<input type="date" name="date_from">
</div>
<div>
<label>По дату <span class="muted">(необов'язково)</span></label>
<input type="date" name="date_to">
</div>
</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn secondary small" type="submit">📊 Excel</button>
<button class="btn secondary small" type="submit" formaction="{{ url_for('admin.suppliers_statement', supplier_id=supplier.id, fmt='pdf') }}">📄 PDF</button>
</div>
</form>
</div>
{% if supplied_ingredients %}
<h2>Товари цього постачальника</h2>
<div class="card">
@ -70,8 +90,9 @@
<td data-label="Оплачено">{{ d.paid_amount }} грн</td>
<td data-label="Борг">{% if d.debt > 0 %}<span style="color:var(--danger);">{{ d.debt }} грн</span>{% else %}<span class="muted">0</span>{% endif %}</td>
<td data-label="Дата" class="muted">{{ d.delivery_date.strftime('%d.%m.%Y') if d.delivery_date else '—' }}</td>
<td data-label="">
<form method="post" action="{{ url_for('admin.supplier_deliveries_delete', supplier_id=supplier.id, delivery_id=d.id) }}" onsubmit="return confirm('Видалити цей запис поставки? Це не поверне сировину на склад, якщо вона була зарахована.');">
<td data-label="" style="white-space:nowrap;">
<a class="btn small secondary" href="{{ url_for('admin.supplier_deliveries_edit', supplier_id=supplier.id, delivery_id=d.id) }}" title="Редагувати">✏️</a>
<form method="post" action="{{ url_for('admin.supplier_deliveries_delete', supplier_id=supplier.id, delivery_id=d.id) }}" onsubmit="return confirm('Видалити цей запис поставки? Це не поверне сировину на склад, якщо вона була зарахована.');" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn small danger" type="submit"></button>
</form>

View file

@ -9,6 +9,26 @@
</div>
</div>
<div class="card" style="margin-top:16px;max-width:520px;">
<h3 style="margin-top:0;">📥 Скачати зведення по всіх постачальниках</h3>
<form method="get" action="{{ url_for('admin.suppliers_statement_summary', fmt='xlsx') }}">
<div class="row">
<div>
<label>З дати <span class="muted">(необов'язково)</span></label>
<input type="date" name="date_from">
</div>
<div>
<label>По дату <span class="muted">(необов'язково)</span></label>
<input type="date" name="date_to">
</div>
</div>
<div style="display:flex;gap:10px;margin-top:10px;">
<button class="btn secondary small" type="submit">📊 Excel</button>
<button class="btn secondary small" type="submit" formaction="{{ url_for('admin.suppliers_statement_summary', fmt='pdf') }}">📄 PDF</button>
</div>
</form>
</div>
<div class="card" style="margin-top:16px;">
<div class="table-wrap">
<table class="responsive-table">

View file

@ -0,0 +1,241 @@
"""Downloadable Excel/PDF statements for suppliers — one supplier's
detailed ledger, or a summary across all of them, optionally scoped to a
date range. Reused by both the per-supplier and the all-suppliers-summary
routes in bober_bbq/admin/suppliers.py.
PDF needs a real Cyrillic-capable TTF (fpdf2, like any PDF library, draws
no glyphs on its own). We deliberately don't bundle a font in this repo —
that's either a licensing problem (Windows fonts) or unnecessary repo
weight. DejaVu Sans is the standard, freely-embeddable choice, and ships
as the Debian package `fonts-dejavu-core` see docs/DEPLOYMENT.md.
"""
import io
import os
from datetime import date
import openpyxl
from fpdf import FPDF
from openpyxl.styles import Font
from bober_bbq.models import Supplier, SupplierDelivery
DEJAVU_REGULAR = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
DEJAVU_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
class PdfFontMissing(Exception):
"""Raised when no Cyrillic-capable TTF is available on this server."""
def _pdf_font_paths() -> tuple[str, str]:
# PDF_FONT_PATH/PDF_FONT_BOLD_PATH let tests/dev point at any local
# Cyrillic TTF pair without needing DejaVu specifically installed. In
# production both DejaVuSans.ttf and DejaVuSans-Bold.ttf come from the
# same apt package, so it's fine to require both together.
regular = os.environ.get("PDF_FONT_PATH") or DEJAVU_REGULAR
bold = os.environ.get("PDF_FONT_BOLD_PATH") or DEJAVU_BOLD
if not os.path.exists(regular) or not os.path.exists(bold):
raise PdfFontMissing(
"Не вдалося згенерувати PDF — на сервері відсутній шрифт із кириличним "
"набором символів. Встановіть: sudo apt install fonts-dejavu-core"
)
return regular, bold
def _fmt_num(value) -> str:
if value is None:
return ""
return f"{value:g}"
def _fmt_date(value) -> str:
return value.strftime("%d.%m.%Y") if value else ""
def _period_label(date_from: date | None, date_to: date | None) -> str:
return f"{date_from.strftime('%d.%m.%Y') if date_from else 'початок'}{date_to.strftime('%d.%m.%Y') if date_to else 'сьогодні'}"
# ---------------------------------------------------------------------------
# Data gathering — shared by both xlsx and pdf renderers, both report shapes
# ---------------------------------------------------------------------------
def supplier_statement_rows(supplier: Supplier, date_from: date | None = None, date_to: date | None = None):
query = SupplierDelivery.query.filter_by(supplier_id=supplier.id)
if date_from:
query = query.filter(SupplierDelivery.delivery_date >= date_from)
if date_to:
query = query.filter(SupplierDelivery.delivery_date <= date_to)
rows = query.order_by(SupplierDelivery.delivery_date.asc().nulls_first(), SupplierDelivery.id.asc()).all()
delivered = round(sum(r.delivery_sum for r in rows), 2)
paid = round(sum(r.paid_amount for r in rows), 2)
totals = {"delivered": delivered, "paid": paid, "debt": round(delivered - paid, 2)}
return rows, totals
def all_suppliers_summary_rows(date_from: date | None = None, date_to: date | None = None):
"""One row per supplier with any activity in the period (delivered or
paid) suppliers untouched in this window are omitted so the summary
doesn't fill up with zero-rows. Includes inactive suppliers: debt
doesn't disappear when a supplier is deactivated."""
summary = []
for supplier in Supplier.query.order_by(Supplier.name).all():
_, totals = supplier_statement_rows(supplier, date_from, date_to)
if totals["delivered"] == 0 and totals["paid"] == 0:
continue
summary.append({"supplier": supplier, **totals})
grand_totals = {
"delivered": round(sum(r["delivered"] for r in summary), 2),
"paid": round(sum(r["paid"] for r in summary), 2),
"debt": round(sum(r["debt"] for r in summary), 2),
}
return summary, grand_totals
# ---------------------------------------------------------------------------
# Excel
# ---------------------------------------------------------------------------
def render_statement_xlsx(supplier: Supplier, rows, totals, date_from, date_to) -> io.BytesIO:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Відомість"
bold = Font(bold=True)
ws.append([f"Відомість по постачальнику: {supplier.name}"])
ws["A1"].font = Font(bold=True, size=14)
if supplier.tax_id:
ws.append([f"ЄДРПОУ/ІПН: {supplier.tax_id}"])
ws.append([f"Період: {_period_label(date_from, date_to)}"])
ws.append([])
header_idx = ws.max_row + 1
ws.append(["Дата", "Товар", "Артикул", "№ накладної", "Кількість", "Од.", "Ціна", "Сума", "Оплачено", "Борг"])
for cell in ws[header_idx]:
cell.font = bold
for d in rows:
ws.append([_fmt_date(d.delivery_date), d.product_name, d.sku or "", d.reference or "", d.quantity, d.unit, d.price_per_unit, d.delivery_sum, d.paid_amount, d.debt])
ws.append([])
ws.append(["", "", "", "", "", "", "Разом:", totals["delivered"], totals["paid"], totals["debt"]])
for cell in ws[ws.max_row]:
cell.font = bold
for i, width in enumerate([12, 28, 12, 14, 10, 6, 10, 12, 12, 12], start=1):
ws.column_dimensions[chr(64 + i)].width = width
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf
def render_summary_xlsx(rows, totals, date_from, date_to) -> io.BytesIO:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Зведення"
bold = Font(bold=True)
ws.append(["Зведення по постачальниках"])
ws["A1"].font = Font(bold=True, size=14)
ws.append([f"Період: {_period_label(date_from, date_to)}"])
ws.append([])
header_idx = ws.max_row + 1
ws.append(["Постачальник", "ЄДРПОУ/ІПН", "Поставлено", "Оплачено", "Борг"])
for cell in ws[header_idx]:
cell.font = bold
for r in rows:
ws.append([r["supplier"].name, r["supplier"].tax_id or "", r["delivered"], r["paid"], r["debt"]])
ws.append([])
ws.append(["Разом:", "", totals["delivered"], totals["paid"], totals["debt"]])
for cell in ws[ws.max_row]:
cell.font = bold
for i, width in enumerate([28, 14, 14, 14, 14], start=1):
ws.column_dimensions[chr(64 + i)].width = width
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf
# ---------------------------------------------------------------------------
# PDF — raises PdfFontMissing if no Cyrillic TTF is available; callers turn
# that into a flash message rather than a 500.
# ---------------------------------------------------------------------------
def _new_pdf(title_lines: list[str]) -> FPDF:
regular, bold = _pdf_font_paths()
pdf = FPDF()
pdf.add_page()
pdf.add_font("DejaVu", "", regular)
pdf.add_font("DejaVu", "B", bold)
for i, line in enumerate(title_lines):
pdf.set_font("DejaVu", style="B" if i == 0 else "", size=14 if i == 0 else 11)
pdf.cell(0, 8, line, new_x="LMARGIN", new_y="NEXT")
pdf.ln(4)
return pdf
def render_statement_pdf(supplier: Supplier, rows, totals, date_from, date_to) -> io.BytesIO:
title_lines = [f"Відомість по постачальнику: {supplier.name}"]
if supplier.tax_id:
title_lines.append(f"ЄДРПОУ/ІПН: {supplier.tax_id}")
title_lines.append(f"Період: {_period_label(date_from, date_to)}")
pdf = _new_pdf(title_lines)
pdf.set_font("DejaVu", size=9)
with pdf.table(col_widths=(18, 42, 18, 20, 16, 16, 18, 20, 18)) as table:
header_row = table.row()
for h in ["Дата", "Товар", "Артикул", "№ накл.", "К-сть", "Ціна", "Сума", "Оплачено", "Борг"]:
header_row.cell(h)
for d in rows:
row = table.row()
row.cell(_fmt_date(d.delivery_date))
row.cell(d.product_name)
row.cell(d.sku or "")
row.cell(d.reference or "")
row.cell(f"{_fmt_num(d.quantity)} {d.unit}")
row.cell(_fmt_num(d.price_per_unit))
row.cell(_fmt_num(d.delivery_sum))
row.cell(_fmt_num(d.paid_amount))
row.cell(_fmt_num(d.debt))
pdf.ln(4)
pdf.set_font("DejaVu", style="B", size=11)
pdf.cell(
0, 8,
f"Разом поставлено: {totals['delivered']:g} грн Оплачено: {totals['paid']:g} грн Борг: {totals['debt']:g} грн",
new_x="LMARGIN", new_y="NEXT",
)
return io.BytesIO(bytes(pdf.output()))
def render_summary_pdf(rows, totals, date_from, date_to) -> io.BytesIO:
pdf = _new_pdf(["Зведення по постачальниках", f"Період: {_period_label(date_from, date_to)}"])
pdf.set_font("DejaVu", size=10)
with pdf.table(col_widths=(55, 30, 30, 30, 30)) as table:
header_row = table.row()
for h in ["Постачальник", "ЄДРПОУ/ІПН", "Поставлено", "Оплачено", "Борг"]:
header_row.cell(h)
for r in rows:
row = table.row()
row.cell(r["supplier"].name)
row.cell(r["supplier"].tax_id or "")
row.cell(_fmt_num(r["delivered"]))
row.cell(_fmt_num(r["paid"]))
row.cell(_fmt_num(r["debt"]))
pdf.ln(4)
pdf.set_font("DejaVu", style="B", size=11)
pdf.cell(
0, 8,
f"Разом: поставлено {totals['delivered']:g} грн, оплачено {totals['paid']:g} грн, борг {totals['debt']:g} грн",
new_x="LMARGIN", new_y="NEXT",
)
return io.BytesIO(bytes(pdf.output()))

View file

@ -21,9 +21,13 @@
## 1. Підготовка сервера
```bash
sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm
sudo apt update && sudo apt install -y python3.11 python3.11-venv nginx certbot python3-certbot-nginx nodejs npm fonts-dejavu-core
```
`fonts-dejavu-core` — потрібен лише для PDF-відомостей постачальників
(кирилична TTF-шрифтова пара для `fpdf2`, `bober_bbq/utils/supplier_reports.py`).
Excel-відомості й решта застосунку без нього працюють нормально.
## 2. Код і залежності
```bash

View file

@ -15,3 +15,4 @@ xlrd>=2.0,<3
google-api-python-client>=2.100,<3
google-auth>=2.23,<3
google-auth-httplib2>=0.2,<1
fpdf2>=2.7,<3