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
Shows: total active, paying members, subsidiaries (fee in parent), resignations, standard vs premium fee tier counts. All dynamic from database. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""
|
|
Reports Routes
|
|
==============
|
|
|
|
Business analytics and reporting endpoints.
|
|
"""
|
|
|
|
from datetime import datetime, date
|
|
from flask import render_template, request, url_for
|
|
from flask_login import login_required
|
|
from utils.decorators import member_required, rada_member_required
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
from . import bp
|
|
from database import SessionLocal, Company, Category, CompanySocialMedia
|
|
|
|
|
|
@bp.route('/', endpoint='reports_index')
|
|
@login_required
|
|
@member_required
|
|
def index():
|
|
"""Lista dostępnych raportów."""
|
|
reports = [
|
|
{
|
|
'id': 'staz-czlonkostwa',
|
|
'title': 'Staż członkostwa w Izbie NORDA',
|
|
'description': 'Zestawienie firm według daty przystąpienia do Izby. Pokazuje historię i lojalność członków.',
|
|
'icon': '🏆',
|
|
'url': url_for('.report_membership')
|
|
},
|
|
{
|
|
'id': 'social-media',
|
|
'title': 'Pokrycie Social Media',
|
|
'description': 'Analiza obecności firm w mediach społecznościowych: Facebook, Instagram, LinkedIn, YouTube, TikTok, X.',
|
|
'icon': '📱',
|
|
'url': url_for('.report_social_media')
|
|
},
|
|
{
|
|
'id': 'struktura-branzowa',
|
|
'title': 'Struktura branżowa',
|
|
'description': 'Rozkład firm według kategorii działalności: IT, Budownictwo, Usługi, Produkcja, Handel.',
|
|
'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)
|
|
|
|
|
|
@bp.route('/staz-czlonkostwa', endpoint='report_membership')
|
|
@login_required
|
|
@member_required
|
|
def membership():
|
|
"""Raport: Staż członkostwa w Izbie NORDA."""
|
|
db = SessionLocal()
|
|
try:
|
|
# Firmy z member_since, posortowane od najstarszego
|
|
companies = db.query(Company).filter(
|
|
Company.member_since.isnot(None)
|
|
).order_by(Company.member_since.asc()).all()
|
|
|
|
# Statystyki
|
|
today = date.today()
|
|
stats = {
|
|
'total_with_date': len(companies),
|
|
'total_without_date': db.query(Company).filter(
|
|
Company.member_since.is_(None)
|
|
).count(),
|
|
'oldest': companies[0] if companies else None,
|
|
'newest': companies[-1] if companies else None,
|
|
'avg_years': sum(
|
|
(today - c.member_since).days / 365.25
|
|
for c in companies
|
|
) / len(companies) if companies else 0
|
|
}
|
|
|
|
# Dodaj obliczony staż do każdej firmy
|
|
for c in companies:
|
|
c.membership_years = int((today - c.member_since).days / 365.25)
|
|
|
|
# Dodaj też do oldest i newest
|
|
if stats['oldest']:
|
|
stats['oldest'].membership_years = int((today - stats['oldest'].member_since).days / 365.25)
|
|
|
|
return render_template(
|
|
'reports/membership.html',
|
|
companies=companies,
|
|
stats=stats,
|
|
generated_at=datetime.now()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/social-media', endpoint='report_social_media')
|
|
@login_required
|
|
@member_required
|
|
def social_media():
|
|
"""Raport: Pokrycie Social Media."""
|
|
db = SessionLocal()
|
|
try:
|
|
# Wszystkie firmy z ich profilami social media
|
|
companies = db.query(Company).options(
|
|
joinedload(Company.social_media_profiles)
|
|
).order_by(Company.name).all()
|
|
|
|
platforms = ['facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'twitter']
|
|
|
|
# Statystyki platform
|
|
platform_stats = {}
|
|
for platform in platforms:
|
|
count = db.query(CompanySocialMedia).filter_by(
|
|
platform=platform
|
|
).count()
|
|
platform_stats[platform] = {
|
|
'count': count,
|
|
'percent': round(count / len(companies) * 100, 1) if companies else 0
|
|
}
|
|
|
|
# Firmy z min. 1 profilem
|
|
companies_with_social = [
|
|
c for c in companies if c.social_media_profiles
|
|
]
|
|
|
|
stats = {
|
|
'total_companies': len(companies),
|
|
'with_social': len(companies_with_social),
|
|
'without_social': len(companies) - len(companies_with_social),
|
|
'coverage_percent': round(
|
|
len(companies_with_social) / len(companies) * 100, 1
|
|
) if companies else 0
|
|
}
|
|
|
|
return render_template(
|
|
'reports/social_media.html',
|
|
companies=companies,
|
|
platforms=platforms,
|
|
platform_stats=platform_stats,
|
|
stats=stats,
|
|
generated_at=datetime.now()
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@bp.route('/struktura-branzowa', endpoint='report_categories')
|
|
@login_required
|
|
@member_required
|
|
def categories():
|
|
"""Raport: Struktura branżowa."""
|
|
db = SessionLocal()
|
|
try:
|
|
# Grupowanie po category_id (kolumna FK, nie relacja)
|
|
category_counts = db.query(
|
|
Company.category_id,
|
|
func.count(Company.id).label('count')
|
|
).group_by(Company.category_id).all()
|
|
|
|
total = sum(c.count for c in category_counts)
|
|
|
|
# Pobierz mapę kategorii (id -> name) jednym zapytaniem
|
|
category_map = {cat.id: cat.name for cat in db.query(Category).all()}
|
|
|
|
categories_list = []
|
|
for cat in category_counts:
|
|
cat_id = cat.category_id
|
|
cat_name = category_map.get(cat_id, 'Brak kategorii') if cat_id else 'Brak kategorii'
|
|
|
|
examples = db.query(Company.name).filter(
|
|
Company.category_id == cat_id
|
|
).limit(3).all()
|
|
|
|
categories_list.append({
|
|
'name': cat_name,
|
|
'count': cat.count,
|
|
'percent': round(cat.count / total * 100, 1) if total else 0,
|
|
'examples': [e.name for e in examples]
|
|
})
|
|
|
|
# Sortuj od największej
|
|
categories_list.sort(key=lambda x: x['count'], reverse=True)
|
|
|
|
return render_template(
|
|
'reports/categories.html',
|
|
categories=categories_list,
|
|
total=total,
|
|
generated_at=datetime.now()
|
|
)
|
|
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()
|
|
|
|
# Membership structure breakdown
|
|
subsidiaries_count = db.query(Company).filter(
|
|
Company.status == 'active',
|
|
Company.fee_included_in_parent == True
|
|
).count()
|
|
paying_companies = db.query(Company).filter(
|
|
Company.status == 'active',
|
|
Company.fee_included_in_parent != True
|
|
).count()
|
|
|
|
# Fee tier breakdown: standard (200) vs premium (300+)
|
|
from sqlalchemy import func, distinct
|
|
standard_fee = 0
|
|
premium_fee = 0
|
|
for company_id, company_fees in by_company.items():
|
|
if company_fees:
|
|
avg_amount = sum(float(f.amount) for f in company_fees) / len(company_fees)
|
|
if avg_amount > 200:
|
|
premium_fee += 1
|
|
else:
|
|
standard_fee += 1
|
|
|
|
resigned_count = db.query(Company).filter(
|
|
Company.membership_status == 'resigned'
|
|
).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(),
|
|
subsidiaries_count=subsidiaries_count,
|
|
paying_companies=paying_companies,
|
|
standard_fee=standard_fee,
|
|
premium_fee=premium_fee,
|
|
resigned_count=resigned_count,
|
|
)
|
|
finally:
|
|
db.close()
|