diff --git a/bober_bbq/admin/suppliers.py b/bober_bbq/admin/suppliers.py index 2427747..4abef9f 100644 --- a/bober_bbq/admin/suppliers.py +++ b/bober_bbq/admin/suppliers.py @@ -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//deliveries//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//statement/") +@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/") +@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) diff --git a/bober_bbq/templates/admin/supplier_delivery_edit.html b/bober_bbq/templates/admin/supplier_delivery_edit.html new file mode 100644 index 0000000..a9a332f --- /dev/null +++ b/bober_bbq/templates/admin/supplier_delivery_edit.html @@ -0,0 +1,77 @@ +{% extends "admin/base.html" %} +{% block page_title %}Редагувати поставку{% endblock %} +{% block content %} +← До постачальника «{{ supplier.name }}» +

✏️ Редагувати поставку

+

Це чиста корекція запису — навіть якщо тут зміниться кількість чи прив'язка до інгредієнта, залишок на складі та історія рухів не перераховуються (склад міняється лише через власні дії на картці інгредієнта).

+ +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + + + {% if ingredients %} + + + {% endif %} + + + + +
+ + Скасувати +
+
+
+{% endblock %} diff --git a/bober_bbq/templates/admin/supplier_detail.html b/bober_bbq/templates/admin/supplier_detail.html index a283f2e..3f2b666 100644 --- a/bober_bbq/templates/admin/supplier_detail.html +++ b/bober_bbq/templates/admin/supplier_detail.html @@ -32,6 +32,26 @@ +
+

📥 Скачати відомість

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ {% if supplied_ingredients %}

Товари цього постачальника

@@ -70,8 +90,9 @@ {{ d.paid_amount }} грн {% if d.debt > 0 %}{{ d.debt }} грн{% else %}0{% endif %} {{ d.delivery_date.strftime('%d.%m.%Y') if d.delivery_date else '—' }} - -
+ + ✏️ +
diff --git a/bober_bbq/templates/admin/suppliers.html b/bober_bbq/templates/admin/suppliers.html index a9d7e9b..e0ffaf6 100644 --- a/bober_bbq/templates/admin/suppliers.html +++ b/bober_bbq/templates/admin/suppliers.html @@ -9,6 +9,26 @@
+
+

📥 Скачати зведення по всіх постачальниках

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
diff --git a/bober_bbq/utils/supplier_reports.py b/bober_bbq/utils/supplier_reports.py new file mode 100644 index 0000000..d4aa45d --- /dev/null +++ b/bober_bbq/utils/supplier_reports.py @@ -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())) diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b1fca3c..0c6ccc9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 diff --git a/requirements.txt b/requirements.txt index 66233da..882ba64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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