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
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:
parent
dc4b6d0d64
commit
4ac39471f6
@ -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
166
templates/reports/fees.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user