feat(reports): membership fees report for board and council
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Dynamic report at /raporty/skladki showing:
- Yearly execution bar (% collected)
- Company breakdown: fully paid / partial / no payments / wrong amounts
- Monthly execution bars per month
- Summary totals (collected / outstanding / plan)
Access: Rada Izby + OFFICE_MANAGER+. Numbers only, no company names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-20 09:30:42 +01:00
parent dc4b6d0d64
commit 4ac39471f6
2 changed files with 262 additions and 2 deletions

View File

@ -6,9 +6,9 @@ Business analytics and reporting endpoints.
"""
from datetime import datetime, date
from flask import render_template, url_for
from flask import render_template, request, url_for
from flask_login import login_required
from utils.decorators import member_required
from utils.decorators import member_required, rada_member_required
from sqlalchemy import func
from sqlalchemy.orm import joinedload
@ -43,6 +43,13 @@ def index():
'icon': '🏢',
'url': url_for('.report_categories')
},
{
'id': 'skladki',
'title': 'Składki członkowskie',
'description': 'Podsumowanie stanu składek: wykonanie roczne, zaległości, nieprawidłowe kwoty. Raport dla zarządu i Rady Izby.',
'icon': '💰',
'url': url_for('.report_fees'),
},
]
return render_template('reports/index.html', reports=reports)
@ -188,3 +195,90 @@ def categories():
)
finally:
db.close()
@bp.route('/skladki', endpoint='report_fees')
@login_required
@rada_member_required
def fees_report():
"""Raport składek członkowskich — dla zarządu i Rady Izby. Dynamiczny, generowany z bazy."""
from database import MembershipFee
db = SessionLocal()
try:
year = request.args.get('year', datetime.now().year, type=int)
fees = db.query(MembershipFee).filter(MembershipFee.fee_year == year).all()
by_company = {}
for f in fees:
if f.company_id not in by_company:
by_company[f.company_id] = []
by_company[f.company_id].append(f)
total_companies_with_fees = len(by_company)
total_active = db.query(Company).filter(Company.status == 'active').count()
fully_paid = 0
partially_paid = 0
no_payments = 0
wrong_amounts = 0
total_due = 0
total_paid = 0
for company_id, company_fees in by_company.items():
paid_months = sum(1 for f in company_fees if f.status == 'paid')
partial_months = sum(1 for f in company_fees if f.status == 'partial')
total_months = len(company_fees)
for f in company_fees:
total_due += float(f.amount)
total_paid += float(f.amount_paid or 0)
if partial_months > 0:
wrong_amounts += 1
if paid_months == total_months:
fully_paid += 1
elif paid_months == 0 and partial_months == 0:
no_payments += 1
else:
partially_paid += 1
execution_pct = (total_paid / total_due * 100) if total_due > 0 else 0
outstanding = total_due - total_paid
monthly_stats = []
MONTHS_NAMES = {
1: 'Styczeń', 2: 'Luty', 3: 'Marzec', 4: 'Kwiecień',
5: 'Maj', 6: 'Czerwiec', 7: 'Lipiec', 8: 'Sierpień',
9: 'Wrzesień', 10: 'Październik', 11: 'Listopad', 12: 'Grudzień'
}
for m in range(1, 13):
month_fees = [f for f in fees if f.fee_month == m]
if month_fees:
m_due = sum(float(f.amount) for f in month_fees)
m_paid = sum(float(f.amount_paid or 0) for f in month_fees)
m_pct = (m_paid / m_due * 100) if m_due > 0 else 0
monthly_stats.append({'month': m, 'due': int(m_due), 'paid': int(m_paid), 'pct': round(m_pct, 1)})
return render_template(
'reports/fees.html',
year=year,
years=list(range(2022, datetime.now().year + 2)),
total_active=total_active,
total_companies_with_fees=total_companies_with_fees,
fully_paid=fully_paid,
partially_paid=partially_paid,
no_payments=no_payments,
wrong_amounts=wrong_amounts,
total_due=int(total_due),
total_paid=int(total_paid),
outstanding=int(outstanding),
execution_pct=round(execution_pct, 1),
monthly_stats=monthly_stats,
months_names=MONTHS_NAMES,
generated_at=datetime.now(),
)
finally:
db.close()

166
templates/reports/fees.html Normal file
View File

@ -0,0 +1,166 @@
{% extends "base.html" %}
{% block title %}Raport składek {{ year }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.report-container { max-width: 900px; margin: 0 auto; }
.report-header { margin-bottom: var(--spacing-xl); }
.report-header h1 { font-size: var(--font-size-2xl); }
.report-meta { font-size: var(--font-size-sm); color: var(--text-secondary); margin-top: var(--spacing-xs); }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--spacing-md); margin-bottom: var(--spacing-xl); }
.stat-box { background: var(--surface); padding: var(--spacing-md); border-radius: var(--radius-lg); box-shadow: var(--shadow); text-align: center; border-top: 3px solid var(--border); }
.stat-box.green { border-top-color: var(--success); }
.stat-box.orange { border-top-color: var(--warning); }
.stat-box.red { border-top-color: var(--error); }
.stat-box.blue { border-top-color: var(--primary); }
.stat-num { font-size: var(--font-size-2xl); font-weight: 700; color: var(--text-primary); }
.stat-label { font-size: var(--font-size-xs); color: var(--text-secondary); margin-top: 2px; }
.section { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-lg); box-shadow: var(--shadow); margin-bottom: var(--spacing-xl); }
.section h2 { font-size: var(--font-size-lg); margin-bottom: var(--spacing-md); }
.progress-bar-bg { background: #e5e7eb; border-radius: 999px; height: 28px; overflow: hidden; position: relative; }
.progress-bar-fill { height: 100%; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-size: var(--font-size-sm); font-weight: 600; color: white; transition: width 0.5s; }
.progress-bar-fill.green { background: var(--success); }
.progress-bar-fill.orange { background: var(--warning); }
.progress-bar-fill.red { background: var(--error); }
.month-row { display: flex; align-items: center; gap: var(--spacing-md); padding: var(--spacing-sm) 0; border-bottom: 1px solid var(--border); }
.month-row:last-child { border-bottom: none; }
.month-name { width: 90px; font-weight: 500; font-size: var(--font-size-sm); }
.month-bar { flex: 1; }
.month-nums { width: 180px; text-align: right; font-size: var(--font-size-sm); color: var(--text-secondary); }
.category-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }
.cat-card { padding: var(--spacing-md); border-radius: var(--radius); border: 1px solid var(--border); }
.cat-card h3 { font-size: var(--font-size-base); margin-bottom: var(--spacing-xs); }
.cat-num { font-size: var(--font-size-2xl); font-weight: 700; }
.cat-num.green { color: var(--success); }
.cat-num.orange { color: var(--warning); }
.cat-num.red { color: var(--error); }
.cat-num.blue { color: #3b82f6; }
@media (max-width: 640px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.category-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock %}
{% block content %}
<div class="report-container">
<div class="report-header">
<a href="{{ url_for('reports.reports_index') }}" style="color: var(--text-secondary); text-decoration: none; font-size: var(--font-size-sm);">← Raporty</a>
<h1>Składki członkowskie {{ year }}</h1>
<div class="report-meta">
Wygenerowano: {{ generated_at.strftime('%d.%m.%Y %H:%M') }} |
<select onchange="location.href='?year='+this.value" style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px 6px;font-size:var(--font-size-sm);">
{% for y in years %}<option value="{{ y }}" {{ 'selected' if y == year }}>{{ y }}</option>{% endfor %}
</select>
</div>
</div>
<!-- Execution bar -->
<div class="section">
<h2>Wykonanie roczne</h2>
<div style="display:flex;justify-content:space-between;margin-bottom:var(--spacing-xs);font-size:var(--font-size-sm);">
<span>Zebrano: <strong>{{ total_paid }} zł</strong></span>
<span>Do zebrania: <strong>{{ outstanding }} zł</strong></span>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill {{ 'green' if execution_pct >= 70 else 'orange' if execution_pct >= 40 else 'red' }}" style="width: {{ execution_pct }}%; min-width: 40px;">
{{ execution_pct }}%
</div>
</div>
<div style="text-align:center;margin-top:var(--spacing-xs);font-size:var(--font-size-sm);color:var(--text-secondary);">
Plan roczny: {{ total_due }} zł
</div>
</div>
<!-- Summary stats -->
<div class="stats-row">
<div class="stat-box blue">
<div class="stat-num">{{ total_companies_with_fees }}</div>
<div class="stat-label">Firm z danymi</div>
</div>
<div class="stat-box green">
<div class="stat-num">{{ fully_paid }}</div>
<div class="stat-label">Opłacone w całości</div>
</div>
<div class="stat-box orange">
<div class="stat-num">{{ partially_paid }}</div>
<div class="stat-label">Częściowo opłacone</div>
</div>
<div class="stat-box red">
<div class="stat-num">{{ no_payments }}</div>
<div class="stat-label">Brak wpłat</div>
</div>
</div>
<!-- Categories -->
<div class="section">
<h2>Podział firm</h2>
<div class="category-grid">
<div class="cat-card">
<h3>Opłacone w całości</h3>
<div class="cat-num green">{{ fully_paid }}</div>
<div class="stat-label">firm uregulowało składki za cały rok</div>
</div>
<div class="cat-card">
<h3>Częściowo opłacone</h3>
<div class="cat-num orange">{{ partially_paid }}</div>
<div class="stat-label">firm ma wpłaty, ale nie za wszystkie miesiące</div>
</div>
<div class="cat-card">
<h3>Brak wpłat</h3>
<div class="cat-num red">{{ no_payments }}</div>
<div class="stat-label">firm nie dokonało żadnej wpłaty</div>
</div>
<div class="cat-card">
<h3>Nieprawidłowe kwoty</h3>
<div class="cat-num blue">{{ wrong_amounts }}</div>
<div class="stat-label">firm z niedopłatami (niepełne kwoty)</div>
</div>
</div>
</div>
<!-- Monthly breakdown -->
<div class="section">
<h2>Wykonanie miesięczne</h2>
{% for ms in monthly_stats %}
<div class="month-row">
<div class="month-name">{{ months_names[ms.month] }}</div>
<div class="month-bar">
<div class="progress-bar-bg" style="height:20px;">
<div class="progress-bar-fill {{ 'green' if ms.pct >= 70 else 'orange' if ms.pct >= 40 else 'red' }}" style="width:{{ ms.pct }}%;min-width:30px;font-size:11px;">
{{ ms.pct }}%
</div>
</div>
</div>
<div class="month-nums">{{ ms.paid }} / {{ ms.due }} zł</div>
</div>
{% endfor %}
</div>
<!-- Key numbers -->
<div class="section" style="text-align:center;">
<h2>Podsumowanie</h2>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:var(--spacing-lg);margin-top:var(--spacing-md);">
<div>
<div style="font-size:var(--font-size-3xl);font-weight:700;color:var(--success);">{{ total_paid }} zł</div>
<div class="stat-label">Zebrano</div>
</div>
<div>
<div style="font-size:var(--font-size-3xl);font-weight:700;color:var(--warning);">{{ outstanding }} zł</div>
<div class="stat-label">Pozostało do zebrania</div>
</div>
<div>
<div style="font-size:var(--font-size-3xl);font-weight:700;color:var(--primary);">{{ total_due }} zł</div>
<div class="stat-label">Plan roczny</div>
</div>
</div>
</div>
</div>
{% endblock %}