diff --git a/app/main.py b/app/main.py index bfe2bf3..b9ae202 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,7 @@ from decimal import Decimal from functools import wraps from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort from flask_login import login_required, current_user -from sqlalchemy import extract +from sqlalchemy import extract, or_ from sqlalchemy.exc import IntegrityError from .extensions import db from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule @@ -212,25 +212,140 @@ def orlen_price(): @login_required def orlen_data(): settings = CompanySettings.query.first() - selected_year = request.args.get("year", type=int) or date.today().year - selected_fuels = [f.upper() for f in request.args.getlist("fuel") if f.upper() in FUEL_TYPES] or ["PB95"] + today = date.today() + mode = request.args.get("mode", "history") + if mode not in {"history", "compare"}: + mode = "history" + include_vat = request.args.get("gross") == "1" - show_lpg_regions = request.args.get('show_lpg_regions') == '1' + available_years = [ + year for (year,) in db.session.query(extract("year", OrlenPrice.effective_date)) + .distinct().order_by(extract("year", OrlenPrice.effective_date).desc()).all() + if year is not None + ] + available_years = [int(year) for year in available_years] + if today.year not in available_years: + available_years.insert(0, today.year) + + selected_year = request.args.get("year", type=int) or today.year + selected_fuels = [f.upper() for f in request.args.getlist("fuel") if f.upper() in FUEL_TYPES] or ["PB95"] selected_regions = [r.lower() for r in request.args.getlist("region") if r.lower() in POLISH_REGIONS] - if not selected_regions: selected_regions=[settings.region.lower()] - if not show_lpg_regions: selected_regions=[settings.region.lower()] - query = OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(selected_fuels), extract("year", OrlenPrice.effective_date)==selected_year) - rows = query.order_by(OrlenPrice.effective_date, OrlenPrice.fuel_type).all() + if not selected_regions: + selected_regions = [settings.region.lower()] + + rows = [] chart = {} - for fuel in selected_fuels: - fuel_rows = [r for r in rows if r.fuel_type == fuel and (fuel != "LPG" or r.region in selected_regions)] - chart_key = fuel if fuel != "LPG" else None - if fuel == "LPG": - for region in selected_regions: - chart[f"LPG · {region}"] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * (1 + float(effective_vat_rate(settings.vat_rate, r.effective_date)) / 100 if include_vat else 1), 4)} for r in fuel_rows if r.region == region] + comparison_summary = [] + comparison_type = request.args.get("comparison_type", "years") + compare_fuel = request.args.get("compare_fuel", "PB95").upper() + if compare_fuel not in FUEL_TYPES: + compare_fuel = "PB95" + compare_region = request.args.get("compare_region", settings.region).lower() + if compare_region not in POLISH_REGIONS: + compare_region = settings.region.lower() + compare_year = request.args.get("compare_year", type=int) or (selected_year - 1) + + def priced_value(row): + multiplier = 1 + float(effective_vat_rate(settings.vat_rate, row.effective_date)) / 100 if include_vat else 1 + return round(float(row.price_per_liter) * multiplier, 4) + + def summary(label, source_rows): + values = [priced_value(row) for row in source_rows] + return { + "label": label, + "count": len(values), + "average": round(sum(values) / len(values), 4) if values else None, + "minimum": min(values) if values else None, + "maximum": max(values) if values else None, + "first": values[0] if values else None, + "last": values[-1] if values else None, + "change": round(values[-1] - values[0], 4) if len(values) > 1 else None, + } + + if mode == "compare": + if comparison_type not in {"years", "periods"}: + comparison_type = "years" + base_query = OrlenPrice.query.filter_by(fuel_type=compare_fuel) + if compare_fuel == "LPG": + base_query = base_query.filter_by(region=compare_region) + + if comparison_type == "years": + year_a, year_b = selected_year, compare_year + all_rows = base_query.filter(extract("year", OrlenPrice.effective_date).in_([year_a, year_b])).order_by(OrlenPrice.effective_date).all() + for year in (year_a, year_b): + period_rows = [row for row in all_rows if row.effective_date.year == year] + chart[str(year)] = [{"date": row.effective_date.strftime("%m-%d"), "value": priced_value(row)} for row in period_rows] + comparison_summary.append(summary(str(year), period_rows)) + rows = all_rows + period_a_from = date(year_a, 1, 1) + period_a_to = date(year_a, 12, 31) + period_b_from = date(year_b, 1, 1) + period_b_to = date(year_b, 12, 31) else: - chart[fuel] = [{"date": r.effective_date.isoformat(), "value": round(float(r.price_per_liter) * (1 + float(effective_vat_rate(settings.vat_rate, r.effective_date)) / 100 if include_vat else 1), 4)} for r in fuel_rows] - return render_template("orlen.html", rows=rows, chart=chart, selected_fuels=selected_fuels, selected_year=selected_year, settings=settings, fuel_types=FUEL_TYPES, include_vat=include_vat, regions=POLISH_REGIONS, selected_regions=selected_regions, show_lpg_regions=show_lpg_regions) + def parse_date_arg(name, fallback): + try: + return date.fromisoformat(request.args.get(name, "")) + except ValueError: + return fallback + + period_a_from = parse_date_arg("period_a_from", today.replace(month=1, day=1)) + period_a_to = parse_date_arg("period_a_to", today) + previous_year = today.year - 1 + period_b_from = parse_date_arg("period_b_from", date(previous_year, period_a_from.month, min(period_a_from.day, 28))) + period_b_to = parse_date_arg("period_b_to", date(previous_year, period_a_to.month, min(period_a_to.day, 28))) + if period_a_from > period_a_to: + period_a_from, period_a_to = period_a_to, period_a_from + if period_b_from > period_b_to: + period_b_from, period_b_to = period_b_to, period_b_from + + ranges = [ + ("Okres A", period_a_from, period_a_to), + ("Okres B", period_b_from, period_b_to), + ] + all_rows = base_query.filter( + or_( + OrlenPrice.effective_date.between(period_a_from, period_a_to), + OrlenPrice.effective_date.between(period_b_from, period_b_to), + ) + ).order_by(OrlenPrice.effective_date).all() + for label, start, end in ranges: + period_rows = [row for row in all_rows if start <= row.effective_date <= end] + chart[label] = [{"date": f"Dzień {(row.effective_date - start).days + 1:03d}", "value": priced_value(row)} for row in period_rows] + comparison_summary.append(summary(f"{label}: {start.isoformat()} – {end.isoformat()}", period_rows)) + rows = all_rows + else: + query = OrlenPrice.query.filter(OrlenPrice.fuel_type.in_(selected_fuels), extract("year", OrlenPrice.effective_date) == selected_year) + rows = query.order_by(OrlenPrice.effective_date, OrlenPrice.fuel_type).all() + for fuel in selected_fuels: + fuel_rows = [row for row in rows if row.fuel_type == fuel and (fuel != "LPG" or row.region in selected_regions)] + if fuel == "LPG": + for region in selected_regions: + chart[f"LPG · {region}"] = [{"date": row.effective_date.isoformat(), "value": priced_value(row)} for row in fuel_rows if row.region == region] + else: + chart[fuel] = [{"date": row.effective_date.isoformat(), "value": priced_value(row)} for row in fuel_rows] + period_a_from = period_a_to = period_b_from = period_b_to = None + + summary_difference = None + if len(comparison_summary) == 2 and comparison_summary[0]["average"] is not None and comparison_summary[1]["average"] is not None: + first_average = comparison_summary[0]["average"] + second_average = comparison_summary[1]["average"] + summary_difference = { + "value": round(first_average - second_average, 4), + "percent": round((first_average - second_average) / second_average * 100, 2) if second_average else None, + } + + return render_template( + "orlen.html", rows=rows, chart=chart, mode=mode, + selected_fuels=selected_fuels, selected_year=selected_year, + compare_year=compare_year, compare_fuel=compare_fuel, + comparison_type=comparison_type, comparison_summary=comparison_summary, + summary_difference=summary_difference, compare_region=compare_region, + period_a_from=period_a_from, period_a_to=period_a_to, + period_b_from=period_b_from, period_b_to=period_b_to, + settings=settings, fuel_types=FUEL_TYPES, include_vat=include_vat, + regions=POLISH_REGIONS, selected_regions=selected_regions, + available_years=available_years, + ) @main.post("/orlen/sync") @login_required diff --git a/app/static/js/orlen.js b/app/static/js/orlen.js index 422d21c..62dec5e 100644 --- a/app/static/js/orlen.js +++ b/app/static/js/orlen.js @@ -1 +1,2 @@ (()=>{const F=FuelTrack;F.bindLpgRegionPicker=()=>{const select=F.qs('#lpg-region-select');if(!select||select.dataset.choicesBound)return;select.dataset.choicesBound='1';const choices=new Choices(select,{removeItemButton:true,searchEnabled:true,shouldSort:false,position:'bottom',placeholder:true,placeholderValue:'Wybierz województwa',noResultsText:'Brak województwa',noChoicesText:'Brak opcji',itemSelectText:'Wybierz'});const modal=F.qs('#lpgRegionsModal');modal?.addEventListener('hidden.bs.modal',()=>{const count=F.qsa('option:checked',select).length;const badge=F.qs('#lpg-region-count');if(badge)badge.textContent=count?`${count} wybrano`:'Domyślne województwo'})};F.bindOrlenSync=()=>{const btn=F.qs('#orlen-sync-btn');if(!btn||btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',async()=>{const form=F.qs('#orlen-filter-form'),fuels=F.qsa('[name=fuel]:checked',form).map(x=>x.value),year=F.qs('[name=year]',form).value,status=F.qs('#orlen-sync-status');if(!fuels.length){status.textContent='Wybierz co najmniej jedno paliwo.';return}btn.disabled=true;const old=btn.innerHTML;btn.innerHTML='Pobieranie…';try{const regions=F.qsa('[name=region] option:checked',form).map(x=>x.value);const r=await fetch('/api/orlen/sync',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({fuels,year,regions})}),d=await r.json();if(!r.ok)throw new Error(d.error||'Błąd pobierania');status.textContent=d.message;await F.replaceMain(`/orlen?${new URLSearchParams(new FormData(form))}`,{push:true});F.notify(d.message)}catch(e){status.textContent=`Błąd: ${e.message}`;F.notify(e.message,'danger')}finally{btn.disabled=false;btn.innerHTML=old}})}})(); +;(()=>{const F=FuelTrack;F.bindOrlenComparison=()=>{const type=F.qs('#comparison-type'),fuel=F.qs('#compare-fuel');if(type&&!type.dataset.compareBound){type.dataset.compareBound='1';const update=()=>{F.qs('#year-comparison-fields')?.classList.toggle('d-none',type.value!=='years');F.qs('#period-comparison-fields')?.classList.toggle('d-none',type.value!=='periods')};type.addEventListener('change',update);update()}if(fuel&&!fuel.dataset.compareBound){fuel.dataset.compareBound='1';const update=()=>F.qs('#compare-region-wrap')?.classList.toggle('d-none',fuel.value!=='LPG');fuel.addEventListener('change',update);update()}}})(); diff --git a/app/templates/orlen.html b/app/templates/orlen.html index e26f312..2571a8c 100644 --- a/app/templates/orlen.html +++ b/app/templates/orlen.html @@ -1,8 +1,52 @@ -{% extends 'base.html' %}{% block title %}Ceny Orlen{% endblock %}{% block content %} -

Dane cenowe Orlen

Porównuj kilka paliw na jednym wykresie i zapisuj dane w bazie.

-
{% for f in fuel_types %}{% endfor %}
{% if selected_regions %}{{selected_regions|length}} wybrano{% else %}Domyślne województwo{% endif %}
{% if current_user.role in ['boss','admin'] %}
{% endif %}
+{% extends 'base.html' %} +{% block title %}Ceny Orlen{% endblock %} +{% block content %} + + + + +{% if mode == 'history' %} +
+
+ +
{% for f in fuel_types %}{% endfor %}
+
{% if selected_regions %}{{selected_regions|length}} wybrano{% else %}Domyślne województwo{% endif %}
+
+
+
+
+{% if current_user.role in ['boss','admin'] %}
{% endif %} +
-

Trend ceny {% if include_vat %}brutto{% else %}netto{% endif %} za litr

Brak danych dla wybranych filtrów.
+{% else %} +
+
+ +
Porównanie zawsze dotyczy jednego rodzaju paliwa.
+
+
+ +
+ +
+ +
+
+
+
+ +{% if comparison_summary %}
{% for item in comparison_summary %}
{{item.label}}{% if item.average is not none %}{{'%.4f'|format(item.average)}} zł/l{% else %}Brak danych{% endif %}{% if item.count %}
Min. {{'%.4f'|format(item.minimum)}} · Maks. {{'%.4f'|format(item.maximum)}} · {{item.count}} pomiarów
{% endif %}
{% endfor %}
{% endif %} +{% if summary_difference %}
Średnia w pierwszym okresie jest {% if summary_difference.value > 0 %}wyższa{% elif summary_difference.value < 0 %}niższa{% else %}taka sama{% endif %} o {{'%.4f'|format(summary_difference.value|abs)}} zł/l{% if summary_difference.percent is not none %} ({{'%.2f'|format(summary_difference.percent|abs)}}%){% endif %}.
{% endif %} +{% endif %} + +

{% if mode == 'compare' %}Porównanie: {{compare_fuel}}{% else %}Trend ceny{% endif %} {% if include_vat %}brutto{% else %}netto{% endif %}

Cena za litr w złotych.

Brak danych dla wybranych filtrów.
+
{% for r in rows|reverse %}{% else %}{% endfor %}
DataPaliwoCena netto zł/lRegionPobrano
{{r.effective_date}}{{r.product_name}}{{'%.4f'|format(r.price_per_liter|float)}}{{r.region or '—'}}{{r.fetched_at.strftime('%Y-%m-%d %H:%M')}}
Brak danych w bazie.
-{% endblock %}{% block scripts %}{% endblock %} +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py index a999a01..db1d8f3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -316,3 +316,36 @@ def test_cannot_delete_assigned_fuel_card(tmp_path): assert response.status_code == 409 assert response.json['ok'] is False assert 'użytkowników' in response.json['message'] + + +def test_orlen_page_has_history_and_comparison_modes(tmp_path): + app = make_app(tmp_path, "orlen-ui.db") + client = app.test_client() + client.post('/login', data={'email':'admin@example.com','password':'admin123!'}) + history = client.get('/orlen?mode=history').get_data(as_text=True) + assert 'Przegląd danych' in history + assert 'Porównanie okresów' in history + comparison = client.get('/orlen?mode=compare&compare_fuel=PB98&comparison_type=years&year=2026&compare_year=2025').get_data(as_text=True) + assert 'Porównanie zawsze dotyczy jednego rodzaju paliwa.' in comparison + assert 'Porównanie: PB98' in comparison + assert 'Dowolne okresy' in comparison + +def test_orlen_comparison_filters_to_one_fuel(tmp_path): + from datetime import date + from decimal import Decimal + from app.extensions import db + from app.models import OrlenPrice + app = make_app(tmp_path, "orlen-compare.db") + client = app.test_client() + client.post('/login', data={'email':'admin@example.com','password':'admin123!'}) + with app.app_context(): + db.session.add_all([ + OrlenPrice(fuel_type='PB95', effective_date=date(2025,1,2), price_per_liter=Decimal('4.1000'), raw_value=Decimal('4100'), region='', product_name='PB95', source='test'), + OrlenPrice(fuel_type='PB95', effective_date=date(2026,1,2), price_per_liter=Decimal('4.5000'), raw_value=Decimal('4500'), region='', product_name='PB95', source='test'), + OrlenPrice(fuel_type='DIESEL', effective_date=date(2026,1,2), price_per_liter=Decimal('5.0000'), raw_value=Decimal('5000'), region='', product_name='DIESEL', source='test'), + ]) + db.session.commit() + html = client.get('/orlen?mode=compare&compare_fuel=PB95&comparison_type=years&year=2026&compare_year=2025').get_data(as_text=True) + assert '4.5000' in html + assert '4.1000' in html + assert '>DIESEL' not in html