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:
parent
1481332d3f
commit
a3c29095c8
7 changed files with 490 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
77
bober_bbq/templates/admin/supplier_delivery_edit.html
Normal file
77
bober_bbq/templates/admin/supplier_delivery_edit.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
241
bober_bbq/utils/supplier_reports.py
Normal file
241
bober_bbq/utils/supplier_reports.py
Normal 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()))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue